diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..582a9d2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,114 @@ +name: "报告 Bug" +description: "代码出现了非预期的问题、崩溃或报错" +title: "[Bug]: " +labels: ["type: bug", "status: needs info"] +body: + - type: markdown + attributes: + value: | + 请提供尽可能详细的信息,帮助我们快速定位和修复问题。 + - type: checkboxes + id: pre-check + attributes: + label: 提交前确认 + description: 请务必确认以下事项 + options: + - label: 我已搜索过现有的 Issues,确认这不是重复问题 + required: true + - label: 我使用的是最新版本 + required: true + - label: 我已阅读过相关文档 + required: true + - type: dropdown + id: platform + attributes: + label: 使用平台 + description: 选择出现问题的平台 + options: + - Windows + - macOS + - Linux + validations: + required: true + - type: dropdown + id: severity + attributes: + label: 问题严重程度 + description: 这个问题对你的使用造成了多大影响? + options: + - 严重崩溃或数据丢失(无法使用) + - 核心功能受影响(在下一个常规发布中必须修复) + - 边缘场景或轻微问题(等待空闲时修复) + validations: + required: true + - type: textarea + id: description + attributes: + label: 问题描述 + description: 清晰描述你遇到的问题,包括实际发生了什么 + placeholder: 例如:当我点击发送按钮时,应用程序崩溃并显示白屏 + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: 复现步骤 + description: 提供详细的操作步骤,让我们能够重现这个问题 + placeholder: | + 1. 打开应用并登录账号 + 2. 进入聊天页面 + 3. 点击发送按钮 + 4. 观察到应用崩溃 + validations: + required: true + - type: textarea + id: expected-behavior + attributes: + label: 预期行为 + description: 描述你期望的正确行为应该是什么样的 + placeholder: 例如:点击发送按钮后,消息应该正常发送并显示在聊天窗口中 + validations: + required: true + - type: textarea + id: actual-behavior + attributes: + label: 实际行为 + description: 描述实际发生的错误行为 + placeholder: 例如:点击后应用直接崩溃,显示白屏 + validations: + required: true + - type: textarea + id: logs + attributes: + label: 错误日志或截图 + description: 粘贴控制台错误信息、崩溃日志,或拖入截图 + placeholder: 请粘贴完整的错误堆栈信息 + render: shell + - type: input + id: os + attributes: + label: 操作系统版本 + description: 例如:Windows 11 24H2、macOS 15.0、Ubuntu 24.04 + placeholder: Windows 11 24H2 + validations: + required: true + - type: input + id: app-version + attributes: + label: 应用版本 + description: 在关于页面或设置中查看版本号 + placeholder: v1.2.3 + validations: + required: true + - type: input + id: architecture + attributes: + label: 系统架构 + description: 例如:x64、arm64 + placeholder: x64 + - type: textarea + id: additional-context + attributes: + label: 补充信息 + description: 其他可能有助于定位问题的信息 + placeholder: 例如:这个问题是在某次更新后开始出现的,或者只在特定网络环境下出现 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3e9a940 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name:🤔 找不到合适的模板? + url: https://t.me/weflow_cc + about: 如果你的问题不属于上述任何分类,请前往我们的 Telegram 频道与我们交流。 diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml new file mode 100644 index 0000000..554c7f0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -0,0 +1,67 @@ +name: "文档反馈" +description: "文档存在错别字、描述不清晰或缺少必要的示例" +title: "[Docs]: " +labels: ["type: docs"] +body: + - type: markdown + attributes: + value: | + 优秀的文档和代码一样重要。感谢你帮助我们完善文档! + - type: dropdown + id: doc-type + attributes: + label: 文档类型 + description: 问题出现在哪类文档中? + options: + - README 或项目说明 + - 安装部署文档 + - 使用教程 + - API 文档 + - 开发者文档 + - 其他 + validations: + required: true + - type: input + id: doc-link + attributes: + label: 文档位置 + description: 提供文档的 URL 或文件路径 + placeholder: 例如:docs/installation.md 或 https://github.com/xxx/xxx/wiki/xxx + validations: + required: true + - type: dropdown + id: issue-type + attributes: + label: 问题类型 + description: 文档存在什么问题? + options: + - 错别字或语法错误 + - 内容过时或不准确 + - 描述不清晰或有歧义 + - 缺少必要的示例代码 + - 缺少重要的说明或警告 + - 链接失效或错误 + - 其他 + validations: + required: true + - type: textarea + id: issue-desc + attributes: + label: 问题描述 + description: 详细说明文档中存在的问题 + placeholder: 例如:第 3 步中的命令拼写错误,应该是 "npm install" 而不是 "npm instal" + validations: + required: true + - type: textarea + id: suggestion + attributes: + label: 修改建议 + description: 你认为应该如何修改? + placeholder: 例如:建议将"安装依赖"部分补充完整的命令示例,并说明不同操作系统的差异 + validations: + required: true + - type: textarea + id: additional + attributes: + label: 补充说明 + description: 其他需要补充的信息 diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml new file mode 100644 index 0000000..97ff477 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.yml @@ -0,0 +1,78 @@ +name: "功能与体验优化" +description: "对现有的功能逻辑进行优化,或改进用户体验" +title: "[Enhancement]: " +labels: ["type: enhancement"] +body: + - type: markdown + attributes: + value: | + 持续优化是项目进步的动力!请告诉我们哪个现有功能可以做得更好。 + - type: checkboxes + id: pre-check + attributes: + label: 提交前确认 + options: + - label: 我已搜索过现有的 Issues,确认这个优化建议尚未被提出 + required: true + - label: 这是对现有功能的改进,而不是全新功能 + required: true + - type: dropdown + id: category + attributes: + label: 优化类别 + description: 这个优化主要属于哪个方面? + options: + - 性能优化(速度、内存、资源占用) + - 交互体验(操作流程、界面布局) + - 视觉设计(样式、动画、美观度) + - 易用性(降低使用门槛、减少操作步骤) + - 稳定性(减少崩溃、提高可靠性) + - 其他 + validations: + required: true + - type: textarea + id: target + attributes: + label: 目标功能或模块 + description: 你希望优化的具体功能或页面是哪个? + placeholder: 例如:聊天页面的消息加载、设置页面的布局、文件上传功能 + validations: + required: true + - type: textarea + id: current-behavior + attributes: + label: 当前表现 + description: 描述当前功能的不足之处或存在的问题 + placeholder: 例如:消息列表滚动时会出现明显卡顿,加载 100 条消息需要 3 秒 + validations: + required: true + - type: textarea + id: improvement + attributes: + label: 优化建议 + description: 详细说明你的优化方案和预期效果 + placeholder: 例如:建议使用虚拟滚动技术,只渲染可见区域的消息,预计可将加载时间缩短到 0.5 秒以内 + validations: + required: true + - type: textarea + id: benefits + attributes: + label: 优化收益 + description: 这个优化会带来什么具体好处? + placeholder: 例如:提升 80% 的加载速度、减少 50% 的内存占用、降低用户操作步骤从 5 步到 2 步 + validations: + required: true + - type: textarea + id: impact + attributes: + label: 影响范围 + description: 这个优化会影响哪些用户或场景? + placeholder: 例如:所有用户在查看历史消息时都会受益,尤其是群聊消息较多的场景 + - type: checkboxes + id: contribution + attributes: + label: 参与贡献 + options: + - label: 我愿意提交 Pull Request 来实现这个优化 + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..12352b4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,71 @@ +name: "全新功能请求" +description: "提议一个目前项目中完全没有的新特性" +title: "[Feature]: " +labels: ["type: feature"] +body: + - type: markdown + attributes: + value: | + 感谢你为项目提供新想法!详细的需求描述能极大提高该功能被采纳的几率。 + - type: checkboxes + id: pre-check + attributes: + label: 提交前确认 + options: + - label: 我已搜索过现有的 Issues 和 Pull Requests,确认这个功能尚未被提出或实现 + required: true + - label: 这是一个全新的功能,而不是对现有功能的改进 + required: true + - type: dropdown + id: priority + attributes: + label: 功能优先级 + description: 你认为这个功能有多重要? + options: + - 高优先级(核心功能缺失,严重影响使用体验) + - 中优先级(有助于提升使用体验) + - 低优先级(锦上添花的功能) + validations: + required: true + - type: textarea + id: problem + attributes: + label: 问题或痛点 + description: 【为什么需要】你现在做某件事遇到了什么困难?缺少什么能力? + placeholder: 例如:目前无法批量导出聊天记录,每次只能手动复制单条消息,处理 100 条消息需要半小时 + validations: + required: true + - type: textarea + id: solution + attributes: + label: 期望的解决方案 + description: 【怎么实现】详细描述功能的操作流程、界面位置、可选参数等 + placeholder: 例如:在聊天窗口右键菜单添加"导出记录",点击后弹窗可选时间范围、导出格式(TXT/JSON)、筛选用户,最后保存到本地 + validations: + required: true + - type: textarea + id: use-case + attributes: + label: 使用场景 + description: 【什么时候用】你会在哪些具体情况下使用这个功能? + placeholder: 例如:每周五整理工作讨论记录;保存客户沟通记录作为合同依据;备份重要群聊内容 + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: 替代方案 + description: 你目前使用什么临时方案?或者有没有考虑过其他实现方式? + placeholder: 例如:目前只能手动截图或逐条复制粘贴 + - type: textarea + id: reference + attributes: + label: 参考示例 + description: 其他应用中是否有类似功能可以参考? + placeholder: 例如:微信的聊天记录导出功能、Telegram 的导出数据功能 + - type: checkboxes + id: contribution + attributes: + label: 参与贡献 + options: + - label: 我愿意提交 Pull Request 来实现这个功能 diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..2540d6d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,71 @@ +name: "使用答疑" +description: "关于如何配置、如何使用项目的求助" +title: "[Question]: " +labels: ["type: question"] +body: + - type: markdown + attributes: + value: | + 在提问之前,请确保你已经仔细阅读过我们的官方文档。 + - type: checkboxes + id: pre-check + attributes: + label: 提交前确认 + options: + - label: 我已阅读过相关文档 + required: true + - label: 我已搜索过现有的 Issues,没有找到类似问题 + required: true + - type: dropdown + id: question-type + attributes: + label: 问题类型 + description: 你的问题属于哪个方面? + options: + - 安装部署问题 + - 配置相关问题 + - 功能使用问题 + - API 调用问题 + - 错误排查问题 + - 其他 + validations: + required: true + - type: textarea + id: question + attributes: + label: 问题描述 + description: 清晰描述你遇到的问题或疑问 + placeholder: 例如:我在 Windows 系统上安装后无法启动应用,双击图标没有任何反应 + validations: + required: true + - type: textarea + id: attempts + attributes: + label: 已尝试的方法 + description: 你已经尝试过哪些解决方法? + placeholder: 例如:我尝试过重新安装、以管理员身份运行、关闭防火墙,但问题依然存在 + validations: + required: true + - type: textarea + id: environment + attributes: + label: 运行环境 + description: 提供你的系统环境信息 + placeholder: | + 操作系统:Windows 11 + 应用版本:v1.2.3 + 系统架构:x64 + validations: + required: true + - type: textarea + id: code-snippet + attributes: + label: 相关配置或代码 + description: 如果涉及配置或代码问题,请粘贴相关内容 + placeholder: 粘贴你的配置文件或代码片段 + render: javascript + - type: textarea + id: screenshots + attributes: + label: 截图或日志 + description: 如有必要,请提供截图或错误日志 diff --git a/.github/workflows/issue-auto-assign.yml b/.github/workflows/issue-auto-assign.yml new file mode 100644 index 0000000..cc76345 --- /dev/null +++ b/.github/workflows/issue-auto-assign.yml @@ -0,0 +1,84 @@ +name: Issue Auto Assign + +on: + issues: + types: [opened, edited, reopened] + +permissions: + issues: write + +jobs: + assign-by-platform: + runs-on: ubuntu-latest + steps: + - name: Assign issue by selected platform + uses: actions/github-script@v7 + env: + ASSIGNEE_WINDOWS: ${{ vars.ISSUE_ASSIGNEE_WINDOWS }} + ASSIGNEE_MACOS: ${{ vars.ISSUE_ASSIGNEE_MACOS }} + ASSIGNEE_LINUX: ${{ vars.ISSUE_ASSIGNEE_LINUX || 'H3CoF6' }} + with: + script: | + const issue = context.payload.issue; + if (!issue) { + core.info("No issue payload."); + return; + } + + const labels = (issue.labels || []).map((l) => l.name); + if (!labels.includes("type: bug")) { + core.info("Skip non-bug issue."); + return; + } + + const body = issue.body || ""; + const match = body.match(/###\s*(?:使用平台|平台|Platform)\s*\r?\n+([^\r\n]+)/i); + if (!match) { + core.info("No platform field found in issue body."); + return; + } + + const rawPlatform = match[1].trim().toLowerCase(); + let platformKey = null; + if (rawPlatform.includes("windows")) platformKey = "windows"; + if (rawPlatform.includes("macos")) platformKey = "macos"; + if (rawPlatform.includes("linux")) platformKey = "linux"; + + if (!platformKey) { + core.info(`Unrecognized platform value: ${rawPlatform}`); + return; + } + + const parseAssignees = (value) => + (value || "") + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + + const assigneeMap = { + windows: parseAssignees(process.env.ASSIGNEE_WINDOWS), + macos: parseAssignees(process.env.ASSIGNEE_MACOS), + linux: parseAssignees(process.env.ASSIGNEE_LINUX), + }; + + const candidates = assigneeMap[platformKey] || []; + if (candidates.length === 0) { + core.info(`No assignee configured for platform: ${platformKey}`); + return; + } + + const existing = new Set((issue.assignees || []).map((a) => a.login)); + const toAdd = candidates.filter((u) => !existing.has(u)); + if (toAdd.length === 0) { + core.info("All configured assignees already assigned."); + return; + } + + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + assignees: toAdd, + }); + + core.info(`Assigned issue #${issue.number} to: ${toAdd.join(", ")}`); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4c7459..bb7495e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,24 +8,100 @@ on: permissions: contents: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: + release-mac-arm64: + runs-on: macos-14 + + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: "npm" + + - name: Install Dependencies + run: npm install + + - name: Sync version with tag + shell: bash + run: | + VERSION=${GITHUB_REF_NAME#v} + echo "Syncing package.json version to $VERSION" + npm version $VERSION --no-git-tag-version --allow-same-version + + - name: Build Frontend & Type Check + run: | + npx tsc + npx vite build + + - name: Package and Publish macOS arm64 (unsigned DMG) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_IDENTITY_AUTO_DISCOVERY: "false" + run: | + npx electron-builder --mac dmg --arm64 --publish always + + release-linux: + runs-on: ubuntu-latest + + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: "npm" + + - name: Install Dependencies + run: npm install + + - name: Sync version with tag + shell: bash + run: | + VERSION=${GITHUB_REF_NAME#v} + echo "Syncing package.json version to $VERSION" + npm version $VERSION --no-git-tag-version --allow-same-version + + - name: Build Frontend & Type Check + run: | + npx tsc + npx vite build + + - name: Package and Publish Linux + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npx electron-builder --linux --publish always + release: runs-on: windows-latest steps: - name: Check out git repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: 22.12 + node-version: 24 cache: 'npm' - name: Install Dependencies - run: npm ci + run: npm install - name: Sync version with tag shell: bash @@ -45,17 +121,106 @@ jobs: run: | npx electron-builder --publish always - - name: Update Release Notes + release-windows-arm64: + runs-on: windows-latest + + steps: + - name: Check out git repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v5 + with: + node-version: 24 + cache: 'npm' + + - name: Install Dependencies + run: npm install + + - name: Sync version with tag + shell: bash + run: | + VERSION=${GITHUB_REF_NAME#v} + echo "Syncing package.json version to $VERSION" + npm version $VERSION --no-git-tag-version --allow-same-version + + - name: Build Frontend & Type Check + run: | + npx tsc + npx vite build + + - name: Package and Publish Windows arm64 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npx electron-builder --win nsis --arm64 --publish always '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}' + + update-release-notes: + runs-on: ubuntu-latest + needs: + - release-mac-arm64 + - release-linux + - release + - release-windows-arm64 + + steps: + - name: Generate release notes with platform download links env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | - cat < release_notes.md + set -euo pipefail + + TAG="$GITHUB_REF_NAME" + REPO="$GITHUB_REPOSITORY" + RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG" + + ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)" + + pick_asset() { + local pattern="$1" + echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""' + } + + WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')" + WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')" + MAC_ASSET="$(pick_asset "\\.dmg$")" + LINUX_DEB_ASSET="$(pick_asset "\\.deb$")" + LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")" + LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")" + + build_link() { + local name="$1" + if [ -n "$name" ]; then + echo "https://github.com/$REPO/releases/download/$TAG/$name" + fi + } + + WINDOWS_URL="$(build_link "$WINDOWS_ASSET")" + WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")" + MAC_URL="$(build_link "$MAC_ASSET")" + LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")" + LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")" + LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")" + + cat > release_notes.md < 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE EOF - - gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md + + gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md diff --git a/.gitignore b/.gitignore index 8601fb0..dbde240 100644 --- a/.gitignore +++ b/.gitignore @@ -57,11 +57,16 @@ Thumbs.db wcdb/ xkey/ +server/ *info -概述.md chatlab-format.md *.bak AGENTS.md +AGENT.md .claude/ +CLAUDE.md .agents/ -resources/wx_send \ No newline at end of file +resources/wx_send +概述.md +pnpm-lock.yaml +/pnpm-workspace.yaml diff --git a/.npmrc b/.npmrc index 9291011..5e1ea93 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ registry=https://registry.npmmirror.com -electron_mirror=https://npmmirror.com/mirrors/electron/ -electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ +electron-mirror=https://npmmirror.com/mirrors/electron/ +electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ diff --git a/README.md b/README.md index 586b166..7c4637f 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,19 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 - HTTP API 接口(供开发者集成) - 查看完整能力清单:[详细功能](#详细功能清单) +## 支持平台与设备 + + +| 平台 | 设备/架构 | 安装包 | +|------|----------|--------| +| Windows | Windows10+、x64(amd64) | `.exe` | +| macOS | Apple Silicon(M 系列,arm64) | `.dmg` | +| Linux | x64 设备(amd64) | `.deb`、`.tar.gz` | + + ## 快速开始 -若你只想使用成品版本,可前往 Release 下载并安装。 +若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。 ## 详细功能清单 @@ -94,14 +104,8 @@ npm install # 3. 运行应用(开发模式) npm run dev -# 4. 打包可执行文件 -npm run build ``` -打包产物在 `release` 目录下。 - - - ## 致谢 - [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架 diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index c7b1aab..ca2a89a 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -1,33 +1,46 @@ -# WeFlow HTTP API 接口文档 +# WeFlow HTTP API / Push 文档 -WeFlow 提供 HTTP API 服务,支持通过 HTTP 接口查询消息数据,支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出。 +WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。 -## 启用 API 服务 +## 启用方式 -在设置页面 → API 服务 → 点击「启动服务」按钮。 +在应用设置页启用 `API 服务`。 -默认端口:`5031` - -## 基础地址 - -``` -http://127.0.0.1:5031 -``` - ---- +- 默认监听地址:`127.0.0.1` +- 默认端口:`5031` +- 基础地址:`http://127.0.0.1:5031` +- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端 ## 接口列表 -### 1. 健康检查 +- `GET /health` +- `GET /api/v1/health` +- `GET /api/v1/push/messages` +- `GET /api/v1/messages` +- `GET /api/v1/messages/new` +- `GET /api/v1/sessions` +- `GET /api/v1/contacts` +- `GET /api/v1/group-members` +- `GET /api/v1/media/*` -检查 API 服务是否正常运行。 +--- + +## 1. 健康检查 **请求** -``` + +```http GET /health ``` +或 + +```http +GET /api/v1/health +``` + **响应** + ```json { "status": "ok" @@ -36,211 +49,223 @@ GET /health --- -### 2. 获取消息列表 +## 2. 主动推送 -获取指定会话的消息,支持 ChatLab 格式输出。 +通过 SSE 长连接接收新消息事件,端口与 HTTP API 共用。 **请求** + +```http +GET /api/v1/push/messages ``` + +### 说明 + +- 需要先在设置页开启 `HTTP API 服务` +- 同时需要开启 `主动推送` +- 响应类型为 `text/event-stream` +- 新消息事件名固定为 `message.new` +- 建议接收端按 `messageKey` 去重 + +### 事件字段 + +- `event` +- `sessionId` +- `messageKey` +- `avatarUrl` +- `sourceName` +- `groupName`(仅群聊) +- `content` + +### 示例 + +```bash +curl -N "http://127.0.0.1:5031/api/v1/push/messages" +``` + +示例事件: + +```text +event: message.new +data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123456:1760000123:1760000123000:321:wxid_member:1","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]"} +``` + +--- + +## 3. 获取消息 + +读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。 + +**请求** + +```http GET /api/v1/messages ``` -**参数** +### 参数 -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) | -| `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` | -| `offset` | number | ❌ | 偏移量,用于分页,默认 0 | -| `start` | string | ❌ | 开始时间,格式 YYYYMMDD | -| `end` | string | ❌ | 结束时间,格式 YYYYMMDD | -| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) | -| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 | -| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` | -| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti`);`0` 时媒体返回占位符 | -| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian`) | -| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce`) | -| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` | -| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` | +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` | +| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` | +| `offset` | number | 否 | 分页偏移,默认 `0` | +| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 | +| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 | +| `keyword` | string | 否 | 基于消息显示文本过滤 | +| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 | +| `format` | string | 否 | `json` 或 `chatlab` | +| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` | +| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` | +| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` | +| `video` | string | 否 | 在 `media=1` 时控制视频导出 | +| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 | -默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media` - -**示例请求** +### 示例 ```bash -# 获取消息(原始格式) -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50 - -# 获取消息(ChatLab 格式) -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1 - -# 带时间范围查询 -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100 - -# 开启媒体导出(只导出图片和语音) -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0 - -# 关键词过滤 -GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50 +curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20" +curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&chatlab=1" +curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260131" +curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0" ``` -**响应(原始格式)** +### JSON 响应字段 + +顶层字段: + +- `success` +- `talker` +- `count` +- `hasMore` +- `media.enabled` +- `media.exportPath` +- `media.count` +- `messages` + +单条消息字段: + +- `localId` +- `serverId` +- `localType` +- `createTime` +- `isSend` +- `senderUsername` +- `content` +- `rawContent` +- `parsedContent` +- `mediaType` +- `mediaFileName` +- `mediaUrl` +- `mediaLocalPath` + +**示例响应** + ```json { "success": true, - "talker": "wxid_xxx", - "count": 50, + "talker": "xxx@chatroom", + "count": 2, "hasMore": true, "media": { "enabled": true, "exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media", - "count": 12 + "count": 1 }, "messages": [ { "localId": 123, + "serverId": "456", + "localType": 1, + "createTime": 1738713600, + "isSend": 0, + "senderUsername": "wxid_member", + "content": "你好", + "rawContent": "你好", + "parsedContent": "你好" + }, + { + "localId": 124, "localType": 3, + "createTime": 1738713660, + "isSend": 0, + "senderUsername": "wxid_member", "content": "[图片]", - "createTime": 1738713600000, - "senderUsername": "wxid_sender", "mediaType": "image", - "mediaFileName": "image_123.jpg", - "mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg", - "mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg" + "mediaFileName": "abc123.jpg", + "mediaUrl": "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg", + "mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\xxx@chatroom\\images\\abc123.jpg" } ] } ``` -**响应(ChatLab 格式)** -```json -{ - "chatlab": { - "version": "0.0.2", - "exportedAt": 1738713600000, - "generator": "WeFlow", - "description": "Exported from WeFlow" - }, - "meta": { - "name": "会话名称", - "platform": "wechat", - "type": "private", - "ownerId": "wxid_me" - }, - "members": [ - { - "platformId": "wxid_xxx", - "accountName": "用户名", - "groupNickname": "群昵称" - } - ], - "messages": [ - { - "sender": "wxid_xxx", - "accountName": "用户名", - "timestamp": 1738713600000, - "type": 0, - "content": "消息内容", - "mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg" - } - ], - "media": { - "enabled": true, - "exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media", - "count": 12 - } -} -``` +### ChatLab 响应 + +当 `chatlab=1` 或 `format=chatlab` 时,返回 ChatLab 结构: + +- `chatlab.version` +- `chatlab.exportedAt` +- `chatlab.generator` +- `meta.name` +- `meta.platform` +- `meta.type` +- `meta.groupId` +- `meta.groupAvatar` +- `meta.ownerId` +- `members[].platformId` +- `members[].accountName` +- `members[].groupNickname` +- `members[].avatar` +- `messages[].sender` +- `messages[].accountName` +- `messages[].groupNickname` +- `messages[].timestamp` +- `messages[].type` +- `messages[].content` +- `messages[].platformMessageId` +- `messages[].mediaPath` + +群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。 --- -### 3. 访问导出媒体文件 - -通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。 +## 4. 获取会话列表 **请求** -``` -GET /api/v1/media/{relativePath} -``` -**路径参数** - -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| `relativePath` | string | ✅ | 媒体文件的相对路径,如 `wxid_xxx/images/image_123.jpg` | - -**支持的媒体类型** - -| 扩展名 | Content-Type | -|--------|-------------| -| `.png` | image/png | -| `.jpg` / `.jpeg` | image/jpeg | -| `.gif` | image/gif | -| `.webp` | image/webp | -| `.wav` | audio/wav | -| `.mp3` | audio/mpeg | -| `.mp4` | video/mp4 | - -**示例请求** -```bash -# 访问导出的图片 -GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg - -# 访问导出的语音 -GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav - -# 访问导出的视频 -GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4 -``` - -**响应** - -成功时直接返回文件内容,`Content-Type` 根据文件扩展名自动设置。 - -失败时返回: -```json -{ "error": "Media not found" } -``` - -> 注意:媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。 - ---- - -### 4. 获取会话列表 - -获取所有会话列表。 - -**请求** -``` +```http GET /api/v1/sessions ``` -**参数** +### 参数 -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID | -| `limit` | number | ❌ | 返回数量限制,默认 100 | +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `keyword` | string | 否 | 匹配 `username` 或 `displayName` | +| `limit` | number | 否 | 默认 `100` | -**示例请求** -```bash -GET http://127.0.0.1:5031/api/v1/sessions +### 响应字段 -GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20 -``` +- `success` +- `count` +- `sessions[].username` +- `sessions[].displayName` +- `sessions[].type` +- `sessions[].lastTimestamp` +- `sessions[].unreadCount` + +**示例响应** -**响应** ```json { "success": true, - "count": 50, - "total": 100, + "count": 1, "sessions": [ { - "username": "wxid_xxx", - "displayName": "用户名", - "lastMessage": "最后一条消息", - "lastTime": 1738713600000, + "username": "xxx@chatroom", + "displayName": "项目群", + "type": 2, + "lastTimestamp": 1738713600, "unreadCount": 0 } ] @@ -249,40 +274,48 @@ GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20 --- -### 4. 获取联系人列表 - -获取所有联系人信息。 +## 5. 获取联系人列表 **请求** -``` + +```http GET /api/v1/contacts ``` -**参数** +### 参数 -| 参数名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| `keyword` | string | ❌ | 搜索关键词 | -| `limit` | number | ❌ | 返回数量限制,默认 100 | +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` | +| `limit` | number | 否 | 默认 `100` | -**示例请求** -```bash -GET http://127.0.0.1:5031/api/v1/contacts +### 响应字段 -GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三 -``` +- `success` +- `count` +- `contacts[].username` +- `contacts[].displayName` +- `contacts[].remark` +- `contacts[].nickname` +- `contacts[].alias` +- `contacts[].avatarUrl` +- `contacts[].type` + +**示例响应** -**响应** ```json { "success": true, - "count": 50, + "count": 1, "contacts": [ { - "userName": "wxid_xxx", - "alias": "微信号", - "nickName": "昵称", - "remark": "备注名" + "username": "wxid_xxx", + "displayName": "张三", + "remark": "客户张三", + "nickname": "张三", + "alias": "zhangsan", + "avatarUrl": "https://example.com/avatar.jpg", + "type": "friend" } ] } @@ -290,60 +323,157 @@ GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三 --- -## ChatLab 格式说明 +## 6. 获取群成员列表 -ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2。 +返回群成员的 `wxid`、群昵称、备注、微信号等信息。 -### 消息类型映射 +**请求** -| ChatLab Type | 值 | 说明 | -|--------------|-----|------| -| TEXT | 0 | 文本消息 | -| IMAGE | 1 | 图片 | -| VOICE | 2 | 语音 | -| VIDEO | 3 | 视频 | -| FILE | 4 | 文件 | -| EMOJI | 5 | 表情 | -| LINK | 7 | 链接 | -| LOCATION | 8 | 位置 | -| RED_PACKET | 20 | 红包 | -| TRANSFER | 21 | 转账 | -| CALL | 23 | 通话 | -| SYSTEM | 80 | 系统消息 | -| RECALL | 81 | 撤回消息 | -| OTHER | 99 | 其他 | +```http +GET /api/v1/group-members +``` + +### 参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 | +| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 | +| `withCounts` | string | 否 | `includeMessageCounts` 的别名 | +| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 | + +### 响应字段 + +- `success` +- `chatroomId` +- `count` +- `fromCache` +- `updatedAt` +- `members[].wxid` +- `members[].displayName` +- `members[].nickname` +- `members[].remark` +- `members[].alias` +- `members[].groupNickname` +- `members[].avatarUrl` +- `members[].isOwner` +- `members[].isFriend` +- `members[].messageCount` + +**示例请求** + +```bash +curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom" +curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=1" +``` + +**示例响应** + +```json +{ + "success": true, + "chatroomId": "xxx@chatroom", + "count": 2, + "fromCache": false, + "updatedAt": 1760000000000, + "members": [ + { + "wxid": "wxid_member_a", + "displayName": "客户A", + "nickname": "阿甲", + "remark": "客户A", + "alias": "kehua", + "groupNickname": "甲方", + "avatarUrl": "https://example.com/a.jpg", + "isOwner": true, + "isFriend": true, + "messageCount": 128 + }, + { + "wxid": "wxid_member_b", + "displayName": "李四", + "nickname": "李四", + "remark": "", + "alias": "", + "groupNickname": "", + "avatarUrl": "", + "isOwner": false, + "isFriend": false, + "messageCount": 0 + } + ] +} +``` + +说明: + +- `displayName` 是当前应用内的主展示名。 +- `groupNickname` 是成员在该群里的群昵称。 +- `remark` 是你对该联系人的备注。 +- `alias` 是微信号。 +- 当微信源数据里没有群昵称时,`groupNickname` 会为空。 --- -## 使用示例 +## 7. 访问导出媒体 + +通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。 + +**请求** + +```http +GET /api/v1/media/{relativePath} +``` + +### 示例 + +```bash +curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg" +curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/voices/voice_100.wav" +curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/videos/video_200.mp4" +curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif" +``` + +### 支持的 Content-Type + +| 扩展名 | Content-Type | +| --- | --- | +| `.png` | `image/png` | +| `.jpg` / `.jpeg` | `image/jpeg` | +| `.gif` | `image/gif` | +| `.webp` | `image/webp` | +| `.wav` | `audio/wav` | +| `.mp3` | `audio/mpeg` | +| `.mp4` | `video/mp4` | + +常见错误响应: + +```json +{ + "error": "Media not found" +} +``` + +--- + +## 8. 使用示例 ### PowerShell ```powershell -# 健康检查 Invoke-RestMethod http://127.0.0.1:5031/health - -# 获取会话列表 Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions - -# 获取消息 Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10" - -# 获取 ChatLab 格式 -Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10 +Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1" ``` ### cURL ```bash -# 健康检查 curl http://127.0.0.1:5031/health - -# 获取会话列表 -curl http://127.0.0.1:5031/api/v1/sessions - -# 获取消息(ChatLab 格式) curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" +curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三" +curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom" ``` ### Python @@ -353,39 +483,26 @@ import requests BASE_URL = "http://127.0.0.1:5031" -# 获取会话列表 -sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json() -print(sessions) +messages = requests.get( + f"{BASE_URL}/api/v1/messages", + params={"talker": "xxx@chatroom", "limit": 50} +).json() + +members = requests.get( + f"{BASE_URL}/api/v1/group-members", + params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1} +).json() -# 获取消息 -messages = requests.get(f"{BASE_URL}/api/v1/messages", params={ - "talker": "wxid_xxx", - "limit": 100, - "chatlab": 1 -}).json() print(messages) -``` - -### JavaScript / Node.js - -```javascript -const BASE_URL = "http://127.0.0.1:5031"; - -// 获取会话列表 -const sessions = await fetch(`${BASE_URL}/api/v1/sessions`).then(r => r.json()); -console.log(sessions); - -// 获取消息(ChatLab 格式) -const messages = await fetch(`${BASE_URL}/api/v1/messages?talker=wxid_xxx&chatlab=1`) - .then(r => r.json()); -console.log(messages); +print(members) ``` --- -## 注意事项 +## 9. 注意事项 -1. API 仅监听本地地址 `127.0.0.1`,不对外网开放 -2. 需要先连接数据库才能查询数据 -3. 时间参数格式为 `YYYYMMDD`(如 20260205) -4. 支持 CORS,可从浏览器前端直接调用 +1. API 仅监听本机 `127.0.0.1`,不对外网开放。 +2. 使用前需要先在 WeFlow 中完成数据库连接。 +3. `start` 和 `end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD` 的 `end` 会扩展到当天 `23:59:59`。 +4. 群成员的 `groupNickname` 依赖微信源数据;源数据缺失时不会自动补出。 +5. 媒体访问链接只有在对应消息已经通过 `media=1` 导出后才可访问。 diff --git a/electron/entitlements.mac.plist b/electron/entitlements.mac.plist new file mode 100644 index 0000000..02af842 --- /dev/null +++ b/electron/entitlements.mac.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.debugger + + com.apple.security.get-task-allow + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/electron/exportWorker.ts b/electron/exportWorker.ts new file mode 100644 index 0000000..1f98439 --- /dev/null +++ b/electron/exportWorker.ts @@ -0,0 +1,56 @@ +import { parentPort, workerData } from 'worker_threads' +import type { ExportOptions } from './services/exportService' + +interface ExportWorkerConfig { + sessionIds: string[] + outputDir: string + options: ExportOptions + resourcesPath?: string + userDataPath?: string + logEnabled?: boolean +} + +const config = workerData as ExportWorkerConfig +process.env.WEFLOW_WORKER = '1' +if (config.resourcesPath) { + process.env.WCDB_RESOURCES_PATH = config.resourcesPath +} +if (config.userDataPath) { + process.env.WEFLOW_USER_DATA_PATH = config.userDataPath + process.env.WEFLOW_CONFIG_CWD = config.userDataPath +} +process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow' + +async function run() { + const [{ wcdbService }, { exportService }] = await Promise.all([ + import('./services/wcdbService'), + import('./services/exportService') + ]) + + wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '') + wcdbService.setLogEnabled(config.logEnabled === true) + + const result = await exportService.exportSessions( + Array.isArray(config.sessionIds) ? config.sessionIds : [], + String(config.outputDir || ''), + config.options || { format: 'json' }, + (progress) => { + parentPort?.postMessage({ + type: 'export:progress', + data: progress + }) + } + ) + + parentPort?.postMessage({ + type: 'export:result', + data: result + }) +} + +run().catch((error) => { + parentPort?.postMessage({ + type: 'export:error', + error: String(error) + }) +}) diff --git a/electron/imageSearchWorker.ts b/electron/imageSearchWorker.ts index 56826a2..429a00f 100644 --- a/electron/imageSearchWorker.ts +++ b/electron/imageSearchWorker.ts @@ -10,7 +10,7 @@ type WorkerPayload = { thumbOnly: boolean } -type Candidate = { score: number; path: string; isThumb: boolean; hasX: boolean } +type Candidate = { score: number; path: string; isThumb: boolean } const payload = workerData as WorkerPayload @@ -18,16 +18,26 @@ function looksLikeMd5(value: string): boolean { return /^[a-fA-F0-9]{16,32}$/.test(value) } +function stripDatVariantSuffix(base: string): string { + const lower = base.toLowerCase() + const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c'] + for (const suffix of suffixes) { + if (lower.endsWith(suffix)) { + return lower.slice(0, -suffix.length) + } + } + if (/[._][a-z]$/.test(lower)) { + return lower.slice(0, -2) + } + return lower +} + function hasXVariant(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) + return stripDatVariantSuffix(baseLower) !== baseLower } function hasImageVariantSuffix(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) -} - -function isLikelyImageDatBase(baseLower: string): boolean { - return hasImageVariantSuffix(baseLower) || looksLikeMd5(baseLower) + return stripDatVariantSuffix(baseLower) !== baseLower } function normalizeDatBase(name: string): string { @@ -35,10 +45,17 @@ function normalizeDatBase(name: string): string { if (base.endsWith('.dat') || base.endsWith('.jpg')) { base = base.slice(0, -4) } - while (/[._][a-z]$/.test(base)) { - base = base.slice(0, -2) + while (true) { + const stripped = stripDatVariantSuffix(base) + if (stripped === base) { + return base + } + base = stripped } - return base +} + +function isLikelyImageDatBase(baseLower: string): boolean { + return hasImageVariantSuffix(baseLower) || looksLikeMd5(normalizeDatBase(baseLower)) } function matchesDatName(fileName: string, datName: string): boolean { @@ -47,25 +64,23 @@ function matchesDatName(fileName: string, datName: string): boolean { const normalizedBase = normalizeDatBase(base) const normalizedTarget = normalizeDatBase(datName.toLowerCase()) if (normalizedBase === normalizedTarget) return true - const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`) - if (pattern.test(lower)) return true - return lower.endsWith('.dat') && lower.includes(datName) + return lower.endsWith('.dat') && lower.includes(normalizedTarget) } function scoreDatName(fileName: string): number { - if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1 - if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1 - return 2 + const lower = fileName.toLowerCase() + const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower + if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600 + if (!hasXVariant(baseLower)) return 500 + if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450 + if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 + if (isThumbnailDat(lower)) return 100 + return 350 } function isThumbnailDat(fileName: string): boolean { - return fileName.includes('.t.dat') || fileName.includes('_t.dat') -} - -function isHdDat(fileName: string): boolean { const lower = fileName.toLowerCase() - const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower - return base.endsWith('_hd') || base.endsWith('_h') + return lower.includes('.t.dat') || lower.includes('_t.dat') || lower.includes('_thumb.dat') } function walkForDat( @@ -105,20 +120,15 @@ function walkForDat( if (!lower.endsWith('.dat')) continue const baseLower = lower.slice(0, -4) if (!isLikelyImageDatBase(baseLower)) continue - if (!hasXVariant(baseLower)) continue if (!matchesDatName(lower, datName)) continue - // 排除高清图片格式 (_hd, _h) - if (isHdDat(lower)) continue matchedBases.add(baseLower) const isThumb = isThumbnailDat(lower) if (!allowThumbnail && isThumb) continue if (thumbOnly && !isThumb) continue - const score = scoreDatName(lower) candidates.push({ - score, + score: scoreDatName(lower), path: entryPath, - isThumb, - hasX: hasXVariant(baseLower) + isThumb }) } } @@ -126,10 +136,8 @@ function walkForDat( return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) } } - const withX = candidates.filter((item) => item.hasX) - const basePool = withX.length ? withX : candidates - const nonThumb = basePool.filter((item) => !item.isThumb) - const finalPool = thumbOnly ? basePool : (nonThumb.length ? nonThumb : basePool) + const nonThumb = candidates.filter((item) => !item.isThumb) + const finalPool = thumbOnly ? candidates : (nonThumb.length ? nonThumb : candidates) let best: { score: number; path: string } | null = null for (const item of finalPool) { diff --git a/electron/main.ts b/electron/main.ts index 91c6b14..2ebe56b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,9 +1,10 @@ import './preload-env' -import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron' +import { app, BrowserWindow, ipcMain, nativeTheme, session, Tray, Menu, nativeImage } from 'electron' import { Worker } from 'worker_threads' +import { randomUUID } from 'crypto' import { join, dirname } from 'path' import { autoUpdater } from 'electron-updater' -import { readFile, writeFile, mkdir } from 'fs/promises' +import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises' import { existsSync } from 'fs' import { ConfigService } from './services/config' import { dbPathService } from './services/dbPathService' @@ -16,14 +17,19 @@ import { groupAnalyticsService } from './services/groupAnalyticsService' import { annualReportService } from './services/annualReportService' import { exportService, ExportOptions, ExportProgress } from './services/exportService' import { KeyService } from './services/keyService' +import { KeyServiceLinux } from './services/keyServiceLinux' +import { KeyServiceMac } from './services/keyServiceMac' import { voiceTranscribeService } from './services/voiceTranscribeService' import { videoService } from './services/videoService' import { snsService, isVideoUrl } from './services/snsService' import { contactExportService } from './services/contactExportService' import { windowsHelloService } from './services/windowsHelloService' +import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService' +import { cloudControlService } from './services/cloudControlService' -import { registerNotificationHandlers, showNotification } from './windows/notificationWindow' +import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow' import { httpService } from './services/httpService' +import { messagePushService } from './services/messagePushService' // 配置自动更新 @@ -84,23 +90,277 @@ let agreementWindow: BrowserWindow | null = null let onboardingWindow: BrowserWindow | null = null // Splash 启动窗口 let splashWindow: BrowserWindow | null = null -const keyService = new KeyService() +const sessionChatWindows = new Map() +const sessionChatWindowSources = new Map() + +let keyService: any +if (process.platform === 'darwin') { + keyService = new KeyServiceMac() +} else if (process.platform === 'linux') { + // const { KeyServiceLinux } = require('./services/keyServiceLinux') + // keyService = new KeyServiceLinux() + + import('./services/keyServiceLinux').then(({ KeyServiceLinux }) => { + keyService = new KeyServiceLinux(); + }); + +} else { + keyService = new KeyService() +} let mainWindowReady = false let shouldShowMain = true +let isAppQuitting = false +let tray: Tray | null = null +let isClosePromptVisible = false +const chatHistoryPayloadStore = new Map() + +type WindowCloseBehavior = 'ask' | 'tray' | 'quit' // 更新下载状态管理(Issue #294 修复) let isDownloadInProgress = false let downloadProgressHandler: ((progress: any) => void) | null = null let downloadedHandler: (() => void) | null = null +const normalizeReleaseNotes = (rawReleaseNotes: unknown): string => { + const merged = (() => { + if (typeof rawReleaseNotes === 'string') { + return rawReleaseNotes + } + if (Array.isArray(rawReleaseNotes)) { + return rawReleaseNotes + .map((item) => { + if (!item || typeof item !== 'object') return '' + const note = (item as { note?: unknown }).note + return typeof note === 'string' ? note : '' + }) + .filter(Boolean) + .join('\n\n') + } + return '' + })() + + if (!merged.trim()) return '' + + // 兼容 electron-updater 直接返回 HTML 的场景 + const removeDownloadSectionFromHtml = (input: string): string => { + return input.replace( + /]*>\s*(?:下载|download)\s*<\/h[1-6]>\s*[\s\S]*?(?= { + const lines = input.split(/\r?\n/) + const output: string[] = [] + let skipDownloadSection = false + + for (const line of lines) { + const headingMatch = line.match(/^\s*#{1,6}\s*(.+?)\s*$/) + if (headingMatch) { + const heading = headingMatch[1].trim().toLowerCase() + if (heading === '下载' || heading === 'download') { + skipDownloadSection = true + continue + } + if (skipDownloadSection) { + skipDownloadSection = false + } + } + if (!skipDownloadSection) { + output.push(line) + } + } + + return output.join('\n') + } + + const cleaned = removeDownloadSectionFromMarkdown(removeDownloadSectionFromHtml(merged)) + .replace(/\n{3,}/g, '\n\n') + .trim() + + return cleaned +} + +type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid' +type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done' + +interface AnnualReportYearsProgressPayload { + years?: number[] + done: boolean + error?: string + canceled?: boolean + strategy?: AnnualReportYearsLoadStrategy + phase?: AnnualReportYearsLoadPhase + statusText?: string + nativeElapsedMs?: number + scanElapsedMs?: number + totalElapsedMs?: number + switched?: boolean + nativeTimedOut?: boolean +} + +interface AnnualReportYearsTaskState { + cacheKey: string + canceled: boolean + done: boolean + snapshot: AnnualReportYearsProgressPayload + updatedAt: number +} + +interface OpenSessionChatWindowOptions { + source?: 'chat' | 'export' + initialDisplayName?: string + initialAvatarUrl?: string + initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other' +} + +const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => { + return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat' +} + +const normalizeSessionChatWindowOptionString = (value: unknown): string => { + return String(value || '').trim() +} + +const loadSessionChatWindowContent = ( + win: BrowserWindow, + sessionId: string, + source: 'chat' | 'export', + options?: OpenSessionChatWindowOptions +) => { + const queryParams = new URLSearchParams({ + sessionId, + source + }) + const initialDisplayName = normalizeSessionChatWindowOptionString(options?.initialDisplayName) + const initialAvatarUrl = normalizeSessionChatWindowOptionString(options?.initialAvatarUrl) + const initialContactType = normalizeSessionChatWindowOptionString(options?.initialContactType) + if (initialDisplayName) queryParams.set('initialDisplayName', initialDisplayName) + if (initialAvatarUrl) queryParams.set('initialAvatarUrl', initialAvatarUrl) + if (initialContactType) queryParams.set('initialContactType', initialContactType) + const query = queryParams.toString() + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${query}`) + return + } + win.loadFile(join(__dirname, '../dist/index.html'), { + hash: `/chat-window?${query}` + }) +} + +const annualReportYearsLoadTasks = new Map() +const annualReportYearsTaskByCacheKey = new Map() +const annualReportYearsSnapshotCache = new Map() +const annualReportYearsSnapshotTtlMs = 10 * 60 * 1000 + +const normalizeAnnualReportYearsSnapshot = (snapshot: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload => { + const years = Array.isArray(snapshot.years) ? [...snapshot.years] : [] + return { ...snapshot, years } +} + +const buildAnnualReportYearsCacheKey = (dbPath: string, wxid: string): string => { + return `${String(dbPath || '').trim()}\u0001${String(wxid || '').trim()}` +} + +const pruneAnnualReportYearsSnapshotCache = (): void => { + const now = Date.now() + for (const [cacheKey, entry] of annualReportYearsSnapshotCache.entries()) { + if (now - entry.updatedAt > annualReportYearsSnapshotTtlMs) { + annualReportYearsSnapshotCache.delete(cacheKey) + } + } +} + +const persistAnnualReportYearsSnapshot = ( + cacheKey: string, + taskId: string, + snapshot: AnnualReportYearsProgressPayload +): void => { + annualReportYearsSnapshotCache.set(cacheKey, { + taskId, + snapshot: normalizeAnnualReportYearsSnapshot(snapshot), + updatedAt: Date.now() + }) + pruneAnnualReportYearsSnapshotCache() +} + +const getAnnualReportYearsSnapshot = ( + cacheKey: string +): { taskId: string; snapshot: AnnualReportYearsProgressPayload } | null => { + pruneAnnualReportYearsSnapshotCache() + const entry = annualReportYearsSnapshotCache.get(cacheKey) + if (!entry) return null + return { + taskId: entry.taskId, + snapshot: normalizeAnnualReportYearsSnapshot(entry.snapshot) + } +} + +const broadcastAnnualReportYearsProgress = ( + taskId: string, + payload: AnnualReportYearsProgressPayload +): void => { + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue + win.webContents.send('annualReport:availableYearsProgress', { + taskId, + ...payload + }) + } +} + +const isYearsLoadCanceled = (taskId: string): boolean => { + const task = annualReportYearsLoadTasks.get(taskId) + return task?.canceled === true +} + +const setupCustomTitleBarWindow = (win: BrowserWindow): void => { + if (process.platform === 'darwin') { + win.setWindowButtonVisibility(false) + } + + const emitMaximizeState = () => { + if (win.isDestroyed()) return + win.webContents.send('window:maximizeStateChanged', win.isMaximized() || win.isFullScreen()) + } + + win.on('maximize', emitMaximizeState) + win.on('unmaximize', emitMaximizeState) + win.on('enter-full-screen', emitMaximizeState) + win.on('leave-full-screen', emitMaximizeState) + win.webContents.on('did-finish-load', emitMaximizeState) +} + +const getWindowCloseBehavior = (): WindowCloseBehavior => { + const behavior = configService?.get('windowCloseBehavior') + return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask' +} + +const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => { + if (isClosePromptVisible) return + isClosePromptVisible = true + win.webContents.send('window:confirmCloseRequested', { + canMinimizeToTray: Boolean(tray) + }) +} + function createWindow(options: { autoShow?: boolean } = {}) { // 获取图标路径 - 打包后在 resources 目录 const { autoShow = true } = options + let iconName = 'icon.ico'; + if (process.platform === 'linux') { + iconName = 'icon.png'; + } else if (process.platform === 'darwin') { + iconName = 'icon.icns'; + } + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev - ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + ? join(__dirname, `../public/${iconName}`) + : join(process.resourcesPath, iconName); const win = new BrowserWindow({ width: 1400, @@ -115,13 +375,10 @@ function createWindow(options: { autoShow?: boolean } = {}) { webSecurity: false // Allow loading local files (video playback) }, titleBarStyle: 'hidden', - titleBarOverlay: { - color: '#00000000', - symbolColor: '#1a1a1a', - height: 40 - }, + titleBarOverlay: false, show: false }) + setupCustomTitleBarWindow(win) // 窗口准备好后显示 // Splash 模式下不在这里 show,由启动流程统一控制 @@ -195,6 +452,40 @@ function createWindow(options: { autoShow?: boolean } = {}) { callback(false) }) + win.on('close', (e) => { + if (isAppQuitting || win !== mainWindow) return + e.preventDefault() + const closeBehavior = getWindowCloseBehavior() + + if (closeBehavior === 'quit') { + isAppQuitting = true + app.quit() + return + } + + if (closeBehavior === 'tray' && tray) { + win.hide() + return + } + + requestMainWindowCloseConfirmation(win) + }) + + win.on('closed', () => { + if (mainWindow !== win) return + + mainWindow = null + mainWindowReady = false + isClosePromptVisible = false + + if (process.platform !== 'darwin' && !isAppQuitting) { + destroyNotificationWindow() + if (BrowserWindow.getAllWindows().length === 0) { + app.quit() + } + } + }) + return win } @@ -211,7 +502,9 @@ function createAgreementWindow() { const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) const isDark = nativeTheme.shouldUseDarkColors @@ -261,7 +554,9 @@ function createSplashWindow(): BrowserWindow { const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) splashWindow = new BrowserWindow({ width: 760, @@ -332,7 +627,9 @@ function createOnboardingWindow() { const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) onboardingWindow = new BrowserWindow({ width: 960, @@ -378,7 +675,9 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) // 获取屏幕尺寸 const { screen } = require('electron') @@ -476,7 +775,9 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) { const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) const win = new BrowserWindow({ width: 900, @@ -490,17 +791,14 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) { nodeIntegration: false, webSecurity: false // 允许加载本地文件 }, - titleBarStyle: 'hidden', - titleBarOverlay: { - color: '#00000000', - symbolColor: '#ffffff', - height: 40 - }, + frame: false, show: false, backgroundColor: '#000000', autoHideMenuBar: true }) + setupCustomTitleBarWindow(win) + win.once('ready-to-show', () => { win.show() }) @@ -534,10 +832,20 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) { * 创建独立的聊天记录窗口 */ function createChatHistoryWindow(sessionId: string, messageId: number) { + return createChatHistoryRouteWindow(`/chat-history/${sessionId}/${messageId}`) +} + +function createChatHistoryPayloadWindow(payloadId: string) { + return createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`) +} + +function createChatHistoryRouteWindow(route: string) { const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') - : join(process.resourcesPath, 'icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) // 根据系统主题设置窗口背景色 const isDark = nativeTheme.shouldUseDarkColors @@ -554,22 +862,19 @@ function createChatHistoryWindow(sessionId: string, messageId: number) { nodeIntegration: false }, titleBarStyle: 'hidden', - titleBarOverlay: { - color: '#00000000', - symbolColor: isDark ? '#ffffff' : '#1a1a1a', - height: 32 - }, + titleBarOverlay: false, show: false, backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0', autoHideMenuBar: true }) + setupCustomTitleBarWindow(win) win.once('ready-to-show', () => { win.show() }) if (process.env.VITE_DEV_SERVER_URL) { - win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-history/${sessionId}/${messageId}`) + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${route}`) win.webContents.on('before-input-event', (event, input) => { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { @@ -583,13 +888,99 @@ function createChatHistoryWindow(sessionId: string, messageId: number) { }) } else { win.loadFile(join(__dirname, '../dist/index.html'), { - hash: `/chat-history/${sessionId}/${messageId}` + hash: route }) } return win } +/** + * 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域) + */ +function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWindowOptions) { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return null + const normalizedSource = normalizeSessionChatWindowSource(options?.source) + + const existing = sessionChatWindows.get(normalizedSessionId) + if (existing && !existing.isDestroyed()) { + const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat' + if (trackedSource !== normalizedSource) { + loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource, options) + sessionChatWindowSources.set(normalizedSessionId, normalizedSource) + } + if (existing.isMinimized()) { + existing.restore() + } + existing.focus() + return existing + } + + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : (process.platform === 'darwin' + ? join(process.resourcesPath, 'icon.icns') + : join(process.resourcesPath, 'icon.ico')) + + const isDark = nativeTheme.shouldUseDarkColors + + const win = new BrowserWindow({ + width: 600, + height: 820, + minWidth: 420, + minHeight: 560, + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false + }, + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#00000000', + symbolColor: isDark ? '#ffffff' : '#1a1a1a', + height: 40 + }, + show: false, + backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0', + autoHideMenuBar: true + }) + + loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource, options) + + if (process.env.VITE_DEV_SERVER_URL) { + win.webContents.on('before-input-event', (event, input) => { + if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { + if (win.webContents.isDevToolsOpened()) { + win.webContents.closeDevTools() + } else { + win.webContents.openDevTools() + } + event.preventDefault() + } + }) + } + + win.once('ready-to-show', () => { + win.show() + win.focus() + }) + + win.on('closed', () => { + const tracked = sessionChatWindows.get(normalizedSessionId) + if (tracked === win) { + sessionChatWindows.delete(normalizedSessionId) + sessionChatWindowSources.delete(normalizedSessionId) + } + }) + + sessionChatWindows.set(normalizedSessionId, win) + sessionChatWindowSources.set(normalizedSessionId, normalizedSource) + return win +} + function showMainWindow() { shouldShowMain = true if (mainWindowReady) { @@ -597,6 +988,65 @@ function showMainWindow() { } } +const normalizeAccountId = (value: string): string => { + const trimmed = String(value || '').trim() + if (!trimmed) return '' + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + return match?.[1] || trimmed + } + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed +} + +const buildAccountNameMatcher = (wxidCandidates: string[]) => { + const loweredCandidates = wxidCandidates + .map((item) => String(item || '').trim().toLowerCase()) + .filter(Boolean) + return (name: string): boolean => { + const loweredName = String(name || '').trim().toLowerCase() + if (!loweredName) return false + return loweredCandidates.some((candidate) => ( + loweredName === candidate || + loweredName.startsWith(`${candidate}_`) || + loweredName.includes(candidate) + )) + } +} + +const removePathIfExists = async ( + targetPath: string, + removedPaths: string[], + warnings: string[] +): Promise => { + if (!targetPath || !existsSync(targetPath)) return + try { + await rm(targetPath, { recursive: true, force: true }) + removedPaths.push(targetPath) + } catch (error) { + warnings.push(`${targetPath}: ${String(error)}`) + } +} + +const removeMatchedEntriesInDir = async ( + rootDir: string, + shouldRemove: (name: string) => boolean, + removedPaths: string[], + warnings: string[] +): Promise => { + if (!rootDir || !existsSync(rootDir)) return + try { + const entries = await readdir(rootDir, { withFileTypes: true }) + for (const entry of entries) { + if (!shouldRemove(entry.name)) continue + const targetPath = join(rootDir, entry.name) + await removePathIfExists(targetPath, removedPaths, warnings) + } + } catch (error) { + warnings.push(`${rootDir}: ${String(error)}`) + } +} + // 注册 IPC 处理器 function registerIpcHandlers() { registerNotificationHandlers() @@ -606,11 +1056,14 @@ function registerIpcHandlers() { }) ipcMain.handle('config:set', async (_, key: string, value: any) => { - return configService?.set(key as any, value) + const result = configService?.set(key as any, value) + void messagePushService.handleConfigChanged(key) + return result }) ipcMain.handle('config:clear', async () => { configService?.clear() + messagePushService.handleConfigCleared() return true }) @@ -651,6 +1104,13 @@ function registerIpcHandlers() { return app.getVersion() }) + ipcMain.handle('app:checkWayland', async () => { + if (process.platform !== 'linux') return false; + + const sessionType = process.env.XDG_SESSION_TYPE?.toLowerCase(); + return Boolean(process.env.WAYLAND_DISPLAY || sessionType === 'wayland'); + }) + ipcMain.handle('log:getPath', async () => { return join(app.getPath('userData'), 'logs', 'wcdb.log') }) @@ -665,6 +1125,50 @@ function registerIpcHandlers() { } }) + ipcMain.handle('log:clear', async () => { + try { + const logPath = join(app.getPath('userData'), 'logs', 'wcdb.log') + await mkdir(dirname(logPath), { recursive: true }) + await writeFile(logPath, '', 'utf8') + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + }) + + ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => { + return exportCardDiagnosticsService.snapshot(options?.limit) + }) + + ipcMain.handle('diagnostics:clearExportCardLogs', async () => { + exportCardDiagnosticsService.clear() + return { success: true } + }) + + ipcMain.handle('diagnostics:exportExportCardLogs', async (_, payload?: { + filePath?: string + frontendLogs?: unknown[] + }) => { + const filePath = typeof payload?.filePath === 'string' ? payload.filePath.trim() : '' + if (!filePath) { + return { success: false, error: '导出路径不能为空' } + } + return exportCardDiagnosticsService.exportCombinedLogs(filePath, payload?.frontendLogs || []) + }) + + // 数据收集服务 + ipcMain.handle('cloud:init', async () => { + await cloudControlService.init() + }) + + ipcMain.handle('cloud:recordPage', (_, pageName: string) => { + cloudControlService.recordPage(pageName) + }) + + ipcMain.handle('cloud:getLogs', async () => { + return cloudControlService.getLogs() + }) + ipcMain.handle('app:checkForUpdates', async () => { if (!AUTO_UPDATE_ENABLED) { return { hasUpdate: false } @@ -678,7 +1182,7 @@ function registerIpcHandlers() { return { hasUpdate: true, version: latestVersion, - releaseNotes: result.updateInfo.releaseNotes as string || '' + releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes) } } } @@ -771,10 +1275,42 @@ function registerIpcHandlers() { } }) + ipcMain.handle('window:isMaximized', (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + return Boolean(win?.isMaximized() || win?.isFullScreen()) + }) + ipcMain.on('window:close', (event) => { BrowserWindow.fromWebContents(event.sender)?.close() }) + ipcMain.handle('window:respondCloseConfirm', async (_event, action: 'tray' | 'quit' | 'cancel') => { + if (!mainWindow || mainWindow.isDestroyed()) { + isClosePromptVisible = false + return false + } + + try { + if (action === 'tray') { + if (tray) { + mainWindow.hide() + return true + } + return false + } + + if (action === 'quit') { + isAppQuitting = true + app.quit() + return true + } + + return true + } finally { + isClosePromptVisible = false + } + }) + // 更新窗口控件主题色 ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => { const win = BrowserWindow.fromWebContents(event.sender) @@ -802,6 +1338,29 @@ function registerIpcHandlers() { return true }) + ipcMain.handle('window:openChatHistoryPayloadWindow', (_, payload: { sessionId: string; title?: string; recordList: any[] }) => { + const payloadId = randomUUID() + chatHistoryPayloadStore.set(payloadId, { + sessionId: String(payload?.sessionId || '').trim(), + title: String(payload?.title || '').trim() || '聊天记录', + recordList: Array.isArray(payload?.recordList) ? payload.recordList : [] + }) + createChatHistoryPayloadWindow(payloadId) + return true + }) + + ipcMain.handle('window:getChatHistoryPayload', (_, payloadId: string) => { + const payload = chatHistoryPayloadStore.get(String(payloadId || '').trim()) + if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' } + return { success: true, payload } + }) + + // 打开会话聊天窗口(同会话仅保留一个窗口并聚焦) + ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => { + const win = createSessionChatWindow(sessionId, options) + return Boolean(win) + }) + // 根据视频尺寸调整窗口大小 ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => { const win = BrowserWindow.fromWebContents(event.sender) @@ -912,8 +1471,27 @@ function registerIpcHandlers() { return chatService.getSessions() }) - ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => { - return chatService.enrichSessionsContactInfo(usernames) + ipcMain.handle('chat:getSessionStatuses', async (_, usernames: string[]) => { + return chatService.getSessionStatuses(usernames) + }) + + ipcMain.handle('chat:getExportTabCounts', async () => { + return chatService.getExportTabCounts() + }) + + ipcMain.handle('chat:getContactTypeCounts', async () => { + return chatService.getContactTypeCounts() + }) + + ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => { + return chatService.getSessionMessageCounts(sessionIds) + }) + + ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[], options?: { + skipDisplayName?: boolean + onlyMissingAvatar?: boolean + }) => { + return chatService.enrichSessionsContactInfo(usernames, options) }) ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => { @@ -970,10 +1548,162 @@ function registerIpcHandlers() { return true }) + ipcMain.handle('chat:clearCurrentAccountData', async (_, options?: { clearCache?: boolean; clearExports?: boolean }) => { + const cfg = configService + if (!cfg) return { success: false, error: '配置服务未初始化' } + + const clearCache = options?.clearCache === true + const clearExports = options?.clearExports === true + if (!clearCache && !clearExports) { + return { success: false, error: '请至少选择一项清理范围' } + } + + const rawWxid = String(cfg.get('myWxid') || '').trim() + if (!rawWxid) { + return { success: false, error: '当前账号未登录或未识别,无法清理' } + } + const normalizedWxid = normalizeAccountId(rawWxid) + const wxidCandidates = Array.from(new Set([rawWxid, normalizedWxid].filter(Boolean))) + const isMatchedAccountName = buildAccountNameMatcher(wxidCandidates) + const removedPaths: string[] = [] + const warnings: string[] = [] + + try { + wcdbService.close() + chatService.close() + } catch (error) { + warnings.push(`关闭数据库连接失败: ${String(error)}`) + } + + if (clearCache) { + const [analyticsResult, imageResult] = await Promise.all([ + analyticsService.clearCache(), + imageDecryptService.clearCache() + ]) + const chatResult = chatService.clearCaches() + const cleanupResults = [analyticsResult, imageResult, chatResult] + for (const result of cleanupResults) { + if (!result.success && result.error) warnings.push(result.error) + } + + const configuredCachePath = String(cfg.get('cachePath') || '').trim() + const documentsWeFlowDir = join(app.getPath('documents'), 'WeFlow') + const userDataCacheDir = join(app.getPath('userData'), 'cache') + const cacheRootCandidates = [ + configuredCachePath, + join(documentsWeFlowDir, 'Images'), + join(documentsWeFlowDir, 'Voices'), + join(documentsWeFlowDir, 'Emojis'), + userDataCacheDir + ].filter(Boolean) + + for (const wxid of wxidCandidates) { + if (configuredCachePath) { + await removePathIfExists(join(configuredCachePath, wxid), removedPaths, warnings) + await removePathIfExists(join(configuredCachePath, 'Images', wxid), removedPaths, warnings) + await removePathIfExists(join(configuredCachePath, 'Voices', wxid), removedPaths, warnings) + await removePathIfExists(join(configuredCachePath, 'Emojis', wxid), removedPaths, warnings) + } + await removePathIfExists(join(documentsWeFlowDir, 'Images', wxid), removedPaths, warnings) + await removePathIfExists(join(documentsWeFlowDir, 'Voices', wxid), removedPaths, warnings) + await removePathIfExists(join(documentsWeFlowDir, 'Emojis', wxid), removedPaths, warnings) + await removePathIfExists(join(userDataCacheDir, wxid), removedPaths, warnings) + } + + for (const cacheRoot of cacheRootCandidates) { + await removeMatchedEntriesInDir(cacheRoot, isMatchedAccountName, removedPaths, warnings) + } + } + + if (clearExports) { + const configuredExportPath = String(cfg.get('exportPath') || '').trim() + const documentsWeFlowDir = join(app.getPath('documents'), 'WeFlow') + const exportRootCandidates = [ + configuredExportPath, + join(documentsWeFlowDir, 'exports'), + join(documentsWeFlowDir, 'Exports') + ].filter(Boolean) + + for (const exportRoot of exportRootCandidates) { + await removeMatchedEntriesInDir(exportRoot, isMatchedAccountName, removedPaths, warnings) + } + + const resetConfigKeys = [ + 'exportSessionRecordMap', + 'exportLastSessionRunMap', + 'exportLastContentRunMap', + 'exportSessionMessageCountCacheMap', + 'exportSessionContentMetricCacheMap', + 'exportSnsStatsCacheMap', + 'snsPageCacheMap', + 'contactsListCacheMap', + 'contactsAvatarCacheMap', + 'lastSession' + ] + for (const key of resetConfigKeys) { + const defaultValue = key === 'lastSession' ? '' : {} + cfg.set(key as any, defaultValue as any) + } + } + + if (clearCache) { + try { + const wxidConfigsRaw = cfg.get('wxidConfigs') as Record | undefined + if (wxidConfigsRaw && typeof wxidConfigsRaw === 'object') { + const nextConfigs: Record = { ...wxidConfigsRaw } + for (const key of Object.keys(nextConfigs)) { + if (isMatchedAccountName(key) || normalizeAccountId(key) === normalizedWxid) { + delete nextConfigs[key] + } + } + cfg.set('wxidConfigs' as any, nextConfigs as any) + } + cfg.set('myWxid' as any, '') + cfg.set('decryptKey' as any, '') + cfg.set('imageXorKey' as any, 0) + cfg.set('imageAesKey' as any, '') + cfg.set('dbPath' as any, '') + cfg.set('lastOpenedDb' as any, '') + cfg.set('onboardingDone' as any, false) + cfg.set('lastSession' as any, '') + } catch (error) { + warnings.push(`清理账号配置失败: ${String(error)}`) + } + } + + return { + success: true, + removedPaths, + warning: warnings.length > 0 ? warnings.join('; ') : undefined + } + }) + ipcMain.handle('chat:getSessionDetail', async (_, sessionId: string) => { return chatService.getSessionDetail(sessionId) }) + ipcMain.handle('chat:getSessionDetailFast', async (_, sessionId: string) => { + return chatService.getSessionDetailFast(sessionId) + }) + + ipcMain.handle('chat:getSessionDetailExtra', async (_, sessionId: string) => { + return chatService.getSessionDetailExtra(sessionId) + }) + + ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[], options?: { + includeRelations?: boolean + forceRefresh?: boolean + allowStaleCache?: boolean + preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean + }) => { + return chatService.getExportSessionStats(sessionIds, options) + }) + + ipcMain.handle('chat:getGroupMyMessageCountHint', async (_, chatroomId: string) => { + return chatService.getGroupMyMessageCountHint(chatroomId) + }) + ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => { return chatService.getImageData(sessionId, msgId) }) @@ -990,13 +1720,16 @@ function registerIpcHandlers() { ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => { return chatService.getMessageDates(sessionId) }) + ipcMain.handle('chat:getMessageDateCounts', async (_, sessionId: string) => { + return chatService.getMessageDateCounts(sessionId) + }) ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => { return chatService.resolveVoiceCache(sessionId, msgId) }) ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => { return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => { - event.sender.send('chat:voiceTranscriptPartial', { msgId, text }) + event.sender.send('chat:voiceTranscriptPartial', { sessionId, msgId, createTime, text }) }) }) @@ -1004,8 +1737,8 @@ function registerIpcHandlers() { return chatService.getMessageById(sessionId, localId) }) - ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => { - return chatService.execQuery(kind, path, sql) + ipcMain.handle('chat:searchMessages', async (_, keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => { + return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp) }) ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => { @@ -1016,6 +1749,22 @@ function registerIpcHandlers() { return snsService.getSnsUsernames() }) + ipcMain.handle('sns:getUserPostCounts', async () => { + return snsService.getUserPostCounts() + }) + + ipcMain.handle('sns:getExportStats', async () => { + return snsService.getExportStats() + }) + + ipcMain.handle('sns:getExportStatsFast', async () => { + return snsService.getExportStatsFast() + }) + + ipcMain.handle('sns:getUserPostStats', async (_, username: string) => { + return snsService.getUserPostStats(username) + }) + ipcMain.handle('sns:debugResource', async (_, url: string) => { return snsService.debugResource(url) }) @@ -1063,11 +1812,17 @@ function registerIpcHandlers() { }) ipcMain.handle('sns:exportTimeline', async (event, options: any) => { - return snsService.exportTimeline(options, (progress) => { - if (!event.sender.isDestroyed()) { - event.sender.send('sns:exportProgress', progress) + const exportOptions = { ...(options || {}) } + delete exportOptions.taskId + + return snsService.exportTimeline( + exportOptions, + (progress) => { + if (!event.sender.isDestroyed()) { + event.sender.send('sns:exportProgress', progress) + } } - }) + ) }) ipcMain.handle('sns:selectExportDir', async () => { @@ -1196,7 +1951,84 @@ function registerIpcHandlers() { event.sender.send('export:progress', progress) } } - return exportService.exportSessions(sessionIds, outputDir, options, onProgress) + + const runMainFallback = async (reason: string) => { + console.warn(`[fallback-export-main] ${reason}`) + return exportService.exportSessions(sessionIds, outputDir, options, onProgress) + } + + const cfg = configService || new ConfigService() + configService = cfg + const logEnabled = cfg.get('logEnabled') + const resourcesPath = app.isPackaged + ? join(process.resourcesPath, 'resources') + : join(app.getAppPath(), 'resources') + const userDataPath = app.getPath('userData') + const workerPath = join(__dirname, 'exportWorker.js') + + const runWorker = async () => { + return await new Promise((resolve, reject) => { + const worker = new Worker(workerPath, { + workerData: { + sessionIds, + outputDir, + options, + resourcesPath, + userDataPath, + logEnabled + } + }) + + let settled = false + const finalizeResolve = (value: any) => { + if (settled) return + settled = true + worker.removeAllListeners() + void worker.terminate() + resolve(value) + } + const finalizeReject = (error: Error) => { + if (settled) return + settled = true + worker.removeAllListeners() + void worker.terminate() + reject(error) + } + + worker.on('message', (msg: any) => { + if (msg && msg.type === 'export:progress') { + onProgress(msg.data as ExportProgress) + return + } + if (msg && msg.type === 'export:result') { + finalizeResolve(msg.data) + return + } + if (msg && msg.type === 'export:error') { + finalizeReject(new Error(String(msg.error || '导出 Worker 执行失败'))) + } + }) + + worker.on('error', (error) => { + finalizeReject(error instanceof Error ? error : new Error(String(error))) + }) + + worker.on('exit', (code) => { + if (settled) return + if (code === 0) { + finalizeResolve({ success: false, successCount: 0, failCount: 0, error: '导出 Worker 未返回结果' }) + } else { + finalizeReject(new Error(`导出 Worker 异常退出: ${code}`)) + } + }) + }) + } + + try { + return await runWorker() + } catch (error) { + return runMainFallback(error instanceof Error ? error.message : String(error)) + } }) ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => { @@ -1285,6 +2117,16 @@ function registerIpcHandlers() { return groupAnalyticsService.getGroupMembers(chatroomId) }) + ipcMain.handle( + 'groupAnalytics:getGroupMembersPanelData', + async (_, chatroomId: string, options?: { forceRefresh?: boolean; includeMessageCounts?: boolean } | boolean) => { + const normalizedOptions = typeof options === 'boolean' + ? { forceRefresh: options } + : options + return groupAnalyticsService.getGroupMembersPanelData(chatroomId, normalizedOptions) + } + ) + ipcMain.handle('groupAnalytics:getGroupMessageRanking', async (_, chatroomId: string, limit?: number, startTime?: number, endTime?: number) => { return groupAnalyticsService.getGroupMessageRanking(chatroomId, limit, startTime, endTime) }) @@ -1297,6 +2139,18 @@ function registerIpcHandlers() { return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime) }) + ipcMain.handle( + 'groupAnalytics:getGroupMemberMessages', + async ( + _, + chatroomId: string, + memberUsername: string, + options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number } + ) => { + return groupAnalyticsService.getGroupMemberMessages(chatroomId, memberUsername, options) + } + ) + ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => { return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath) }) @@ -1365,6 +2219,193 @@ function registerIpcHandlers() { }) }) + ipcMain.handle('annualReport:startAvailableYearsLoad', async (event) => { + const cfg = configService || new ConfigService() + configService = cfg + + const dbPath = cfg.get('dbPath') + const decryptKey = cfg.get('decryptKey') + const wxid = cfg.get('myWxid') + const cacheKey = buildAnnualReportYearsCacheKey(dbPath, wxid) + + const runningTaskId = annualReportYearsTaskByCacheKey.get(cacheKey) + if (runningTaskId) { + const runningTask = annualReportYearsLoadTasks.get(runningTaskId) + if (runningTask && !runningTask.done) { + return { + success: true, + taskId: runningTaskId, + reused: true, + snapshot: normalizeAnnualReportYearsSnapshot(runningTask.snapshot) + } + } + annualReportYearsTaskByCacheKey.delete(cacheKey) + } + + const cachedSnapshot = getAnnualReportYearsSnapshot(cacheKey) + if (cachedSnapshot && cachedSnapshot.snapshot.done) { + return { + success: true, + taskId: cachedSnapshot.taskId, + reused: true, + snapshot: normalizeAnnualReportYearsSnapshot(cachedSnapshot.snapshot) + } + } + + const taskId = `years_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + const initialSnapshot: AnnualReportYearsProgressPayload = cachedSnapshot?.snapshot && !cachedSnapshot.snapshot.done + ? { + ...normalizeAnnualReportYearsSnapshot(cachedSnapshot.snapshot), + done: false, + canceled: false, + error: undefined + } + : { + years: [], + done: false, + strategy: 'native', + phase: 'native', + statusText: '准备使用原生快速模式加载年份...', + nativeElapsedMs: 0, + scanElapsedMs: 0, + totalElapsedMs: 0, + switched: false, + nativeTimedOut: false + } + + const updateTaskSnapshot = (payload: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload | null => { + const task = annualReportYearsLoadTasks.get(taskId) + if (!task) return null + + const hasPayloadYears = Array.isArray(payload.years) + const nextYears = (hasPayloadYears && (payload.done || (payload.years || []).length > 0)) + ? [...(payload.years || [])] + : Array.isArray(task.snapshot.years) ? [...task.snapshot.years] : [] + + const nextSnapshot: AnnualReportYearsProgressPayload = normalizeAnnualReportYearsSnapshot({ + ...task.snapshot, + ...payload, + years: nextYears + }) + task.snapshot = nextSnapshot + task.done = nextSnapshot.done === true + task.updatedAt = Date.now() + annualReportYearsLoadTasks.set(taskId, task) + persistAnnualReportYearsSnapshot(task.cacheKey, taskId, nextSnapshot) + return nextSnapshot + } + + annualReportYearsLoadTasks.set(taskId, { + cacheKey, + canceled: false, + done: false, + snapshot: normalizeAnnualReportYearsSnapshot(initialSnapshot), + updatedAt: Date.now() + }) + annualReportYearsTaskByCacheKey.set(cacheKey, taskId) + persistAnnualReportYearsSnapshot(cacheKey, taskId, initialSnapshot) + + void (async () => { + try { + const result = await annualReportService.getAvailableYears({ + dbPath, + decryptKey, + wxid, + onProgress: (progress) => { + if (isYearsLoadCanceled(taskId)) return + const snapshot = updateTaskSnapshot({ + ...progress, + done: false + }) + if (!snapshot) return + broadcastAnnualReportYearsProgress(taskId, snapshot) + }, + shouldCancel: () => isYearsLoadCanceled(taskId) + }) + + const canceled = isYearsLoadCanceled(taskId) + if (canceled) { + const snapshot = updateTaskSnapshot({ + done: true, + canceled: true, + phase: 'done', + statusText: '已取消年份加载' + }) + if (snapshot) { + broadcastAnnualReportYearsProgress(taskId, snapshot) + } + return + } + + const completionPayload: AnnualReportYearsProgressPayload = result.success + ? { + years: result.data || [], + done: true, + strategy: result.meta?.strategy, + phase: 'done', + statusText: result.meta?.statusText || '年份数据加载完成', + nativeElapsedMs: result.meta?.nativeElapsedMs, + scanElapsedMs: result.meta?.scanElapsedMs, + totalElapsedMs: result.meta?.totalElapsedMs, + switched: result.meta?.switched, + nativeTimedOut: result.meta?.nativeTimedOut + } + : { + years: result.data || [], + done: true, + error: result.error || '加载年度数据失败', + strategy: result.meta?.strategy, + phase: 'done', + statusText: result.meta?.statusText || '年份数据加载失败', + nativeElapsedMs: result.meta?.nativeElapsedMs, + scanElapsedMs: result.meta?.scanElapsedMs, + totalElapsedMs: result.meta?.totalElapsedMs, + switched: result.meta?.switched, + nativeTimedOut: result.meta?.nativeTimedOut + } + + const snapshot = updateTaskSnapshot(completionPayload) + if (snapshot) { + broadcastAnnualReportYearsProgress(taskId, snapshot) + } + } catch (e) { + const snapshot = updateTaskSnapshot({ + done: true, + error: String(e), + phase: 'done', + statusText: '年份数据加载失败', + strategy: 'hybrid' + }) + if (snapshot) { + broadcastAnnualReportYearsProgress(taskId, snapshot) + } + } finally { + const task = annualReportYearsLoadTasks.get(taskId) + if (task) { + annualReportYearsTaskByCacheKey.delete(task.cacheKey) + } + annualReportYearsLoadTasks.delete(taskId) + } + })() + + return { + success: true, + taskId, + reused: false, + snapshot: normalizeAnnualReportYearsSnapshot(initialSnapshot) + } + }) + + ipcMain.handle('annualReport:cancelAvailableYearsLoad', async (_, taskId: string) => { + const key = String(taskId || '').trim() + if (!key) return { success: false, error: '任务ID不能为空' } + const task = annualReportYearsLoadTasks.get(key) + if (!task) return { success: true } + task.canceled = true + annualReportYearsLoadTasks.set(key, task) + return { success: true } + }) + ipcMain.handle('annualReport:generateReport', async (_, year: number) => { const cfg = configService || new ConfigService() configService = cfg @@ -1528,7 +2569,7 @@ function registerIpcHandlers() { // 密钥获取 ipcMain.handle('key:autoGetDbKey', async (event) => { - return keyService.autoGetDbKey(60_000, (message, level) => { + return keyService.autoGetDbKey(180_000, (message, level) => { event.sender.send('key:dbKeyStatus', { message, level }) }) }) @@ -1539,6 +2580,12 @@ function registerIpcHandlers() { }, wxid) }) + ipcMain.handle('key:scanImageKeyFromMemory', async (event, userDir: string) => { + return keyService.autoGetImageKeyByMemoryScan(userDir, (message) => { + event.sender.send('key:imageKeyStatus', { message }) + }) + }) + // HTTP API 服务 ipcMain.handle('http:start', async (_, port?: number) => { return httpService.start(port || 5031) @@ -1588,7 +2635,7 @@ function checkForUpdatesOnStartup() { // 通知渲染进程有新版本 mainWindow.webContents.send('app:updateAvailable', { version: latestVersion, - releaseNotes: result.updateInfo.releaseNotes || '' + releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes) }) } } @@ -1651,6 +2698,10 @@ app.whenReady().then(async () => { // 注册 IPC 处理器 updateSplashProgress(25, '正在初始化...') registerIpcHandlers() + chatService.addDbMonitorListener((type, json) => { + messagePushService.handleDbMonitorChange(type, json) + }) + messagePushService.start() await delay(200) // 检查配置状态 @@ -1661,6 +2712,63 @@ app.whenReady().then(async () => { updateSplashProgress(30, '正在加载界面...') mainWindow = createWindow({ autoShow: false }) + let iconName = 'icon.ico'; + if (process.platform === 'linux') { + iconName = 'icon.png'; + } else if (process.platform === 'darwin') { + iconName = 'icon.icns'; + } + + const isDev = !!process.env.VITE_DEV_SERVER_URL + + const resolvedTrayIcon = isDev + ? join(__dirname, `../public/${iconName}`) + : join(process.resourcesPath, iconName); + + + try { + tray = new Tray(resolvedTrayIcon) + tray.setToolTip('WeFlow') + const contextMenu = Menu.buildFromTemplate([ + { + label: '显示主窗口', + click: () => { + if (mainWindow) { + mainWindow.show() + mainWindow.focus() + } + } + }, + { type: 'separator' }, + { + label: '退出', + click: () => { + isAppQuitting = true + app.quit() + } + } + ]) + tray.setContextMenu(contextMenu) + tray.on('click', () => { + if (mainWindow) { + if (mainWindow.isVisible()) { + mainWindow.focus() + } else { + mainWindow.show() + mainWindow.focus() + } + } + }) + tray.on('double-click', () => { + if (mainWindow) { + mainWindow.show() + mainWindow.focus() + } + }) + } catch (e) { + console.warn('[Tray] Failed to create tray icon:', e) + } + // 配置网络服务 session.defaultSession.webRequest.onBeforeSendHeaders( { @@ -1706,6 +2814,24 @@ app.whenReady().then(async () => { }) }) +app.on('before-quit', async () => { + isAppQuitting = true + // 销毁 tray 图标 + if (tray) { try { tray.destroy() } catch {} tray = null } + // 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。 + destroyNotificationWindow() + // 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留 + const forceExitTimer = setTimeout(() => { + console.warn('[App] Force exit after timeout') + app.exit(0) + }, 5000) + forceExitTimer.unref() + // 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出 + try { await httpService.stop() } catch {} + // 终止 wcdb Worker 线程,避免线程阻止进程退出 + try { await wcdbService.shutdown() } catch {} +}) + app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() diff --git a/electron/preload.ts b/electron/preload.ts index e81a267..b1c9c30 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -63,21 +63,45 @@ contextBridge.exposeInMainWorld('electronAPI', { onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => { ipcRenderer.on('app:updateAvailable', (_, info) => callback(info)) return () => ipcRenderer.removeAllListeners('app:updateAvailable') - } + }, + checkWayland: () => ipcRenderer.invoke('app:checkWayland'), }, // 日志 log: { getPath: () => ipcRenderer.invoke('log:getPath'), read: () => ipcRenderer.invoke('log:read'), + clear: () => ipcRenderer.invoke('log:clear'), debug: (data: any) => ipcRenderer.send('log:debug', data) }, + diagnostics: { + getExportCardLogs: (options?: { limit?: number }) => + ipcRenderer.invoke('diagnostics:getExportCardLogs', options), + clearExportCardLogs: () => + ipcRenderer.invoke('diagnostics:clearExportCardLogs'), + exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) => + ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload) + }, + // 窗口控制 window: { minimize: () => ipcRenderer.send('window:minimize'), maximize: () => ipcRenderer.send('window:maximize'), + isMaximized: () => ipcRenderer.invoke('window:isMaximized'), + onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => { + const listener = (_: unknown, isMaximized: boolean) => callback(isMaximized) + ipcRenderer.on('window:maximizeStateChanged', listener) + return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener) + }, close: () => ipcRenderer.send('window:close'), + onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => { + const listener = (_: unknown, payload: { canMinimizeToTray: boolean }) => callback(payload) + ipcRenderer.on('window:confirmCloseRequested', listener) + return () => ipcRenderer.removeListener('window:confirmCloseRequested', listener) + }, + respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') => + ipcRenderer.invoke('window:respondCloseConfirm', action), openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'), openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'), @@ -89,7 +113,21 @@ contextBridge.exposeInMainWorld('electronAPI', { openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath), openChatHistoryWindow: (sessionId: string, messageId: number) => - ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId) + ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId), + openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: any[] }) => + ipcRenderer.invoke('window:openChatHistoryPayloadWindow', payload), + getChatHistoryPayload: (payloadId: string) => + ipcRenderer.invoke('window:getChatHistoryPayload', payloadId), + openSessionChatWindow: ( + sessionId: string, + options?: { + source?: 'chat' | 'export' + initialDisplayName?: string + initialAvatarUrl?: string + initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other' + } + ) => + ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options) }, // 数据库路径 @@ -114,6 +152,7 @@ contextBridge.exposeInMainWorld('electronAPI', { key: { autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'), autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid), + scanImageKeyFromMemory: (userDir: string) => ipcRenderer.invoke('key:scanImageKeyFromMemory', userDir), onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => { ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('key:dbKeyStatus') @@ -129,8 +168,14 @@ contextBridge.exposeInMainWorld('electronAPI', { chat: { connect: () => ipcRenderer.invoke('chat:connect'), getSessions: () => ipcRenderer.invoke('chat:getSessions'), - enrichSessionsContactInfo: (usernames: string[]) => - ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), + getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames), + getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), + getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'), + getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), + enrichSessionsContactInfo: ( + usernames: string[], + options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean } + ) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options), getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending), getLatestMessages: (sessionId: string, limit?: number) => @@ -148,26 +193,43 @@ contextBridge.exposeInMainWorld('electronAPI', { getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'), downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5), getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId), + clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) => + ipcRenderer.invoke('chat:clearCurrentAccountData', options), close: () => ipcRenderer.invoke('chat:close'), getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), + getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId), + getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId), + getExportSessionStats: ( + sessionIds: string[], + options?: { + includeRelations?: boolean + forceRefresh?: boolean + allowStaleCache?: boolean + preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean + } + ) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options), + getGroupMyMessageCountHint: (chatroomId: string) => + ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId), getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId), getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId), getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId), + getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), - onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => { - const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload) + onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => { + const listener = (_: any, payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => callback(payload) ipcRenderer.on('chat:voiceTranscriptPartial', listener) return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener) }, - execQuery: (kind: string, path: string | null, sql: string) => - ipcRenderer.invoke('chat:execQuery', kind, path, sql), getContacts: () => ipcRenderer.invoke('chat:getContacts'), getMessage: (sessionId: string, localId: number) => ipcRenderer.invoke('chat:getMessage', sessionId, localId), + searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => + ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp), onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => { ipcRenderer.on('wcdb-change', callback) return () => ipcRenderer.removeListener('wcdb-change', callback) @@ -185,12 +247,14 @@ contextBridge.exposeInMainWorld('electronAPI', { preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => ipcRenderer.invoke('image:preload', payloads), onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { - ipcRenderer.on('image:updateAvailable', (_, payload) => callback(payload)) - return () => ipcRenderer.removeAllListeners('image:updateAvailable') + const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload) + ipcRenderer.on('image:updateAvailable', listener) + return () => ipcRenderer.removeListener('image:updateAvailable', listener) }, onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => { - ipcRenderer.on('image:cacheResolved', (_, payload) => callback(payload)) - return () => ipcRenderer.removeAllListeners('image:cacheResolved') + const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload) + ipcRenderer.on('image:cacheResolved', listener) + return () => ipcRenderer.removeListener('image:cacheResolved', listener) } }, @@ -226,9 +290,18 @@ contextBridge.exposeInMainWorld('electronAPI', { groupAnalytics: { getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'), getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId), + getGroupMembersPanelData: ( + chatroomId: string, + options?: { forceRefresh?: boolean; includeMessageCounts?: boolean } + ) => ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, options), getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime), getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime), getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime), + getGroupMemberMessages: ( + chatroomId: string, + memberUsername: string, + options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number } + ) => ipcRenderer.invoke('groupAnalytics:getGroupMemberMessages', chatroomId, memberUsername, options), exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath), exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime) @@ -237,9 +310,29 @@ contextBridge.exposeInMainWorld('electronAPI', { // 年度报告 annualReport: { getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'), + startAvailableYearsLoad: () => ipcRenderer.invoke('annualReport:startAvailableYearsLoad'), + cancelAvailableYearsLoad: (taskId: string) => ipcRenderer.invoke('annualReport:cancelAvailableYearsLoad', taskId), generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year), exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => ipcRenderer.invoke('annualReport:exportImages', payload), + onAvailableYearsProgress: (callback: (payload: { + taskId: string + years?: number[] + done: boolean + error?: string + canceled?: boolean + strategy?: 'cache' | 'native' | 'hybrid' + phase?: 'cache' | 'native' | 'scan' | 'done' + statusText?: string + nativeElapsedMs?: number + scanElapsedMs?: number + totalElapsedMs?: number + switched?: boolean + nativeTimedOut?: boolean + }) => void) => { + ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload)) + return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress') + }, onProgress: (callback: (payload: { status: string; progress: number }) => void) => { ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('annualReport:progress') @@ -264,7 +357,20 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options), exportContacts: (outputDir: string, options: any) => ipcRenderer.invoke('export:exportContacts', outputDir, options), - onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => { + onProgress: (callback: (payload: { + current: number + total: number + currentSession: string + currentSessionId?: string + phase: string + phaseProgress?: number + phaseTotal?: number + phaseLabel?: string + collectedMessages?: number + exportedMessages?: number + estimatedTotalMessages?: number + writtenFiles?: number + }) => void) => { ipcRenderer.on('export:progress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('export:progress') } @@ -286,6 +392,10 @@ contextBridge.exposeInMainWorld('electronAPI', { getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), + getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'), + getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), + getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), + getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload), @@ -302,6 +412,14 @@ contextBridge.exposeInMainWorld('electronAPI', { downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params) }, + + // 数据收集 + cloud: { + init: () => ipcRenderer.invoke('cloud:init'), + recordPage: (pageName: string) => ipcRenderer.invoke('cloud:recordPage', pageName), + getLogs: () => ipcRenderer.invoke('cloud:getLogs') + }, + // HTTP API 服务 http: { start: (port?: number) => ipcRenderer.invoke('http:start', port), diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts index 875be7a..1ba6c00 100644 --- a/electron/services/analyticsService.ts +++ b/electron/services/analyticsService.ts @@ -68,29 +68,14 @@ class AnalyticsService { return new Set(this.getExcludedUsernamesList()) } - private escapeSqlValue(value: string): string { - return value.replace(/'/g, "''") - } - private async getAliasMap(usernames: string[]): Promise> { const map: Record = {} if (usernames.length === 0) return map - // C++ 层不支持参数绑定,直接内联转义后的字符串值 - const chunkSize = 200 - for (let i = 0; i < usernames.length; i += chunkSize) { - const chunk = usernames.slice(i, i + chunkSize) - const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',') - const sql = `SELECT username, alias FROM contact WHERE username IN (${inList})` - const result = await wcdbService.execQuery('contact', null, sql) - if (!result.success || !result.rows) continue - for (const row of result.rows as Record[]) { - const username = row.username || '' - const alias = row.alias || '' - if (username && alias) { - map[username] = alias - } - } + const result = await wcdbService.getContactAliasMap(usernames) + if (!result.success || !result.map) return map + for (const [username, alias] of Object.entries(result.map)) { + if (username && alias) map[username] = alias } return map diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts index 86a7086..e6e0967 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -85,7 +85,34 @@ export interface AnnualReportData { } | null } +export interface AvailableYearsLoadProgress { + years: number[] + strategy: 'cache' | 'native' | 'hybrid' + phase: 'cache' | 'native' | 'scan' + statusText: string + nativeElapsedMs: number + scanElapsedMs: number + totalElapsedMs: number + switched?: boolean + nativeTimedOut?: boolean +} + +interface AvailableYearsLoadMeta { + strategy: 'cache' | 'native' | 'hybrid' + nativeElapsedMs: number + scanElapsedMs: number + totalElapsedMs: number + switched: boolean + nativeTimedOut: boolean + statusText: string +} + class AnnualReportService { + private readonly availableYearsCacheTtlMs = 10 * 60 * 1000 + private readonly availableYearsScanConcurrency = 4 + private readonly availableYearsColumnCache = new Map() + private readonly availableYearsCache = new Map() + constructor() { } @@ -181,6 +208,235 @@ class AnnualReportService { } } + private quoteSqlIdentifier(identifier: string): string { + return `"${String(identifier || '').replace(/"/g, '""')}"` + } + + private toUnixTimestamp(value: any): number { + const n = Number(value) + if (!Number.isFinite(n) || n <= 0) return 0 + // 兼容毫秒级时间戳 + const seconds = n > 1e12 ? Math.floor(n / 1000) : Math.floor(n) + return seconds > 0 ? seconds : 0 + } + + private addYearsFromRange(years: Set, firstTs: number, lastTs: number): boolean { + let changed = false + const currentYear = new Date().getFullYear() + const minTs = firstTs > 0 ? firstTs : lastTs + const maxTs = lastTs > 0 ? lastTs : firstTs + if (minTs <= 0 || maxTs <= 0) return changed + + const minYear = new Date(minTs * 1000).getFullYear() + const maxYear = new Date(maxTs * 1000).getFullYear() + for (let y = minYear; y <= maxYear; y++) { + if (y >= 2010 && y <= currentYear && !years.has(y)) { + years.add(y) + changed = true + } + } + return changed + } + + private normalizeAvailableYears(years: Iterable): number[] { + return Array.from(new Set(Array.from(years))) + .filter((y) => Number.isFinite(y)) + .map((y) => Math.floor(y)) + .sort((a, b) => b - a) + } + + private async forEachWithConcurrency( + items: T[], + concurrency: number, + handler: (item: T, index: number) => Promise, + shouldStop?: () => boolean + ): Promise { + if (!items.length) return + const workerCount = Math.max(1, Math.min(concurrency, items.length)) + let nextIndex = 0 + const workers: Promise[] = [] + + for (let i = 0; i < workerCount; i++) { + workers.push((async () => { + while (true) { + if (shouldStop?.()) break + const current = nextIndex + nextIndex += 1 + if (current >= items.length) break + await handler(items[current], current) + } + })()) + } + + await Promise.all(workers) + } + + private async detectTimeColumn(dbPath: string, tableName: string): Promise { + const cacheKey = `${dbPath}\u0001${tableName}` + if (this.availableYearsColumnCache.has(cacheKey)) { + const cached = this.availableYearsColumnCache.get(cacheKey) || '' + return cached || null + } + + const result = await wcdbService.getMessageTableColumns(dbPath, tableName) + if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) { + this.availableYearsColumnCache.set(cacheKey, '') + return null + } + + const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time'] + const columns = new Set() + for (const columnName of result.columns) { + const name = String(columnName || '').trim().toLowerCase() + if (name) columns.add(name) + } + + for (const candidate of candidates) { + if (columns.has(candidate)) { + this.availableYearsColumnCache.set(cacheKey, candidate) + return candidate + } + } + + this.availableYearsColumnCache.set(cacheKey, '') + return null + } + + private async getTableTimeRange(dbPath: string, tableName: string): Promise<{ first: number; last: number } | null> { + const cacheKey = `${dbPath}\u0001${tableName}` + const cachedColumn = this.availableYearsColumnCache.get(cacheKey) + const initialColumn = cachedColumn && cachedColumn.length > 0 ? cachedColumn : 'create_time' + const tried = new Set() + + const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => { + const result = await wcdbService.getMessageTableTimeRange(dbPath, tableName) + if (!result.success || !result.data) return null + const row = result.data as Record + const actualColumn = String(row.column || '').trim().toLowerCase() + if (column && actualColumn && column.toLowerCase() !== actualColumn) return null + const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs) + const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs) + return { first, last } + } + + tried.add(initialColumn) + const quick = await queryByColumn(initialColumn) + if (quick) { + if (!cachedColumn) this.availableYearsColumnCache.set(cacheKey, initialColumn) + return quick + } + + const detectedColumn = await this.detectTimeColumn(dbPath, tableName) + if (!detectedColumn || tried.has(detectedColumn)) { + return null + } + + return queryByColumn(detectedColumn) + } + + private async getAvailableYearsByTableScan( + sessionIds: string[], + options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean } + ): Promise { + const years = new Set() + let lastEmittedSize = 0 + + const emitIfChanged = (force = false) => { + if (!options?.onProgress) return + const next = this.normalizeAvailableYears(years) + if (!force && next.length === lastEmittedSize) return + options.onProgress(next) + lastEmittedSize = next.length + } + + const shouldCancel = () => options?.shouldCancel?.() === true + + await this.forEachWithConcurrency(sessionIds, this.availableYearsScanConcurrency, async (sessionId) => { + if (shouldCancel()) return + const tableStats = await wcdbService.getMessageTableStats(sessionId) + if (!tableStats.success || !Array.isArray(tableStats.tables) || tableStats.tables.length === 0) { + return + } + + for (const table of tableStats.tables as Record[]) { + if (shouldCancel()) return + const tableName = String(table.table_name || table.name || '').trim() + const dbPath = String(table.db_path || table.dbPath || '').trim() + if (!tableName || !dbPath) continue + + const range = await this.getTableTimeRange(dbPath, tableName) + if (!range) continue + const changed = this.addYearsFromRange(years, range.first, range.last) + if (changed) emitIfChanged() + } + }, shouldCancel) + + emitIfChanged(true) + return this.normalizeAvailableYears(years) + } + + private async getAvailableYearsByEdgeScan( + sessionIds: string[], + options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean } + ): Promise { + const years = new Set() + let lastEmittedSize = 0 + const shouldCancel = () => options?.shouldCancel?.() === true + + const emitIfChanged = (force = false) => { + if (!options?.onProgress) return + const next = this.normalizeAvailableYears(years) + if (!force && next.length === lastEmittedSize) return + options.onProgress(next) + lastEmittedSize = next.length + } + + for (const sessionId of sessionIds) { + if (shouldCancel()) break + const first = await this.getEdgeMessageTime(sessionId, true) + const last = await this.getEdgeMessageTime(sessionId, false) + const changed = this.addYearsFromRange(years, first || 0, last || 0) + if (changed) emitIfChanged() + } + emitIfChanged(true) + return this.normalizeAvailableYears(years) + } + + private buildAvailableYearsCacheKey(dbPath: string, cleanedWxid: string): string { + return `${dbPath}\u0001${cleanedWxid}` + } + + private getCachedAvailableYears(cacheKey: string): number[] | null { + const cached = this.availableYearsCache.get(cacheKey) + if (!cached) return null + if (Date.now() - cached.updatedAt > this.availableYearsCacheTtlMs) { + this.availableYearsCache.delete(cacheKey) + return null + } + return [...cached.years] + } + + private setCachedAvailableYears(cacheKey: string, years: number[]): void { + const normalized = this.normalizeAvailableYears(years) + + this.availableYearsCache.set(cacheKey, { + years: normalized, + updatedAt: Date.now() + }) + + if (this.availableYearsCache.size > 8) { + let oldestKey = '' + let oldestTime = Number.POSITIVE_INFINITY + for (const [key, val] of this.availableYearsCache) { + if (val.updatedAt < oldestTime) { + oldestTime = val.updatedAt + oldestKey = key + } + } + if (oldestKey) this.availableYearsCache.delete(oldestKey) + } + } + private decodeMessageContent(messageContent: any, compressContent: any): string { let content = this.decodeMaybeCompressed(compressContent) if (!content || content.length === 0) { @@ -359,38 +615,226 @@ class AnnualReportService { return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd } } - async getAvailableYears(params: { dbPath: string; decryptKey: string; wxid: string }): Promise<{ success: boolean; data?: number[]; error?: string }> { + async getAvailableYears(params: { + dbPath: string + decryptKey: string + wxid: string + onProgress?: (payload: AvailableYearsLoadProgress) => void + shouldCancel?: () => boolean + nativeTimeoutMs?: number + }): Promise<{ success: boolean; data?: number[]; error?: string; meta?: AvailableYearsLoadMeta }> { try { + const isCancelled = () => params.shouldCancel?.() === true + const totalStartedAt = Date.now() + let nativeElapsedMs = 0 + let scanElapsedMs = 0 + let switched = false + let nativeTimedOut = false + let latestYears: number[] = [] + + const emitProgress = (payload: { + years?: number[] + strategy: 'cache' | 'native' | 'hybrid' + phase: 'cache' | 'native' | 'scan' + statusText: string + switched?: boolean + nativeTimedOut?: boolean + }) => { + if (!params.onProgress) return + if (Array.isArray(payload.years)) latestYears = payload.years + params.onProgress({ + years: latestYears, + strategy: payload.strategy, + phase: payload.phase, + statusText: payload.statusText, + nativeElapsedMs, + scanElapsedMs, + totalElapsedMs: Date.now() - totalStartedAt, + switched: payload.switched ?? switched, + nativeTimedOut: payload.nativeTimedOut ?? nativeTimedOut + }) + } + + const buildMeta = ( + strategy: 'cache' | 'native' | 'hybrid', + statusText: string + ): AvailableYearsLoadMeta => ({ + strategy, + nativeElapsedMs, + scanElapsedMs, + totalElapsedMs: Date.now() - totalStartedAt, + switched, + nativeTimedOut, + statusText + }) + const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid) - if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } - - const sessionIds = await this.getPrivateSessions(conn.cleanedWxid) - if (sessionIds.length === 0) { - return { success: false, error: '未找到消息会话' } - } - - const fastYears = await wcdbService.getAvailableYears(sessionIds) - if (fastYears.success && fastYears.data) { - return { success: true, data: fastYears.data } - } - - const years = new Set() - for (const sessionId of sessionIds) { - const first = await this.getEdgeMessageTime(sessionId, true) - const last = await this.getEdgeMessageTime(sessionId, false) - if (!first && !last) continue - - const minYear = new Date((first || last || 0) * 1000).getFullYear() - const maxYear = new Date((last || first || 0) * 1000).getFullYear() - for (let y = minYear; y <= maxYear; y++) { - if (y >= 2010 && y <= new Date().getFullYear()) years.add(y) + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error, meta: buildMeta('hybrid', '连接数据库失败') } + if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } + const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid) + const cached = this.getCachedAvailableYears(cacheKey) + if (cached) { + latestYears = cached + emitProgress({ + years: cached, + strategy: 'cache', + phase: 'cache', + statusText: '命中缓存,已快速加载年份数据' + }) + return { + success: true, + data: cached, + meta: buildMeta('cache', '命中缓存,已快速加载年份数据') } } - const sortedYears = Array.from(years).sort((a, b) => b - a) - return { success: true, data: sortedYears } + const sessionIds = await this.getPrivateSessions(conn.cleanedWxid) + if (sessionIds.length === 0) { + return { success: false, error: '未找到消息会话', meta: buildMeta('hybrid', '未找到消息会话') } + } + if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } + + const nativeTimeoutMs = Math.max(1000, Math.floor(params.nativeTimeoutMs || 5000)) + const nativeStartedAt = Date.now() + let nativeTicker: ReturnType | null = null + + emitProgress({ + strategy: 'native', + phase: 'native', + statusText: '正在使用原生快速模式加载年份...' + }) + nativeTicker = setInterval(() => { + nativeElapsedMs = Date.now() - nativeStartedAt + emitProgress({ + strategy: 'native', + phase: 'native', + statusText: '正在使用原生快速模式加载年份...' + }) + }, 120) + + const nativeRace = await Promise.race([ + wcdbService.getAvailableYears(sessionIds) + .then((result) => ({ kind: 'result' as const, result })) + .catch((error) => ({ kind: 'error' as const, error: String(error) })), + new Promise<{ kind: 'timeout' }>((resolve) => setTimeout(() => resolve({ kind: 'timeout' }), nativeTimeoutMs)) + ]) + + if (nativeTicker) { + clearInterval(nativeTicker) + nativeTicker = null + } + nativeElapsedMs = Math.max(nativeElapsedMs, Date.now() - nativeStartedAt) + + if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } + + if (nativeRace.kind === 'result' && nativeRace.result.success && Array.isArray(nativeRace.result.data) && nativeRace.result.data.length > 0) { + const years = this.normalizeAvailableYears(nativeRace.result.data) + latestYears = years + this.setCachedAvailableYears(cacheKey, years) + emitProgress({ + years, + strategy: 'native', + phase: 'native', + statusText: '原生快速模式加载完成' + }) + return { + success: true, + data: years, + meta: buildMeta('native', '原生快速模式加载完成') + } + } + + switched = true + nativeTimedOut = nativeRace.kind === 'timeout' + emitProgress({ + strategy: 'hybrid', + phase: 'native', + statusText: nativeTimedOut + ? '原生快速模式超时,已自动切换到扫表兼容模式...' + : '原生快速模式不可用,已自动切换到扫表兼容模式...', + switched: true, + nativeTimedOut + }) + + const scanStartedAt = Date.now() + let scanTicker: ReturnType | null = null + scanTicker = setInterval(() => { + scanElapsedMs = Date.now() - scanStartedAt + emitProgress({ + strategy: 'hybrid', + phase: 'scan', + statusText: nativeTimedOut + ? '原生已超时,正在使用扫表兼容模式加载年份...' + : '正在使用扫表兼容模式加载年份...', + switched: true, + nativeTimedOut + }) + }, 120) + + let years = await this.getAvailableYearsByTableScan(sessionIds, { + onProgress: (items) => { + latestYears = items + scanElapsedMs = Date.now() - scanStartedAt + emitProgress({ + years: items, + strategy: 'hybrid', + phase: 'scan', + statusText: nativeTimedOut + ? '原生已超时,正在使用扫表兼容模式加载年份...' + : '正在使用扫表兼容模式加载年份...', + switched: true, + nativeTimedOut + }) + }, + shouldCancel: params.shouldCancel + }) + + if (isCancelled()) { + if (scanTicker) clearInterval(scanTicker) + return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } + } + if (years.length === 0) { + years = await this.getAvailableYearsByEdgeScan(sessionIds, { + onProgress: (items) => { + latestYears = items + scanElapsedMs = Date.now() - scanStartedAt + emitProgress({ + years: items, + strategy: 'hybrid', + phase: 'scan', + statusText: '扫表结果为空,正在执行游标兜底扫描...', + switched: true, + nativeTimedOut + }) + }, + shouldCancel: params.shouldCancel + }) + } + if (scanTicker) { + clearInterval(scanTicker) + scanTicker = null + } + scanElapsedMs = Math.max(scanElapsedMs, Date.now() - scanStartedAt) + + if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') } + + this.setCachedAvailableYears(cacheKey, years) + latestYears = years + emitProgress({ + years, + strategy: 'hybrid', + phase: 'scan', + statusText: '扫表兼容模式加载完成', + switched: true, + nativeTimedOut + }) + return { + success: true, + data: years, + meta: buildMeta('hybrid', '扫表兼容模式加载完成') + } } catch (e) { - return { success: false, error: String(e) } + return { success: false, error: String(e), meta: { strategy: 'hybrid', nativeElapsedMs: 0, scanElapsedMs: 0, totalElapsedMs: 0, switched: false, nativeTimedOut: false, statusText: '加载年度数据失败' } } } } diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e188de8..4a71e88 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -1,26 +1,23 @@ import { join, dirname, basename, extname } from 'path' -import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch } from 'fs' +import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs' import * as path from 'path' import * as fs from 'fs' import * as https from 'https' import * as http from 'http' import * as fzstd from 'fzstd' import * as crypto from 'crypto' -import Database from 'better-sqlite3' -import { app, BrowserWindow } from 'electron' +import { app, BrowserWindow, dialog } from 'electron' import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { MessageCacheService } from './messageCacheService' import { ContactCacheService, ContactCacheEntry } from './contactCacheService' +import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService' +import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from './groupMyMessageCountCacheService' +import { exportCardDiagnosticsService } from './exportCardDiagnosticsService' import { voiceTranscribeService } from './voiceTranscribeService' +import { ImageDecryptService } from './imageDecryptService' import { LRUCache } from '../utils/LRUCache.js' -type HardlinkState = { - db: Database.Database - imageTable?: string - dirTable?: string -} - export interface ChatSession { username: string type: number @@ -29,6 +26,7 @@ export interface ChatSession { sortTimestamp: number // 用于排序 lastTimestamp: number // 用于显示时间 lastMsgType: number + messageCountHint?: number displayName?: string avatarUrl?: string lastMsgSender?: string @@ -39,8 +37,10 @@ export interface ChatSession { } export interface Message { + messageKey: string localId: number serverId: number + serverIdRaw?: string localType: number createTime: number sortSeq: number @@ -114,8 +114,28 @@ export interface Message { datatype: number sourcename: string sourcetime: string - datadesc: string + sourceheadurl?: string + datadesc?: string datatitle?: string + fileext?: string + datasize?: number + messageuuid?: string + dataurl?: string + datathumburl?: string + datacdnurl?: string + cdndatakey?: string + cdnthumbkey?: string + aeskey?: string + md5?: string + fullmd5?: string + thumbfullmd5?: string + srcMsgLocalid?: number + imgheight?: number + imgwidth?: number + duration?: number + chatRecordTitle?: string + chatRecordDesc?: string + chatRecordList?: any[] }> _db_path?: string // 内部字段:记录消息所属数据库路径 } @@ -132,26 +152,89 @@ export interface ContactInfo { displayName: string remark?: string nickname?: string + alias?: string avatarUrl?: string type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' } +interface ExportSessionStats { + totalMessages: number + voiceMessages: number + imageMessages: number + videoMessages: number + emojiMessages: number + transferMessages: number + redPacketMessages: number + callMessages: number + firstTimestamp?: number + lastTimestamp?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number +} + +interface ExportSessionStatsOptions { + includeRelations?: boolean + forceRefresh?: boolean + allowStaleCache?: boolean + preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean +} + +interface ExportSessionStatsCacheMeta { + updatedAt: number + stale: boolean + includeRelations: boolean + source: 'memory' | 'disk' | 'fresh' +} + +interface ExportTabCounts { + private: number + group: number + official: number + former_friend: number +} + +interface SessionDetailFast { + wxid: string + displayName: string + remark?: string + nickName?: string + alias?: string + avatarUrl?: string + messageCount: number +} + +interface SessionDetailExtra { + firstMessageTime?: number + latestMessageTime?: number + messageTables: { dbName: string; tableName: string; count: number }[] +} + +type SessionDetail = SessionDetailFast & SessionDetailExtra + // 表情包缓存 const emojiCache: Map = new Map() const emojiDownloading: Map> = new Map() +const FRIEND_EXCLUDE_USERNAMES = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) class ChatService { private configService: ConfigService private connected = false + private readonly dbMonitorListeners = new Set<(type: string, json: string) => void>() private messageCursors: Map = new Map() private messageCursorMutex: boolean = false private readonly messageBatchDefault = 50 private avatarCache: Map private readonly avatarCacheTtlMs = 10 * 60 * 1000 private readonly defaultV1AesKey = 'cfcd208495d565ef' - private hardlinkCache = new Map() private readonly contactCacheService: ContactCacheService private readonly messageCacheService: MessageCacheService + private readonly sessionStatsCacheService: SessionStatsCacheService + private readonly groupMyMessageCountCacheService: GroupMyMessageCountCacheService + private readonly imageDecryptService: ImageDecryptService private voiceWavCache: LRUCache private voiceTranscriptCache: LRUCache private voiceTranscriptPending = new Map>() @@ -171,8 +254,45 @@ class ChatService { name2IdTable?: string }>() // 缓存会话表信息,避免每次查询 - private sessionTablesCache = new Map>() + private sessionTablesCache = new Map; updatedAt: number }>() + private messageTableColumnsCache = new Map; updatedAt: number }>() + private messageName2IdTableCache = new Map() + private messageSenderIdCache = new Map() private readonly sessionTablesCacheTtl = 300000 // 5分钟 + private readonly messageTableColumnsCacheTtlMs = 30 * 60 * 1000 + private messageDbCountSnapshotCache: { + dbPaths: string[] + dbSignature: string + updatedAt: number + } | null = null + private readonly messageDbCountSnapshotCacheTtlMs = 8000 + private sessionMessageCountCache = new Map() + private sessionMessageCountHintCache = new Map() + private sessionMessageCountBatchCache: { + dbSignature: string + sessionIdsKey: string + counts: Record + updatedAt: number + } | null = null + private sessionMessageCountCacheScope = '' + private readonly sessionMessageCountCacheTtlMs = 10 * 60 * 1000 + private readonly sessionMessageCountBatchCacheTtlMs = 5 * 60 * 1000 + private sessionDetailFastCache = new Map() + private sessionDetailExtraCache = new Map() + private readonly sessionDetailFastCacheTtlMs = 60 * 1000 + private readonly sessionDetailExtraCacheTtlMs = 5 * 60 * 1000 + private sessionStatusCache = new Map() + private readonly sessionStatusCacheTtlMs = 10 * 60 * 1000 + private sessionStatsCacheScope = '' + private sessionStatsMemoryCache = new Map() + private sessionStatsPendingBasic = new Map>() + private sessionStatsPendingFull = new Map>() + private allGroupSessionIdsCache: { ids: string[]; updatedAt: number } | null = null + private readonly sessionStatsCacheTtlMs = 10 * 60 * 1000 + private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000 + private groupMyMessageCountCacheScope = '' + private groupMyMessageCountMemoryCache = new Map() + private initFailureDialogShown = false constructor() { this.configService = new ConfigService() @@ -180,6 +300,9 @@ class ChatService { const persisted = this.contactCacheService.getAllEntries() this.avatarCache = new Map(Object.entries(persisted)) this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath()) + this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath()) + this.groupMyMessageCountCacheService = new GroupMyMessageCountCacheService(this.configService.getCacheBasePath()) + this.imageDecryptService = new ImageDecryptService() // 初始化LRU缓存,限制大小防止内存泄漏 this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries) this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录 @@ -204,6 +327,67 @@ class ChatService { return cleaned } + /** + * 判断头像 URL 是否可用,过滤历史缓存里的错误 hex 数据。 + */ + private isValidAvatarUrl(avatarUrl?: string): avatarUrl is string { + const normalized = String(avatarUrl || '').trim() + if (!normalized) return false + const normalizedLower = normalized.toLowerCase() + if (normalizedLower.includes('base64,ffd8')) return false + if (normalizedLower.startsWith('ffd8')) return false + 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 { + 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 { + // 弹窗失败不阻断主流程 + } + } + /** * 连接数据库 */ @@ -228,7 +412,9 @@ class ChatService { const cleanedWxid = this.cleanAccountDirName(wxid) const openOk = await wcdbService.open(dbPath, decryptKey, cleanedWxid) if (!openOk) { - return { success: false, error: 'WCDB 打开失败,请检查路径和密钥' } + const detailedError = this.toCodeOnlyMessage(await wcdbService.getLastInitError()) + await this.maybeShowInitFailureDialog(detailedError) + return { success: false, error: detailedError } } this.connected = true @@ -242,12 +428,19 @@ class ChatService { return { success: true } } catch (e) { console.error('ChatService: 连接数据库失败:', e) - return { success: false, error: String(e) } + return { success: false, error: this.toCodeOnlyMessage(String(e), -3998) } } } private monitorSetup = false + addDbMonitorListener(listener: (type: string, json: string) => void): () => void { + this.dbMonitorListeners.add(listener) + return () => { + this.dbMonitorListeners.delete(listener) + } + } + private setupDbMonitor() { if (this.monitorSetup) return this.monitorSetup = true @@ -255,8 +448,17 @@ class ChatService { // 使用 C++ DLL 内部的文件监控 (ReadDirectoryChangesW) // 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更 wcdbService.setMonitor((type, json) => { + this.handleSessionStatsMonitorChange(type, json) + for (const listener of this.dbMonitorListeners) { + try { + listener(type, json) + } catch (error) { + console.error('[ChatService] 数据库监听回调失败:', error) + } + } + const windows = BrowserWindow.getAllWindows() // 广播给所有渲染进程窗口 - BrowserWindow.getAllWindows().forEach((win) => { + windows.forEach((win) => { if (!win.isDestroyed()) { win.webContents.send('wcdb-change', { type, json }) } @@ -342,6 +544,7 @@ class ChatService { if (!connectResult.success) { return { success: false, error: connectResult.error } } + this.refreshSessionMessageCountCacheScope() const result = await wcdbService.getSessions() if (!result.success || !result.sessions) { @@ -357,7 +560,7 @@ class ChatService { return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` } } - // 转换为 ChatSession(先加载缓存,但不等待数据库查询) + // 转换为 ChatSession(先加载缓存,但不等待额外状态查询) const sessions: ChatSession[] = [] const now = Date.now() const myWxid = this.configService.get('myWxid') @@ -395,6 +598,21 @@ class ChatService { const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '') const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10) + const messageCountHintRaw = + row.message_count ?? + row.messageCount ?? + row.msg_count ?? + row.msgCount ?? + row.total_count ?? + row.totalCount ?? + row.n_msg ?? + row.nMsg ?? + row.message_num ?? + row.messageNum + const parsedMessageCountHint = Number(messageCountHintRaw) + const messageCountHint = Number.isFinite(parsedMessageCountHint) && parsedMessageCountHint >= 0 + ? Math.floor(parsedMessageCountHint) + : undefined // 先尝试从缓存获取联系人信息(快速路径) let displayName = username @@ -405,7 +623,7 @@ class ChatService { avatarUrl = cached.avatarUrl } - sessions.push({ + const nextSession: ChatSession = { username, type: parseInt(row.type || '0', 10), unreadCount: parseInt(row.unread_count || row.unreadCount || row.unreadcount || '0', 10), @@ -413,29 +631,29 @@ class ChatService { sortTimestamp: sortTs, lastTimestamp: lastTs, lastMsgType, + messageCountHint, displayName, avatarUrl, lastMsgSender: row.last_msg_sender, lastSenderDisplayName: row.last_sender_display_name, selfWxid: myWxid - }) - } - - // 批量拉取 extra_buffer 状态(isFolded/isMuted),不阻塞主流程 - const allUsernames = sessions.map(s => s.username) - try { - const statusResult = await wcdbService.getContactStatus(allUsernames) - if (statusResult.success && statusResult.map) { - for (const s of sessions) { - const st = statusResult.map[s.username] - if (st) { - s.isFolded = st.isFolded - s.isMuted = st.isMuted - } - } } - } catch { - // 状态获取失败不影响会话列表返回 + + const cachedStatus = this.sessionStatusCache.get(username) + if (cachedStatus && now - cachedStatus.updatedAt <= this.sessionStatusCacheTtlMs) { + nextSession.isFolded = cachedStatus.isFolded + nextSession.isMuted = cachedStatus.isMuted + } + + sessions.push(nextSession) + + if (typeof messageCountHint === 'number') { + this.sessionMessageCountHintCache.set(username, messageCountHint) + this.sessionMessageCountCache.set(username, { + count: messageCountHint, + updatedAt: Date.now() + }) + } } // 不等待联系人信息加载,直接返回基础会话列表 @@ -447,18 +665,69 @@ class ChatService { } } + async getSessionStatuses(usernames: string[]): Promise<{ + success: boolean + map?: Record + error?: string + }> { + try { + if (!Array.isArray(usernames) || usernames.length === 0) { + return { success: true, map: {} } + } + + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error } + } + + const result = await wcdbService.getContactStatus(usernames) + if (!result.success || !result.map) { + return { success: false, error: result.error || '获取会话状态失败' } + } + + const now = Date.now() + for (const username of usernames) { + const state = result.map[username] || { isFolded: false, isMuted: false } + this.sessionStatusCache.set(username, { + isFolded: Boolean(state.isFolded), + isMuted: Boolean(state.isMuted), + updatedAt: now + }) + } + + return { + success: true, + map: result.map as Record + } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 异步补充会话列表的联系人信息(公开方法,供前端调用) */ - async enrichSessionsContactInfo(usernames: string[]): Promise<{ + async enrichSessionsContactInfo( + usernames: string[], + options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean } + ): Promise<{ success: boolean contacts?: Record error?: string }> { try { - if (usernames.length === 0) { + const normalizedUsernames = Array.from( + new Set( + (usernames || []) + .map((username) => String(username || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedUsernames.length === 0) { return { success: true, contacts: {} } } + const skipDisplayName = options?.skipDisplayName === true + const onlyMissingAvatar = options?.onlyMissingAvatar === true const connectResult = await this.ensureConnected() if (!connectResult.success) { @@ -471,16 +740,23 @@ class ChatService { const updatedEntries: Record = {} // 检查缓存 - for (const username of usernames) { + for (const username of normalizedUsernames) { const cached = this.avatarCache.get(username) + const isValidAvatar = this.isValidAvatarUrl(cached?.avatarUrl) + const cachedAvatarUrl = isValidAvatar ? cached?.avatarUrl : undefined + if (onlyMissingAvatar && cachedAvatarUrl) { + result[username] = { + displayName: skipDisplayName ? undefined : cached?.displayName, + avatarUrl: cachedAvatarUrl + } + continue + } // 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取 // 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取 - const isValidAvatar = cached?.avatarUrl && - !cached.avatarUrl.includes('base64,ffd8') // 检测错误的 hex 格式 if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) { result[username] = { - displayName: cached.displayName, - avatarUrl: cached.avatarUrl + displayName: skipDisplayName ? undefined : cached.displayName, + avatarUrl: cachedAvatarUrl } } else { missing.push(username) @@ -489,16 +765,19 @@ class ChatService { // 批量查询缺失的联系人信息 if (missing.length > 0) { - const [displayNames, avatarUrls] = await Promise.all([ - wcdbService.getDisplayNames(missing), - wcdbService.getAvatarUrls(missing) - ]) + const displayNames = skipDisplayName + ? null + : await wcdbService.getDisplayNames(missing) + const avatarUrls = await wcdbService.getAvatarUrls(missing) // 收集没有头像 URL 的用户名 const missingAvatars: string[] = [] for (const username of missing) { - const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined + const previous = this.avatarCache.get(username) + const displayName = displayNames?.success && displayNames.map + ? displayNames.map[username] + : undefined let avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined // 如果没有头像 URL,记录下来稍后从 head_image.db 获取 @@ -507,11 +786,14 @@ class ChatService { } const cacheEntry: ContactCacheEntry = { - displayName: displayName || username, + displayName: displayName || previous?.displayName || username, avatarUrl, updatedAt: now } - result[username] = { displayName, avatarUrl } + result[username] = { + displayName: skipDisplayName ? undefined : (displayName || previous?.displayName), + avatarUrl + } // 更新缓存并记录持久化 this.avatarCache.set(username, cacheEntry) updatedEntries[username] = cacheEntry @@ -552,64 +834,34 @@ class ChatService { if (usernames.length === 0) return result try { - const dbPath = this.configService.get('dbPath') - const wxid = this.configService.get('myWxid') - if (!dbPath || !wxid) return result + const normalizedUsernames = Array.from( + new Set( + usernames + .map((username) => String(username || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedUsernames.length === 0) return result - const accountDir = this.resolveAccountDir(dbPath, wxid) - if (!accountDir) return result + const batchSize = 320 + for (let i = 0; i < normalizedUsernames.length; i += batchSize) { + const batch = normalizedUsernames.slice(i, i + batchSize) + if (batch.length === 0) continue - // head_image.db 可能在不同位置 - const headImageDbPaths = [ - join(accountDir, 'db_storage', 'head_image', 'head_image.db'), - join(accountDir, 'db_storage', 'head_image.db'), - join(accountDir, 'head_image.db') - ] + const queryResult = await wcdbService.getHeadImageBuffers(batch) + if (!queryResult.success || !queryResult.map) continue - let headImageDbPath: string | null = null - for (const path of headImageDbPaths) { - if (existsSync(path)) { - headImageDbPath = path - break - } - } - - if (!headImageDbPath) return result - - // 使用 wcdbService.execQuery 查询加密的 head_image.db - for (const username of usernames) { - try { - const escapedUsername = username.replace(/'/g, "''") - const queryResult = await wcdbService.execQuery( - 'media', - headImageDbPath, - `SELECT image_buffer FROM head_image WHERE username = '${escapedUsername}' LIMIT 1` - ) - - if (queryResult.success && queryResult.rows && queryResult.rows.length > 0) { - const row = queryResult.rows[0] as any - if (row?.image_buffer) { - let base64Data: string - if (typeof row.image_buffer === 'string') { - // WCDB 返回的 BLOB 是十六进制字符串,需要转换为 base64 - if (row.image_buffer.toLowerCase().startsWith('ffd8')) { - const buffer = Buffer.from(row.image_buffer, 'hex') - base64Data = buffer.toString('base64') - } else { - base64Data = row.image_buffer - } - } else if (Buffer.isBuffer(row.image_buffer)) { - base64Data = row.image_buffer.toString('base64') - } else if (Array.isArray(row.image_buffer)) { - base64Data = Buffer.from(row.image_buffer).toString('base64') - } else { - continue - } + for (const [username, rawHex] of Object.entries(queryResult.map)) { + const hex = String(rawHex || '').trim() + if (!username || !hex) continue + try { + const base64Data = Buffer.from(hex, 'hex').toString('base64') + if (base64Data) { result[username] = `data:image/jpeg;base64,${base64Data}` } + } catch { + // ignore invalid blob hex } - } catch { - // 静默处理单个用户的错误 } } } catch (e) { @@ -641,6 +893,377 @@ class ChatService { } } + /** + * 获取联系人类型数量(好友、群聊、公众号、曾经的好友) + */ + async getContactTypeCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error } + } + + const result = await wcdbService.getContactTypeCounts() + if (!result.success || !result.counts) { + return { success: false, error: result.error || '获取联系人类型数量失败' } + } + + const counts: ExportTabCounts = { + private: Number(result.counts.private || 0), + group: Number(result.counts.group || 0), + official: Number(result.counts.official || 0), + former_friend: Number(result.counts.former_friend || 0) + } + + return { success: true, counts } + } catch (e) { + console.error('ChatService: 获取联系人类型数量失败:', e) + return { success: false, error: String(e) } + } + } + + /** + * 获取导出页会话分类数量(轻量接口,优先用于顶部 Tab 数量展示) + */ + async getExportTabCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { + return this.getContactTypeCounts() + } + + private async listMessageDbPathsForCount(): Promise<{ success: boolean; dbPaths?: string[]; error?: string }> { + try { + const result = await wcdbService.listMessageDbs() + if (!result.success) { + return { success: false, error: result.error || '获取消息数据库列表失败' } + } + const normalized = Array.from(new Set( + (result.data || []) + .map(pathItem => String(pathItem || '').trim()) + .filter(Boolean) + )) + return { success: true, dbPaths: normalized } + } catch (e) { + return { success: false, error: String(e) } + } + } + + private buildMessageDbSignature(dbPaths: string[]): string { + if (!Array.isArray(dbPaths) || dbPaths.length === 0) return 'empty' + const parts: string[] = [] + const sortedPaths = [...dbPaths].sort() + for (const dbPath of sortedPaths) { + try { + const stat = statSync(dbPath) + parts.push(`${dbPath}:${stat.size}:${Math.floor(stat.mtimeMs)}`) + } catch { + parts.push(`${dbPath}:missing`) + } + } + return parts.join('|') + } + + private buildSessionHashLookup(sessionIds: string[]): { + full32: Map + short16: Map + } { + const full32 = new Map() + const short16 = new Map() + for (const sessionId of sessionIds) { + const hash = crypto.createHash('md5').update(sessionId).digest('hex').toLowerCase() + full32.set(hash, sessionId) + const shortHash = hash.slice(0, 16) + const existing = short16.get(shortHash) + if (existing === undefined) { + short16.set(shortHash, sessionId) + } else if (existing !== sessionId) { + short16.set(shortHash, null) + } + } + return { full32, short16 } + } + + private matchSessionIdByTableName( + tableName: string, + hashLookup: { + full32: Map + short16: Map + } + ): string | null { + const normalized = String(tableName || '').trim().toLowerCase() + if (!normalized.startsWith('msg_')) return null + const suffix = normalized.slice(4) + + const directFull = hashLookup.full32.get(suffix) + if (directFull) return directFull + + if (suffix.length >= 16) { + const shortCandidate = hashLookup.short16.get(suffix.slice(0, 16)) + if (typeof shortCandidate === 'string') return shortCandidate + } + + const hashMatch = normalized.match(/[a-f0-9]{32}|[a-f0-9]{16}/i) + if (!hashMatch || !hashMatch[0]) return null + const matchedHash = hashMatch[0].toLowerCase() + if (matchedHash.length >= 32) { + const full = hashLookup.full32.get(matchedHash) + if (full) return full + } + const short = hashLookup.short16.get(matchedHash.slice(0, 16)) + return typeof short === 'string' ? short : null + } + + private quoteSqlIdentifier(identifier: string): string { + return `"${String(identifier || '').replace(/"/g, '""')}"` + } + + private async countSessionMessageCountsByTableScan( + sessionIds: string[], + traceId?: string + ): Promise<{ + success: boolean + counts?: Record + error?: string + dbSignature?: string + }> { + const normalizedSessionIds = Array.from(new Set( + (sessionIds || []) + .map(id => String(id || '').trim()) + .filter(Boolean) + )) + if (normalizedSessionIds.length === 0) { + return { success: true, counts: {}, dbSignature: 'empty' } + } + + const snapshotResult = await this.getMessageDbCountSnapshot() + const dbPaths = snapshotResult.success ? (snapshotResult.dbPaths || []) : [] + const dbSignature = snapshotResult.success + ? (snapshotResult.dbSignature || this.buildMessageDbSignature(dbPaths)) + : this.buildMessageDbSignature(dbPaths) + const nativeResult = await wcdbService.getSessionMessageCounts(normalizedSessionIds) + if (!nativeResult.success || !nativeResult.counts) { + return { success: false, error: nativeResult.error || '获取会话消息总数失败', dbSignature } + } + const counts = normalizedSessionIds.reduce>((acc, sid) => { + const raw = nativeResult.counts?.[sid] + acc[sid] = Number.isFinite(raw) ? Math.max(0, Math.floor(Number(raw))) : 0 + return acc + }, {}) + + this.logExportDiag({ + traceId, + level: 'debug', + source: 'backend', + stepId: 'backend-get-session-message-counts-table-scan', + stepName: '会话消息总数表扫描', + status: 'done', + message: '按 Msg 表聚合统计完成', + data: { + dbCount: dbPaths.length, + requestedSessions: normalizedSessionIds.length + } + }) + + return { success: true, counts, dbSignature } + } + + /** + * 批量获取会话消息总数(轻量接口,用于列表优先排序) + */ + async getSessionMessageCounts( + sessionIds: string[], + options?: { preferHintCache?: boolean; bypassSessionCache?: boolean; traceId?: string } + ): Promise<{ + success: boolean + counts?: Record + error?: string + }> { + const traceId = this.normalizeExportDiagTraceId(options?.traceId) + const stepStartedAt = this.startExportDiagStep({ + traceId, + stepId: 'backend-get-session-message-counts', + stepName: 'ChatService.getSessionMessageCounts', + message: '开始批量读取会话消息总数', + data: { + requestedSessions: Array.isArray(sessionIds) ? sessionIds.length : 0, + preferHintCache: options?.preferHintCache !== false, + bypassSessionCache: options?.bypassSessionCache === true + } + }) + let success = false + let errorMessage = '' + let returnedCounts = 0 + + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + errorMessage = connectResult.error || '数据库未连接' + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const normalizedSessionIds = Array.from( + new Set( + (sessionIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedSessionIds.length === 0) { + success = true + return { success: true, counts: {} } + } + + const preferHintCache = options?.preferHintCache !== false + const bypassSessionCache = options?.bypassSessionCache === true + + this.refreshSessionMessageCountCacheScope() + const counts: Record = {} + const now = Date.now() + const pendingSessionIds: string[] = [] + const sessionIdsKey = [...normalizedSessionIds].sort().join('\u0001') + + for (const sessionId of normalizedSessionIds) { + if (!bypassSessionCache) { + const cached = this.sessionMessageCountCache.get(sessionId) + if (cached && now - cached.updatedAt <= this.sessionMessageCountCacheTtlMs) { + counts[sessionId] = cached.count + continue + } + } + + if (preferHintCache) { + const hintCount = this.sessionMessageCountHintCache.get(sessionId) + if (typeof hintCount === 'number' && Number.isFinite(hintCount) && hintCount >= 0) { + counts[sessionId] = Math.floor(hintCount) + this.sessionMessageCountCache.set(sessionId, { + count: Math.floor(hintCount), + updatedAt: now + }) + continue + } + } + + pendingSessionIds.push(sessionId) + } + + if (pendingSessionIds.length > 0) { + let tableScanSucceeded = false + const cachedBatch = this.sessionMessageCountBatchCache + const cachedBatchFresh = cachedBatch && + now - cachedBatch.updatedAt <= this.sessionMessageCountBatchCacheTtlMs + + if (cachedBatchFresh && cachedBatch.sessionIdsKey === sessionIdsKey) { + const snapshot = await this.getMessageDbCountSnapshot() + if (snapshot.success && snapshot.dbSignature === cachedBatch.dbSignature) { + for (const sessionId of pendingSessionIds) { + const nextCountRaw = cachedBatch.counts[sessionId] + const nextCount = Number.isFinite(nextCountRaw) ? Math.max(0, Math.floor(nextCountRaw)) : 0 + counts[sessionId] = nextCount + this.sessionMessageCountCache.set(sessionId, { + count: nextCount, + updatedAt: now + }) + } + tableScanSucceeded = true + } + } + + if (!tableScanSucceeded) { + const tableScanResult = await this.countSessionMessageCountsByTableScan(pendingSessionIds, traceId) + if (tableScanResult.success && tableScanResult.counts) { + const nowTs = Date.now() + for (const sessionId of pendingSessionIds) { + const nextCountRaw = tableScanResult.counts[sessionId] + const nextCount = Number.isFinite(nextCountRaw) ? Math.max(0, Math.floor(nextCountRaw)) : 0 + counts[sessionId] = nextCount + this.sessionMessageCountCache.set(sessionId, { + count: nextCount, + updatedAt: nowTs + }) + } + if (tableScanResult.dbSignature) { + this.sessionMessageCountBatchCache = { + dbSignature: tableScanResult.dbSignature, + sessionIdsKey, + counts: { ...counts }, + updatedAt: nowTs + } + } + tableScanSucceeded = true + } else { + this.logExportDiag({ + traceId, + level: 'warn', + source: 'backend', + stepId: 'backend-get-session-message-counts-table-scan', + stepName: '会话消息总数表扫描', + status: 'failed', + message: '按 Msg 表聚合统计失败,回退逐会话统计', + data: { + error: tableScanResult.error || '未知错误' + } + }) + } + } + + if (!tableScanSucceeded) { + const batchSize = 320 + for (let i = 0; i < pendingSessionIds.length; i += batchSize) { + const batch = pendingSessionIds.slice(i, i + batchSize) + this.logExportDiag({ + traceId, + level: 'debug', + source: 'backend', + stepId: 'backend-get-session-message-counts-batch', + stepName: '会话消息总数批次查询', + status: 'running', + message: `开始查询批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(pendingSessionIds.length / batchSize) || 1}`, + data: { + batchSize: batch.length + } + }) + let batchCounts: Record = {} + try { + const result = await wcdbService.getMessageCounts(batch) + if (result.success && result.counts) { + batchCounts = result.counts + } + } catch { + // noop + } + + const nowTs = Date.now() + for (const sessionId of batch) { + const nextCountRaw = batchCounts[sessionId] + const nextCount = Number.isFinite(nextCountRaw) ? Math.max(0, Math.floor(nextCountRaw)) : 0 + counts[sessionId] = nextCount + this.sessionMessageCountCache.set(sessionId, { + count: nextCount, + updatedAt: nowTs + }) + } + } + } + } + + returnedCounts = Object.keys(counts).length + success = true + return { success: true, counts } + } catch (e) { + console.error('ChatService: 批量获取会话消息总数失败:', e) + errorMessage = String(e) + return { success: false, error: String(e) } + } finally { + this.endExportDiagStep({ + traceId, + stepId: 'backend-get-session-message-counts', + stepName: 'ChatService.getSessionMessageCounts', + startedAt: stepStartedAt, + success, + message: success ? '批量会话消息总数读取完成' : '批量会话消息总数读取失败', + data: success ? { returnedCounts } : { error: errorMessage || '未知错误' } + }) + } + } + /** * 获取通讯录列表 */ @@ -651,38 +1274,15 @@ class ChatService { return { success: false, error: connectResult.error } } - // 使用execQuery直接查询加密的contact.db - // kind='contact', path=null表示使用已打开的contact.db - const contactQuery = ` - SELECT username, remark, nick_name, alias, local_type, flag, quan_pin - FROM contact - ` + const contactResult = await wcdbService.getContactsCompact() - - const contactResult = await wcdbService.execQuery('contact', null, contactQuery) - - if (!contactResult.success || !contactResult.rows) { + if (!contactResult.success || !contactResult.contacts) { console.error('查询联系人失败:', contactResult.error) return { success: false, error: contactResult.error || '查询联系人失败' } } - const rows = contactResult.rows as Record[] - - // 调试:显示前5条数据样本 - - rows.slice(0, 5).forEach((row, idx) => { - - }) - - // 调试:统计local_type分布 - const localTypeStats = new Map() - rows.forEach(row => { - const lt = row.local_type || 0 - localTypeStats.set(lt, (localTypeStats.get(lt) || 0) + 1) - }) - - + const rows = contactResult.contacts as Record[] // 获取会话表的最后联系时间用于排序 const lastContactTimeMap = new Map() const sessionResult = await wcdbService.getSessions() @@ -698,25 +1298,24 @@ class ChatService { // 转换为ContactInfo const contacts: (ContactInfo & { lastContactTime: number })[] = [] + const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) for (const row of rows) { - const username = row.username || '' + const username = String(row.username || '').trim() if (!username) continue - const excludeNames = ['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'] let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other' const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) - const flag = Number(row.flag ?? 0) - const quanPin = this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '' + const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim() - if (username.includes('@chatroom')) { + if (username.endsWith('@chatroom')) { type = 'group' } else if (username.startsWith('gh_')) { type = 'official' - } else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 1 && !excludeNames.includes(username)) { + } else if (localType === 1 && !excludeNames.has(username)) { type = 'friend' - } else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 0 && quanPin) { + } else if (localType === 0 && quanPin) { type = 'former_friend' } else { continue @@ -729,6 +1328,7 @@ class ChatService { displayName, remark: row.remark || undefined, nickname: row.nick_name || undefined, + alias: row.alias || undefined, avatarUrl: undefined, type, lastContactTime: lastContactTimeMap.get(username) || 0 @@ -771,7 +1371,8 @@ class ChatService { startTime: number = 0, endTime: number = 0, ascending: boolean = false - ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> { + ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; nextOffset?: number; error?: string }> { + let releaseMessageCursorMutex: (() => void) | null = null try { const connectResult = await this.ensureConnected() if (!connectResult.success) { @@ -785,6 +1386,12 @@ class ChatService { await new Promise(resolve => setTimeout(resolve, 1)) } this.messageCursorMutex = true + let mutexReleased = false + releaseMessageCursorMutex = () => { + if (mutexReleased) return + this.messageCursorMutex = false + mutexReleased = true + } let state = this.messageCursors.get(sessionId) @@ -823,7 +1430,6 @@ class ChatService { state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending } this.messageCursors.set(sessionId, state) - this.messageCursorMutex = false // 如果需要跳过消息(offset > 0),逐批获取但不返回 // 注意:仅在 offset === 0 时重建游标最安全; @@ -843,7 +1449,7 @@ class ChatService { } if (!skipBatch.rows || skipBatch.rows.length === 0) { console.warn(`[ChatService] 跳过时数据耗尽: skipped=${skipped}/${offset}`) - return { success: true, messages: [], hasMore: false } + return { success: true, messages: [], hasMore: false, nextOffset: skipped } } const count = skipBatch.rows.length @@ -856,19 +1462,19 @@ class ChatService { } skipped += count - state.fetched += count // If satisfied offset, break if (skipped >= offset) break; if (!skipBatch.hasMore) { console.warn(`[ChatService] 跳过后无更多数据: skipped=${skipped}/${offset}`) - return { success: true, messages: [], hasMore: false } + return { success: true, messages: [], hasMore: false, nextOffset: skipped } } } if (attempts >= maxSkipAttempts) { console.error(`[ChatService] 跳过消息超过最大尝试次数: attempts=${attempts}`) } + state.fetched = offset console.log(`[ChatService] 跳过完成: skipped=${skipped}, fetched=${state.fetched}, buffered=${state.bufferedMessages?.length || 0}`) } } @@ -879,96 +1485,33 @@ class ChatService { return { success: false, error: '游标状态未初始化' } } - // 获取当前批次的消息 - // Use buffered rows from skip logic if available - let rows: any[] = state.bufferedMessages || [] - state.bufferedMessages = undefined // Clear buffer after use - - // Track actual hasMore status from C++ layer - // If we have buffered messages, we need to check if there's more data - let actualHasMore = rows.length > 0 // If buffer exists, assume there might be more - - // If buffer is not enough to fill a batch, try to fetch more - // Or if buffer is empty, fetch a batch - if (rows.length < batchSize) { - const nextBatch = await wcdbService.fetchMessageBatch(state.cursor) - if (nextBatch.success && nextBatch.rows) { - rows = rows.concat(nextBatch.rows) - state.fetched += nextBatch.rows.length - actualHasMore = nextBatch.hasMore === true - } else if (!nextBatch.success) { - console.error('[ChatService] 获取消息批次失败:', nextBatch.error) - // If we have some buffered rows, we can still return them? - // Or fail? Let's return what we have if any, otherwise fail. - if (rows.length === 0) { - return { success: false, error: nextBatch.error || '获取消息失败' } - } - actualHasMore = false - } + const collected = await this.collectVisibleMessagesFromCursor( + sessionId, + state.cursor, + limit, + state.bufferedMessages as Record[] | undefined + ) + state.bufferedMessages = collected.bufferedRows + if (!collected.success) { + return { success: false, error: collected.error || '获取消息失败' } } - // If we have more than limit (due to buffer + full batch), slice it - if (rows.length > limit) { - rows = rows.slice(0, limit) - // Note: We don't adjust state.fetched here because it tracks cursor position. - // Next time offset will catch up or mismatch trigger reset. - } - - // Use actual hasMore from C++ layer, not simplified row count check - const hasMore = actualHasMore - - const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(rows)) - - // 🔒 安全验证:过滤掉不属于当前 sessionId 的消息(防止 C++ 层或缓存错误) - const filtered = normalized.filter(msg => { - // 检查消息的 senderUsername 或 rawContent 中的 talker - // 群聊消息:senderUsername 是群成员,需要检查 _db_path 或上下文 - // 单聊消息:senderUsername 应该是 sessionId 或自己 - const isGroupChat = sessionId.includes('@chatroom') - - if (isGroupChat) { - // 群聊消息暂不验证(因为 senderUsername 是群成员,不是 sessionId) - return true - } else { - // 单聊消息:senderUsername 应该是 sessionId(对方)或为空/null(自己) - if (!msg.senderUsername || msg.senderUsername === sessionId) { - return true - } - // 如果 isSend 为 1,说明是自己发的,允许通过 - if (msg.isSend === 1) { - return true - } - // 其他情况:可能是错误的消息 - console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`) - return false - } - }) - - if (filtered.length < normalized.length) { - console.warn(`[ChatService] 过滤了 ${normalized.length - filtered.length} 条异常消息`) - } - - // 并发检查并修复缺失 CDN URL 的表情包 - const fixPromises: Promise[] = [] - for (const msg of filtered) { - if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) { - fixPromises.push(this.fallbackEmoticon(msg)) - } - } - - if (fixPromises.length > 0) { - await Promise.allSettled(fixPromises) - } - - state.fetched += rows.length - this.messageCursorMutex = false + const rawRowsConsumed = collected.rawRowsConsumed || 0 + const filtered = collected.messages || [] + const hasMore = collected.hasMore === true + state.fetched += rawRowsConsumed + releaseMessageCursorMutex?.() this.messageCacheService.set(sessionId, filtered) - return { success: true, messages: filtered, hasMore } + console.log( + `[ChatService] getMessages session=${sessionId} rawRowsConsumed=${rawRowsConsumed} visibleMessagesReturned=${filtered.length} filteredOut=${collected.filteredOut || 0} nextOffset=${state.fetched} hasMore=${hasMore}` + ) + return { success: true, messages: filtered, hasMore, nextOffset: state.fetched } } catch (e) { - this.messageCursorMutex = false console.error('ChatService: 获取消息失败:', e) return { success: false, error: String(e) } + } finally { + releaseMessageCursorMutex?.() } } @@ -1063,7 +1606,7 @@ class ChatService { } - async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; error?: string }> { + async getLatestMessages(sessionId: string, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; nextOffset?: number; error?: string }> { try { const connectResult = await this.ensureConnected() if (!connectResult.success) { @@ -1077,24 +1620,19 @@ class ChatService { } try { - const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor) - if (!batch.success || !batch.rows) { - return { success: false, error: batch.error || '获取消息失败' } + const collected = await this.collectVisibleMessagesFromCursor(sessionId, cursorResult.cursor, limit) + if (!collected.success) { + return { success: false, error: collected.error || '获取消息失败' } } - const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(batch.rows as Record[])) - - // 并发检查并修复缺失 CDN URL 的表情包 - const fixPromises: Promise[] = [] - for (const msg of normalized) { - if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) { - fixPromises.push(this.fallbackEmoticon(msg)) - } + console.log( + `[ChatService] getLatestMessages session=${sessionId} rawRowsConsumed=${collected.rawRowsConsumed || 0} visibleMessagesReturned=${collected.messages?.length || 0} filteredOut=${collected.filteredOut || 0} nextOffset=${collected.rawRowsConsumed || 0} hasMore=${collected.hasMore === true}` + ) + return { + success: true, + messages: collected.messages, + hasMore: collected.hasMore, + nextOffset: collected.rawRowsConsumed || 0 } - if (fixPromises.length > 0) { - await Promise.allSettled(fixPromises) - } - - return { success: true, messages: normalized } } finally { await wcdbService.closeMessageCursor(cursorResult.cursor) } @@ -1150,6 +1688,174 @@ class ChatService { return messages } + private encodeMessageKeySegment(value: unknown): string { + const normalized = String(value ?? '').trim() + return encodeURIComponent(normalized) + } + + private getMessageSourceInfo(row: Record): { dbName?: string; tableName?: string; dbPath?: string } { + const dbPath = String( + this.getRowField(row, ['_db_path', 'db_path', 'dbPath', 'database_path', 'databasePath', 'source_db_path']) + || '' + ).trim() + const explicitDbName = String( + this.getRowField(row, ['db_name', 'dbName', 'database_name', 'databaseName', 'db', 'database', 'source_db']) + || '' + ).trim() + const tableName = String( + this.getRowField(row, ['table_name', 'tableName', 'table', 'source_table', 'sourceTable']) + || '' + ).trim() + const dbName = explicitDbName || (dbPath ? basename(dbPath, extname(dbPath)) : '') + return { + dbName: dbName || undefined, + tableName: tableName || undefined, + dbPath: dbPath || undefined + } + } + + private buildMessageKey(input: { + localId: number + serverId: number + createTime: number + sortSeq: number + senderUsername?: string | null + localType: number + dbName?: string + tableName?: string + dbPath?: string + }): string { + const localId = Number.isFinite(input.localId) ? Math.max(0, Math.floor(input.localId)) : 0 + const serverId = Number.isFinite(input.serverId) ? Math.max(0, Math.floor(input.serverId)) : 0 + const createTime = Number.isFinite(input.createTime) ? Math.max(0, Math.floor(input.createTime)) : 0 + const sortSeq = Number.isFinite(input.sortSeq) ? Math.max(0, Math.floor(input.sortSeq)) : 0 + const localType = Number.isFinite(input.localType) ? Math.floor(input.localType) : 0 + const senderUsername = this.encodeMessageKeySegment(input.senderUsername || '') + const dbName = String(input.dbName || '').trim() || (input.dbPath ? basename(input.dbPath, extname(input.dbPath)) : '') + const tableName = String(input.tableName || '').trim() + + if (localId > 0 && dbName && tableName) { + return `${this.encodeMessageKeySegment(dbName)}:${this.encodeMessageKeySegment(tableName)}:${localId}` + } + + if (serverId > 0) { + return `server:${serverId}:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}` + } + + return `fallback:${createTime}:${sortSeq}:${localId}:${senderUsername}:${localType}` + } + + private isMessageVisibleForSession(sessionId: string, msg: Message): boolean { + const isGroupChat = sessionId.includes('@chatroom') + if (isGroupChat) { + return true + } + if (!msg.senderUsername || msg.senderUsername === sessionId) { + return true + } + if (msg.isSend === 1) { + return true + } + console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`) + return false + } + + private async repairEmojiMessages(messages: Message[]): Promise { + const fixPromises: Promise[] = [] + for (const msg of messages) { + if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) { + fixPromises.push(this.fallbackEmoticon(msg)) + } + } + if (fixPromises.length > 0) { + await Promise.allSettled(fixPromises) + } + } + + private async collectVisibleMessagesFromCursor( + sessionId: string, + cursor: number, + limit: number, + initialRows: Record[] = [] + ): Promise<{ + success: boolean + messages?: Message[] + hasMore?: boolean + error?: string + rawRowsConsumed?: number + filteredOut?: number + bufferedRows?: Record[] + }> { + const visibleMessages: Message[] = [] + let queuedRows = Array.isArray(initialRows) ? initialRows.slice() : [] + let rawRowsConsumed = 0 + let filteredOut = 0 + let cursorMayHaveMore = queuedRows.length > 0 + + while (visibleMessages.length < limit) { + if (queuedRows.length === 0) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) { + console.error('[ChatService] 获取消息批次失败:', batch.error) + if (visibleMessages.length === 0) { + return { success: false, error: batch.error || '获取消息失败' } + } + cursorMayHaveMore = false + break + } + + const batchRows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + cursorMayHaveMore = batch.hasMore === true + if (batchRows.length === 0) { + break + } + queuedRows = batchRows + } + + const rowsToProcess = queuedRows + queuedRows = [] + const mappedMessages = this.mapRowsToMessages(rowsToProcess) + for (let index = 0; index < mappedMessages.length; index += 1) { + const msg = mappedMessages[index] + rawRowsConsumed += 1 + if (this.isMessageVisibleForSession(sessionId, msg)) { + visibleMessages.push(msg) + if (visibleMessages.length >= limit) { + if (index + 1 < rowsToProcess.length) { + queuedRows = rowsToProcess.slice(index + 1) + } + break + } + } else { + filteredOut += 1 + } + } + + if (visibleMessages.length >= limit) { + break + } + + if (!cursorMayHaveMore) { + break + } + } + + if (filteredOut > 0) { + console.warn(`[ChatService] 过滤了 ${filteredOut} 条异常消息`) + } + + const normalized = this.normalizeMessageOrder(visibleMessages) + await this.repairEmojiMessages(normalized) + return { + success: true, + messages: normalized, + hasMore: queuedRows.length > 0 || cursorMayHaveMore, + rawRowsConsumed, + filteredOut, + bufferedRows: queuedRows.length > 0 ? queuedRows : undefined + } + } + private getRowField(row: Record, keys: string[]): any { for (const key of keys) { if (row[key] !== undefined && row[key] !== null) return row[key] @@ -1174,6 +1880,69 @@ class ChatService { return Number.isFinite(parsed) ? parsed : fallback } + private normalizeUnsignedIntegerToken(raw: any): string | undefined { + if (raw === undefined || raw === null || raw === '') return undefined + + if (typeof raw === 'bigint') { + return raw >= 0n ? raw.toString() : '0' + } + + if (typeof raw === 'number') { + if (!Number.isFinite(raw)) return undefined + return String(Math.max(0, Math.floor(raw))) + } + + if (Buffer.isBuffer(raw)) { + return this.normalizeUnsignedIntegerToken(raw.toString('utf-8').trim()) + } + if (raw instanceof Uint8Array) { + return this.normalizeUnsignedIntegerToken(Buffer.from(raw).toString('utf-8').trim()) + } + if (Array.isArray(raw)) { + return this.normalizeUnsignedIntegerToken(Buffer.from(raw).toString('utf-8').trim()) + } + + if (typeof raw === 'object') { + if ('value' in raw) return this.normalizeUnsignedIntegerToken(raw.value) + if ('intValue' in raw) return this.normalizeUnsignedIntegerToken(raw.intValue) + if ('low' in raw && 'high' in raw) { + try { + const low = BigInt(raw.low >>> 0) + const high = BigInt(raw.high >>> 0) + const value = (high << 32n) + low + return value >= 0n ? value.toString() : '0' + } catch { + return undefined + } + } + const text = raw.toString ? String(raw).trim() : '' + if (text && text !== '[object Object]') { + return this.normalizeUnsignedIntegerToken(text) + } + return undefined + } + + const text = String(raw).trim() + if (!text) return undefined + if (/^\d+$/.test(text)) { + return text.replace(/^0+(?=\d)/, '') || '0' + } + if (/^[+-]?\d+$/.test(text)) { + try { + const value = BigInt(text) + return value >= 0n ? value.toString() : '0' + } catch { + return undefined + } + } + + const parsed = Number(text) + if (Number.isFinite(parsed)) { + return String(Math.max(0, Math.floor(parsed))) + } + return undefined + } + private coerceRowNumber(raw: any): number { if (raw === undefined || raw === null) return NaN if (typeof raw === 'number') return raw @@ -1210,6 +1979,1117 @@ class ChatService { return Number.isFinite(parsed) ? parsed : NaN } + private buildIdentityKeys(raw: string): string[] { + const value = String(raw || '').trim() + if (!value) return [] + const lowerRaw = value.toLowerCase() + const cleaned = this.cleanAccountDirName(value).toLowerCase() + if (cleaned && cleaned !== lowerRaw) { + return [cleaned, lowerRaw] + } + return [lowerRaw] + } + + private resolveMessageIsSend(rawIsSend: number | null, senderUsername?: string | null): { + isSend: number | null + selfMatched: boolean + correctedBySelfIdentity: boolean + } { + const normalizedRawIsSend = Number.isFinite(rawIsSend as number) ? rawIsSend : null + const senderKeys = this.buildIdentityKeys(String(senderUsername || '')) + if (senderKeys.length === 0) { + return { + isSend: normalizedRawIsSend, + selfMatched: false, + correctedBySelfIdentity: false + } + } + + const myWxid = String(this.configService.get('myWxid') || '').trim() + const selfKeys = this.buildIdentityKeys(myWxid) + if (selfKeys.length === 0) { + return { + isSend: normalizedRawIsSend, + selfMatched: false, + correctedBySelfIdentity: false + } + } + + const selfMatched = senderKeys.some(senderKey => + selfKeys.some(selfKey => + senderKey === selfKey || + senderKey.startsWith(selfKey + '_') || + selfKey.startsWith(senderKey + '_') + ) + ) + + if (selfMatched && normalizedRawIsSend !== 1) { + return { + isSend: 1, + selfMatched: true, + correctedBySelfIdentity: true + } + } + + if (normalizedRawIsSend === null) { + return { + isSend: selfMatched ? 1 : 0, + selfMatched, + correctedBySelfIdentity: false + } + } + + return { + isSend: normalizedRawIsSend, + selfMatched, + correctedBySelfIdentity: false + } + } + + private extractGroupMemberUsername(member: any): string { + if (!member) return '' + if (typeof member === 'string') return member.trim() + return String( + member.username || + member.userName || + member.user_name || + member.encryptUsername || + member.encryptUserName || + member.encrypt_username || + member.originalName || + '' + ).trim() + } + + private async getFriendIdentitySet(): Promise> { + const identities = new Set() + const contactResult = await wcdbService.getContactsCompact() + if (!contactResult.success || !contactResult.contacts) { + return identities + } + + for (const rowAny of contactResult.contacts) { + const row = rowAny as Record + const username = String(row.username || '').trim() + if (!username || username.includes('@chatroom') || username.startsWith('gh_')) continue + if (FRIEND_EXCLUDE_USERNAMES.has(username)) continue + + const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) + if (localType !== 1) continue + + for (const key of this.buildIdentityKeys(username)) { + identities.add(key) + } + } + return identities + } + + private async forEachWithConcurrency( + items: T[], + limit: number, + worker: (item: T) => Promise + ): Promise { + if (items.length === 0) return + const concurrency = Math.max(1, Math.min(limit, items.length)) + let index = 0 + + const runners = Array.from({ length: concurrency }, async () => { + while (true) { + const current = index + index += 1 + if (current >= items.length) return + await worker(items[current]) + } + }) + + await Promise.all(runners) + } + + private normalizeExportDiagTraceId(traceId?: string): string { + const normalized = String(traceId || '').trim() + return normalized + } + + private logExportDiag(input: { + traceId?: string + source?: 'backend' | 'main' | 'frontend' | 'worker' + level?: 'debug' | 'info' | 'warn' | 'error' + message: string + stepId?: string + stepName?: string + status?: 'running' | 'done' | 'failed' | 'timeout' + durationMs?: number + data?: Record + }): void { + const traceId = this.normalizeExportDiagTraceId(input.traceId) + if (!traceId) return + exportCardDiagnosticsService.log({ + traceId, + source: input.source || 'backend', + level: input.level || 'info', + message: input.message, + stepId: input.stepId, + stepName: input.stepName, + status: input.status, + durationMs: input.durationMs, + data: input.data + }) + } + + private startExportDiagStep(input: { + traceId?: string + stepId: string + stepName: string + message: string + data?: Record + }): number { + const startedAt = Date.now() + const traceId = this.normalizeExportDiagTraceId(input.traceId) + if (traceId) { + exportCardDiagnosticsService.stepStart({ + traceId, + stepId: input.stepId, + stepName: input.stepName, + source: 'backend', + message: input.message, + data: input.data + }) + } + return startedAt + } + + private endExportDiagStep(input: { + traceId?: string + stepId: string + stepName: string + startedAt: number + success: boolean + message?: string + data?: Record + }): void { + const traceId = this.normalizeExportDiagTraceId(input.traceId) + if (!traceId) return + exportCardDiagnosticsService.stepEnd({ + traceId, + stepId: input.stepId, + stepName: input.stepName, + source: 'backend', + status: input.success ? 'done' : 'failed', + message: input.message || (input.success ? `${input.stepName} 完成` : `${input.stepName} 失败`), + durationMs: Math.max(0, Date.now() - input.startedAt), + data: input.data + }) + } + + private refreshSessionMessageCountCacheScope(): void { + const dbPath = String(this.configService.get('dbPath') || '') + const myWxid = String(this.configService.get('myWxid') || '') + const scope = `${dbPath}::${myWxid}` + if (scope === this.sessionMessageCountCacheScope) { + this.refreshSessionStatsCacheScope(scope) + this.refreshGroupMyMessageCountCacheScope(scope) + return + } + this.sessionMessageCountCacheScope = scope + this.sessionMessageCountCache.clear() + this.sessionMessageCountHintCache.clear() + this.sessionMessageCountBatchCache = null + this.sessionDetailFastCache.clear() + this.sessionDetailExtraCache.clear() + this.sessionStatusCache.clear() + this.sessionTablesCache.clear() + this.messageTableColumnsCache.clear() + this.messageDbCountSnapshotCache = null + this.refreshSessionStatsCacheScope(scope) + this.refreshGroupMyMessageCountCacheScope(scope) + } + + private refreshGroupMyMessageCountCacheScope(scope: string): void { + if (scope === this.groupMyMessageCountCacheScope) return + this.groupMyMessageCountCacheScope = scope + this.groupMyMessageCountMemoryCache.clear() + } + + private refreshSessionStatsCacheScope(scope: string): void { + if (scope === this.sessionStatsCacheScope) return + this.sessionStatsCacheScope = scope + this.sessionStatsMemoryCache.clear() + this.sessionStatsPendingBasic.clear() + this.sessionStatsPendingFull.clear() + this.allGroupSessionIdsCache = null + } + + private buildScopedSessionStatsKey(sessionId: string): string { + return `${this.sessionStatsCacheScope}::${sessionId}` + } + + private buildScopedGroupMyMessageCountKey(chatroomId: string): string { + return `${this.groupMyMessageCountCacheScope}::${chatroomId}` + } + + private getGroupMyMessageCountHintEntry( + chatroomId: string + ): { entry: GroupMyMessageCountCacheEntry; source: 'memory' | 'disk' } | null { + const scopedKey = this.buildScopedGroupMyMessageCountKey(chatroomId) + const inMemory = this.groupMyMessageCountMemoryCache.get(scopedKey) + if (inMemory) { + return { entry: inMemory, source: 'memory' } + } + + const persisted = this.groupMyMessageCountCacheService.get(this.groupMyMessageCountCacheScope, chatroomId) + if (!persisted) return null + this.groupMyMessageCountMemoryCache.set(scopedKey, persisted) + return { entry: persisted, source: 'disk' } + } + + private setGroupMyMessageCountHintEntry(chatroomId: string, messageCount: number, updatedAt?: number): number { + const nextCount = Number.isFinite(messageCount) ? Math.max(0, Math.floor(messageCount)) : 0 + const nextUpdatedAt = Number.isFinite(updatedAt) ? Math.max(0, Math.floor(updatedAt as number)) : Date.now() + const scopedKey = this.buildScopedGroupMyMessageCountKey(chatroomId) + const existing = this.groupMyMessageCountMemoryCache.get(scopedKey) + if (existing && existing.updatedAt > nextUpdatedAt) { + return existing.updatedAt + } + + const entry: GroupMyMessageCountCacheEntry = { + updatedAt: nextUpdatedAt, + messageCount: nextCount + } + this.groupMyMessageCountMemoryCache.set(scopedKey, entry) + this.groupMyMessageCountCacheService.set(this.groupMyMessageCountCacheScope, chatroomId, entry) + return nextUpdatedAt + } + + private toSessionStatsCacheStats(stats: ExportSessionStats): SessionStatsCacheStats { + const normalized: SessionStatsCacheStats = { + totalMessages: Number.isFinite(stats.totalMessages) ? Math.max(0, Math.floor(stats.totalMessages)) : 0, + voiceMessages: Number.isFinite(stats.voiceMessages) ? Math.max(0, Math.floor(stats.voiceMessages)) : 0, + imageMessages: Number.isFinite(stats.imageMessages) ? Math.max(0, Math.floor(stats.imageMessages)) : 0, + videoMessages: Number.isFinite(stats.videoMessages) ? Math.max(0, Math.floor(stats.videoMessages)) : 0, + emojiMessages: Number.isFinite(stats.emojiMessages) ? Math.max(0, Math.floor(stats.emojiMessages)) : 0, + transferMessages: Number.isFinite(stats.transferMessages) ? Math.max(0, Math.floor(stats.transferMessages)) : 0, + redPacketMessages: Number.isFinite(stats.redPacketMessages) ? Math.max(0, Math.floor(stats.redPacketMessages)) : 0, + callMessages: Number.isFinite(stats.callMessages) ? Math.max(0, Math.floor(stats.callMessages)) : 0 + } + + if (Number.isFinite(stats.firstTimestamp)) normalized.firstTimestamp = Math.max(0, Math.floor(stats.firstTimestamp as number)) + if (Number.isFinite(stats.lastTimestamp)) normalized.lastTimestamp = Math.max(0, Math.floor(stats.lastTimestamp as number)) + if (Number.isFinite(stats.privateMutualGroups)) normalized.privateMutualGroups = Math.max(0, Math.floor(stats.privateMutualGroups as number)) + if (Number.isFinite(stats.groupMemberCount)) normalized.groupMemberCount = Math.max(0, Math.floor(stats.groupMemberCount as number)) + if (Number.isFinite(stats.groupMyMessages)) normalized.groupMyMessages = Math.max(0, Math.floor(stats.groupMyMessages as number)) + if (Number.isFinite(stats.groupActiveSpeakers)) normalized.groupActiveSpeakers = Math.max(0, Math.floor(stats.groupActiveSpeakers as number)) + if (Number.isFinite(stats.groupMutualFriends)) normalized.groupMutualFriends = Math.max(0, Math.floor(stats.groupMutualFriends as number)) + + return normalized + } + + private fromSessionStatsCacheStats(stats: SessionStatsCacheStats): ExportSessionStats { + return { + totalMessages: stats.totalMessages, + voiceMessages: stats.voiceMessages, + imageMessages: stats.imageMessages, + videoMessages: stats.videoMessages, + emojiMessages: stats.emojiMessages, + transferMessages: stats.transferMessages, + redPacketMessages: stats.redPacketMessages, + callMessages: stats.callMessages, + firstTimestamp: stats.firstTimestamp, + lastTimestamp: stats.lastTimestamp, + privateMutualGroups: stats.privateMutualGroups, + groupMemberCount: stats.groupMemberCount, + groupMyMessages: stats.groupMyMessages, + groupActiveSpeakers: stats.groupActiveSpeakers, + groupMutualFriends: stats.groupMutualFriends + } + } + + private supportsRequestedRelation(entry: SessionStatsCacheEntry, includeRelations: boolean): boolean { + if (!includeRelations) return true + return entry.includeRelations + } + + private getSessionStatsCacheEntry(sessionId: string): { entry: SessionStatsCacheEntry; source: 'memory' | 'disk' } | null { + const scopedKey = this.buildScopedSessionStatsKey(sessionId) + const inMemory = this.sessionStatsMemoryCache.get(scopedKey) + if (inMemory) { + return { entry: inMemory, source: 'memory' } + } + + const persisted = this.sessionStatsCacheService.get(this.sessionStatsCacheScope, sessionId) + if (!persisted) return null + this.sessionStatsMemoryCache.set(scopedKey, persisted) + return { entry: persisted, source: 'disk' } + } + + private setSessionStatsCacheEntry(sessionId: string, stats: ExportSessionStats, includeRelations: boolean): number { + const updatedAt = Date.now() + const normalizedStats = this.toSessionStatsCacheStats(stats) + const entry: SessionStatsCacheEntry = { + updatedAt, + includeRelations, + stats: normalizedStats + } + const scopedKey = this.buildScopedSessionStatsKey(sessionId) + this.sessionStatsMemoryCache.set(scopedKey, entry) + this.sessionStatsCacheService.set(this.sessionStatsCacheScope, sessionId, entry) + if (sessionId.endsWith('@chatroom') && Number.isFinite(normalizedStats.groupMyMessages)) { + this.setGroupMyMessageCountHintEntry(sessionId, normalizedStats.groupMyMessages as number, updatedAt) + } + return updatedAt + } + + private deleteSessionStatsCacheEntry(sessionId: string): void { + const scopedKey = this.buildScopedSessionStatsKey(sessionId) + this.sessionStatsMemoryCache.delete(scopedKey) + this.sessionStatsPendingBasic.delete(scopedKey) + this.sessionStatsPendingFull.delete(scopedKey) + this.sessionStatsCacheService.delete(this.sessionStatsCacheScope, sessionId) + } + + private clearSessionStatsCacheForScope(): void { + this.sessionStatsMemoryCache.clear() + this.sessionStatsPendingBasic.clear() + this.sessionStatsPendingFull.clear() + this.allGroupSessionIdsCache = null + this.sessionStatsCacheService.clearScope(this.sessionStatsCacheScope) + } + + private collectSessionIdsFromPayload(payload: unknown): Set { + const ids = new Set() + const walk = (value: unknown, keyHint?: string) => { + if (Array.isArray(value)) { + for (const item of value) walk(item, keyHint) + return + } + if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value as Record)) { + walk(v, k) + } + return + } + if (typeof value !== 'string') return + const normalized = value.trim() + if (!normalized) return + const lowerKey = String(keyHint || '').toLowerCase() + const keyLooksLikeSession = ( + lowerKey.includes('session') || + lowerKey.includes('talker') || + lowerKey.includes('username') || + lowerKey.includes('chatroom') + ) + if (!keyLooksLikeSession && !normalized.includes('@chatroom')) { + return + } + ids.add(normalized) + } + walk(payload) + return ids + } + + private handleSessionStatsMonitorChange(type: string, json: string): void { + this.refreshSessionMessageCountCacheScope() + if (!this.sessionStatsCacheScope) return + + const normalizedType = String(type || '').toLowerCase() + if ( + normalizedType.includes('message') || + normalizedType.includes('session') || + normalizedType.includes('db') + ) { + this.messageDbCountSnapshotCache = null + } + const maybeJson = String(json || '').trim() + let ids = new Set() + if (maybeJson) { + try { + ids = this.collectSessionIdsFromPayload(JSON.parse(maybeJson)) + } catch { + ids = this.collectSessionIdsFromPayload(maybeJson) + } + } + + if (ids.size > 0) { + ids.forEach((sessionId) => this.deleteSessionStatsCacheEntry(sessionId)) + if (Array.from(ids).some((id) => id.includes('@chatroom'))) { + this.allGroupSessionIdsCache = null + } + return + } + + // 无法定位具体会话时,保守地仅在消息/群成员相关变更时清空当前 scope,避免展示过旧统计。 + if ( + normalizedType.includes('message') || + normalizedType.includes('session') || + normalizedType.includes('group') || + normalizedType.includes('member') || + normalizedType.includes('contact') + ) { + this.clearSessionStatsCacheForScope() + } + } + + private async listAllGroupSessionIds(): Promise { + const now = Date.now() + if ( + this.allGroupSessionIdsCache && + now - this.allGroupSessionIdsCache.updatedAt <= this.allGroupSessionIdsCacheTtlMs + ) { + return this.allGroupSessionIdsCache.ids + } + + const result = await wcdbService.getSessions() + if (!result.success || !Array.isArray(result.sessions)) { + return [] + } + + const ids = new Set() + for (const rowAny of result.sessions) { + const row = rowAny as Record + const usernameRaw = row.username ?? row.userName ?? row.talker ?? row.sessionId + const username = String(usernameRaw || '').trim() + if (!username || !username.endsWith('@chatroom')) continue + ids.add(username) + } + + const list = Array.from(ids) + this.allGroupSessionIdsCache = { + ids: list, + updatedAt: now + } + return list + } + + private async getSessionMessageTables(sessionId: string): Promise> { + const now = Date.now() + const cached = this.sessionTablesCache.get(sessionId) + if (cached && now - cached.updatedAt <= this.sessionTablesCacheTtl && cached.tables.length > 0) { + return cached.tables + } + if (cached) { + this.sessionTablesCache.delete(sessionId) + } + + const tableStats = await wcdbService.getMessageTableStats(sessionId) + if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { + return [] + } + + const tables = tableStats.tables + .map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path })) + .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> + + if (tables.length > 0) { + this.sessionTablesCache.set(sessionId, { + tables, + updatedAt: now + }) + } + return tables + } + + private async getMessageTableColumns(dbPath: string, tableName: string): Promise> { + const cacheKey = `${dbPath}\u0001${tableName}` + const now = Date.now() + const cached = this.messageTableColumnsCache.get(cacheKey) + if (cached && now - cached.updatedAt <= this.messageTableColumnsCacheTtlMs) { + return new Set(cached.columns) + } + + const result = await wcdbService.getMessageTableColumns(dbPath, tableName) + if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) return new Set() + + const columns = new Set() + for (const columnName of result.columns) { + const name = String(columnName || '').trim().toLowerCase() + if (name) columns.add(name) + } + this.messageTableColumnsCache.set(cacheKey, { + columns: new Set(columns), + updatedAt: now + }) + return columns + } + + private pickFirstColumn(columns: Set, candidates: string[]): string | undefined { + for (const candidate of candidates) { + const normalized = candidate.toLowerCase() + if (columns.has(normalized)) return normalized + } + return undefined + } + + private escapeSqlLiteral(value: string): string { + return String(value || '').replace(/'/g, "''") + } + + private extractType49XmlTypeForStats(content: string): string { + if (!content) return '' + + const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) + if (appmsgMatch) { + const appmsgInner = appmsgMatch[1] + .replace(//gi, '') + .replace(//gi, '') + const typeMatch = /([\s\S]*?)<\/type>/i.exec(appmsgInner) + if (typeMatch) return String(typeMatch[1] || '').trim() + } + + return this.extractXmlValue(content, 'type') + } + + private async collectSpecialMessageCountsByCursorScan(sessionId: string): Promise<{ + transferMessages: number + redPacketMessages: number + callMessages: number + }> { + const counters = { + transferMessages: 0, + redPacketMessages: 0, + callMessages: 0 + } + + const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, 0, 0) + if (!cursorResult.success || !cursorResult.cursor) { + return counters + } + + const cursor = cursorResult.cursor + try { + while (true) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) break + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + for (const row of rows) { + const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) + if (localType === 50) { + counters.callMessages += 1 + continue + } + if (localType === 8589934592049) { + counters.transferMessages += 1 + continue + } + if (localType === 8594229559345) { + counters.redPacketMessages += 1 + continue + } + if (localType !== 49) continue + + const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content']) + const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content']) + const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) + const xmlType = this.extractType49XmlTypeForStats(content) + if (xmlType === '2000') counters.transferMessages += 1 + if (xmlType === '2001') counters.redPacketMessages += 1 + } + + if (!batch.hasMore || rows.length === 0) break + } + } finally { + await wcdbService.closeMessageCursor(cursor) + } + + return counters + } + + private async collectSessionExportStatsByCursorScan( + sessionId: string, + selfIdentitySet: Set + ): Promise { + const stats: ExportSessionStats = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0, + transferMessages: 0, + redPacketMessages: 0, + callMessages: 0 + } + if (sessionId.endsWith('@chatroom')) { + stats.groupMyMessages = 0 + stats.groupActiveSpeakers = 0 + } + + const senderIdentities = new Set() + const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, 0, 0) + if (!cursorResult.success || !cursorResult.cursor) { + return stats + } + + const cursor = cursorResult.cursor + try { + while (true) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) { + break + } + + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + for (const row of rows) { + stats.totalMessages += 1 + + const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) + if (localType === 34) stats.voiceMessages += 1 + if (localType === 3) stats.imageMessages += 1 + if (localType === 43) stats.videoMessages += 1 + if (localType === 47) stats.emojiMessages += 1 + if (localType === 50) stats.callMessages += 1 + if (localType === 8589934592049) stats.transferMessages += 1 + if (localType === 8594229559345) stats.redPacketMessages += 1 + if (localType === 49) { + const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content']) + const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content']) + const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) + const xmlType = this.extractType49XmlTypeForStats(content) + if (xmlType === '2000') stats.transferMessages += 1 + if (xmlType === '2001') stats.redPacketMessages += 1 + } + + const createTime = this.getRowInt( + row, + ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], + 0 + ) + if (createTime > 0) { + if (stats.firstTimestamp === undefined || createTime < stats.firstTimestamp) { + stats.firstTimestamp = createTime + } + if (stats.lastTimestamp === undefined || createTime > stats.lastTimestamp) { + stats.lastTimestamp = createTime + } + } + + if (sessionId.endsWith('@chatroom')) { + const sender = String(this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || '').trim() + const senderKeys = this.buildIdentityKeys(sender) + if (senderKeys.length > 0) { + senderIdentities.add(senderKeys[0]) + if (senderKeys.some((key) => selfIdentitySet.has(key))) { + stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 + } + } else { + const isSend = this.coerceRowNumber(this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])) + if (Number.isFinite(isSend) && isSend === 1) { + stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 + } + } + } + } + + if (!batch.hasMore || rows.length === 0) { + break + } + } + } finally { + await wcdbService.closeMessageCursor(cursor) + } + + if (sessionId.endsWith('@chatroom')) { + stats.groupActiveSpeakers = senderIdentities.size + if (Number.isFinite(stats.groupMyMessages)) { + this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number) + } + } + return stats + } + + private async collectSessionExportStats( + sessionId: string, + selfIdentitySet: Set, + preferAccurateSpecialTypes: boolean = false + ): Promise { + const stats: ExportSessionStats = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0, + transferMessages: 0, + redPacketMessages: 0, + callMessages: 0 + } + const isGroup = sessionId.endsWith('@chatroom') + if (isGroup) { + stats.groupMyMessages = 0 + stats.groupActiveSpeakers = 0 + } + + const nativeResult = await wcdbService.getSessionMessageTypeStats(sessionId, 0, 0) + if (!nativeResult.success || !nativeResult.data) { + return this.collectSessionExportStatsByCursorScan(sessionId, selfIdentitySet) + } + + const data = nativeResult.data as Record + stats.totalMessages = Math.max(0, Math.floor(Number(data.total_messages || 0))) + stats.voiceMessages = Math.max(0, Math.floor(Number(data.voice_messages || 0))) + stats.imageMessages = Math.max(0, Math.floor(Number(data.image_messages || 0))) + stats.videoMessages = Math.max(0, Math.floor(Number(data.video_messages || 0))) + stats.emojiMessages = Math.max(0, Math.floor(Number(data.emoji_messages || 0))) + stats.callMessages = Math.max(0, Math.floor(Number(data.call_messages || 0))) + stats.transferMessages = Math.max(0, Math.floor(Number(data.transfer_messages || 0))) + stats.redPacketMessages = Math.max(0, Math.floor(Number(data.red_packet_messages || 0))) + + const firstTs = Math.max(0, Math.floor(Number(data.first_timestamp || 0))) + const lastTs = Math.max(0, Math.floor(Number(data.last_timestamp || 0))) + if (firstTs > 0) stats.firstTimestamp = firstTs + if (lastTs > 0) stats.lastTimestamp = lastTs + + if (preferAccurateSpecialTypes) { + try { + const preciseCounters = await this.collectSpecialMessageCountsByCursorScan(sessionId) + stats.transferMessages = preciseCounters.transferMessages + stats.redPacketMessages = preciseCounters.redPacketMessages + stats.callMessages = preciseCounters.callMessages + } catch { + // 保留 native 聚合结果作为兜底 + } + } + + if (isGroup) { + stats.groupMyMessages = Math.max(0, Math.floor(Number(data.group_my_messages || 0))) + stats.groupActiveSpeakers = Math.max(0, Math.floor(Number(data.group_sender_count || 0))) + if (Number.isFinite(stats.groupMyMessages)) { + this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number) + } + } + return stats + } + + private toExportSessionStatsFromNativeTypeRow(sessionId: string, row: Record): ExportSessionStats { + const stats: ExportSessionStats = { + totalMessages: Math.max(0, Math.floor(Number(row?.total_messages || 0))), + voiceMessages: Math.max(0, Math.floor(Number(row?.voice_messages || 0))), + imageMessages: Math.max(0, Math.floor(Number(row?.image_messages || 0))), + videoMessages: Math.max(0, Math.floor(Number(row?.video_messages || 0))), + emojiMessages: Math.max(0, Math.floor(Number(row?.emoji_messages || 0))), + callMessages: Math.max(0, Math.floor(Number(row?.call_messages || 0))), + transferMessages: Math.max(0, Math.floor(Number(row?.transfer_messages || 0))), + redPacketMessages: Math.max(0, Math.floor(Number(row?.red_packet_messages || 0))) + } + + const firstTs = Math.max(0, Math.floor(Number(row?.first_timestamp || 0))) + const lastTs = Math.max(0, Math.floor(Number(row?.last_timestamp || 0))) + if (firstTs > 0) stats.firstTimestamp = firstTs + if (lastTs > 0) stats.lastTimestamp = lastTs + + if (sessionId.endsWith('@chatroom')) { + stats.groupMyMessages = Math.max(0, Math.floor(Number(row?.group_my_messages || 0))) + stats.groupActiveSpeakers = Math.max(0, Math.floor(Number(row?.group_sender_count || 0))) + if (Number.isFinite(stats.groupMyMessages)) { + this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number) + } + } + return stats + } + + private async getMessageDbCountSnapshot(forceRefresh = false): Promise<{ + success: boolean + dbPaths?: string[] + dbSignature?: string + error?: string + }> { + const now = Date.now() + if (!forceRefresh && this.messageDbCountSnapshotCache) { + if (now - this.messageDbCountSnapshotCache.updatedAt <= this.messageDbCountSnapshotCacheTtlMs) { + return { + success: true, + dbPaths: [...this.messageDbCountSnapshotCache.dbPaths], + dbSignature: this.messageDbCountSnapshotCache.dbSignature + } + } + } + + const dbPathsResult = await this.listMessageDbPathsForCount() + if (!dbPathsResult.success || !dbPathsResult.dbPaths) { + return { success: false, error: dbPathsResult.error || '获取消息数据库列表失败' } + } + const dbPaths = dbPathsResult.dbPaths + const dbSignature = this.buildMessageDbSignature(dbPaths) + this.messageDbCountSnapshotCache = { + dbPaths: [...dbPaths], + dbSignature, + updatedAt: now + } + return { success: true, dbPaths, dbSignature } + } + + private async buildGroupRelationStats( + groupSessionIds: string[], + privateSessionIds: string[], + selfIdentitySet: Set + ): Promise<{ + privateMutualGroupMap: Record + groupMutualFriendMap: Record + }> { + const privateMutualGroupMap: Record = {} + const groupMutualFriendMap: Record = {} + if (groupSessionIds.length === 0) { + return { privateMutualGroupMap, groupMutualFriendMap } + } + + const privateIndex = new Map>() + for (const sessionId of privateSessionIds) { + for (const key of this.buildIdentityKeys(sessionId)) { + const set = privateIndex.get(key) || new Set() + set.add(sessionId) + privateIndex.set(key, set) + } + privateMutualGroupMap[sessionId] = 0 + } + + const friendIdentitySet = await this.getFriendIdentitySet() + await this.forEachWithConcurrency(groupSessionIds, 4, async (groupId) => { + const membersResult = await wcdbService.getGroupMembers(groupId) + if (!membersResult.success || !membersResult.members) { + groupMutualFriendMap[groupId] = 0 + return + } + + const touchedPrivateSessions = new Set() + const friendMembers = new Set() + + for (const member of membersResult.members) { + const username = this.extractGroupMemberUsername(member) + const identityKeys = this.buildIdentityKeys(username) + if (identityKeys.length === 0) continue + const canonical = identityKeys[0] + + if (!selfIdentitySet.has(canonical) && friendIdentitySet.has(canonical)) { + friendMembers.add(canonical) + } + + for (const key of identityKeys) { + const linked = privateIndex.get(key) + if (!linked) continue + for (const sessionId of linked) { + touchedPrivateSessions.add(sessionId) + } + } + } + + groupMutualFriendMap[groupId] = friendMembers.size + for (const sessionId of touchedPrivateSessions) { + privateMutualGroupMap[sessionId] = (privateMutualGroupMap[sessionId] || 0) + 1 + } + }) + + return { privateMutualGroupMap, groupMutualFriendMap } + } + + private buildEmptyExportSessionStats(sessionId: string, includeRelations: boolean): ExportSessionStats { + const isGroup = sessionId.endsWith('@chatroom') + const stats: ExportSessionStats = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0, + transferMessages: 0, + redPacketMessages: 0, + callMessages: 0 + } + if (isGroup) { + stats.groupMyMessages = 0 + stats.groupActiveSpeakers = 0 + stats.groupMemberCount = 0 + if (includeRelations) { + stats.groupMutualFriends = 0 + } + } else if (includeRelations) { + stats.privateMutualGroups = 0 + } + return stats + } + + private async computeSessionExportStats( + sessionId: string, + selfIdentitySet: Set, + includeRelations: boolean, + preferAccurateSpecialTypes: boolean = false + ): Promise { + const stats = await this.collectSessionExportStats(sessionId, selfIdentitySet, preferAccurateSpecialTypes) + const isGroup = sessionId.endsWith('@chatroom') + + if (isGroup) { + const memberCountsResult = await wcdbService.getGroupMemberCounts([sessionId]) + const memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {} + stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number' ? Math.max(0, Math.floor(memberCountMap[sessionId])) : 0 + } + + if (includeRelations) { + if (isGroup) { + try { + const { groupMutualFriendMap } = await this.buildGroupRelationStats([sessionId], [], selfIdentitySet) + stats.groupMutualFriends = groupMutualFriendMap[sessionId] || 0 + } catch { + stats.groupMutualFriends = 0 + } + } else { + const allGroups = await this.listAllGroupSessionIds() + if (allGroups.length === 0) { + stats.privateMutualGroups = 0 + } else { + try { + const { privateMutualGroupMap } = await this.buildGroupRelationStats(allGroups, [sessionId], selfIdentitySet) + stats.privateMutualGroups = privateMutualGroupMap[sessionId] || 0 + } catch { + stats.privateMutualGroups = 0 + } + } + } + } + + return stats + } + + private async computeSessionExportStatsBatch( + sessionIds: string[], + includeRelations: boolean, + selfIdentitySet: Set, + preferAccurateSpecialTypes: boolean = false + ): Promise> { + const normalizedSessionIds = Array.from( + new Set( + (sessionIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + ) + ) + const result: Record = {} + if (normalizedSessionIds.length === 0) { + return result + } + + const groupSessionIds = normalizedSessionIds.filter(sessionId => sessionId.endsWith('@chatroom')) + const privateSessionIds = normalizedSessionIds.filter(sessionId => !sessionId.endsWith('@chatroom')) + + let memberCountMap: Record = {} + const shouldLoadGroupMemberCount = groupSessionIds.length > 0 && (includeRelations || normalizedSessionIds.length === 1) + if (shouldLoadGroupMemberCount) { + try { + const memberCountsResult = await wcdbService.getGroupMemberCounts(groupSessionIds) + memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {} + } catch { + memberCountMap = {} + } + } + + let privateMutualGroupMap: Record = {} + let groupMutualFriendMap: Record = {} + if (includeRelations) { + let relationGroupSessionIds: string[] = [] + if (privateSessionIds.length > 0) { + const allGroups = await this.listAllGroupSessionIds() + relationGroupSessionIds = Array.from(new Set([...allGroups, ...groupSessionIds])) + } else if (groupSessionIds.length > 0) { + relationGroupSessionIds = groupSessionIds + } + + if (relationGroupSessionIds.length > 0) { + try { + const relation = await this.buildGroupRelationStats( + relationGroupSessionIds, + privateSessionIds, + selfIdentitySet + ) + privateMutualGroupMap = relation.privateMutualGroupMap || {} + groupMutualFriendMap = relation.groupMutualFriendMap || {} + } catch { + privateMutualGroupMap = {} + groupMutualFriendMap = {} + } + } + } + + const nativeBatchStats: Record = {} + let hasNativeBatchStats = false + if (!preferAccurateSpecialTypes) { + try { + const quickMode = !includeRelations && normalizedSessionIds.length > 1 + const nativeBatch = await wcdbService.getSessionMessageTypeStatsBatch(normalizedSessionIds, { + beginTimestamp: 0, + endTimestamp: 0, + quickMode, + includeGroupSenderCount: true + }) + if (nativeBatch.success && nativeBatch.data) { + for (const sessionId of normalizedSessionIds) { + const row = nativeBatch.data?.[sessionId] as Record | undefined + if (!row || typeof row !== 'object') continue + nativeBatchStats[sessionId] = this.toExportSessionStatsFromNativeTypeRow(sessionId, row) + } + hasNativeBatchStats = Object.keys(nativeBatchStats).length > 0 + } else { + console.warn('[fallback-exec] getSessionMessageTypeStatsBatch failed, fallback to per-session stats path') + } + } catch (error) { + console.warn('[fallback-exec] getSessionMessageTypeStatsBatch exception, fallback to per-session stats path:', error) + } + } + + await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => { + try { + const stats = hasNativeBatchStats && nativeBatchStats[sessionId] + ? { ...nativeBatchStats[sessionId] } + : await this.collectSessionExportStats(sessionId, selfIdentitySet, preferAccurateSpecialTypes) + if (sessionId.endsWith('@chatroom')) { + if (shouldLoadGroupMemberCount) { + stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number' + ? Math.max(0, Math.floor(memberCountMap[sessionId])) + : 0 + } + if (includeRelations) { + stats.groupMutualFriends = typeof groupMutualFriendMap[sessionId] === 'number' + ? Math.max(0, Math.floor(groupMutualFriendMap[sessionId])) + : 0 + } + } else if (includeRelations) { + stats.privateMutualGroups = typeof privateMutualGroupMap[sessionId] === 'number' + ? Math.max(0, Math.floor(privateMutualGroupMap[sessionId])) + : 0 + } + result[sessionId] = stats + } catch { + result[sessionId] = this.buildEmptyExportSessionStats(sessionId, includeRelations) + } + }) + + return result + } + + private async getOrComputeSessionExportStats( + sessionId: string, + includeRelations: boolean, + selfIdentitySet: Set, + preferAccurateSpecialTypes: boolean = false + ): Promise { + if (preferAccurateSpecialTypes) { + return this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations, true) + } + + const scopedKey = this.buildScopedSessionStatsKey(sessionId) + + if (!includeRelations) { + const pendingFull = this.sessionStatsPendingFull.get(scopedKey) + if (pendingFull) return pendingFull + const pendingBasic = this.sessionStatsPendingBasic.get(scopedKey) + if (pendingBasic) return pendingBasic + } else { + const pendingFull = this.sessionStatsPendingFull.get(scopedKey) + if (pendingFull) return pendingFull + } + + const targetMap = includeRelations ? this.sessionStatsPendingFull : this.sessionStatsPendingBasic + const pending = this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations, false) + targetMap.set(scopedKey, pending) + try { + return await pending + } finally { + targetMap.delete(scopedKey) + } + } + /** * HTTP API 复用消息解析逻辑,确保和应用内展示一致。 */ @@ -1219,12 +3099,10 @@ class ChatService { private mapRowsToMessages(rows: Record[]): Message[] { const myWxid = this.configService.get('myWxid') - const cleanedWxid = myWxid ? this.cleanAccountDirName(myWxid) : null - const myWxidLower = myWxid ? myWxid.toLowerCase() : null - const cleanedWxidLower = cleanedWxid ? cleanedWxid.toLowerCase() : null const messages: Message[] = [] for (const row of rows) { + const sourceInfo = this.getMessageSourceInfo(row) const rawMessageContent = this.getRowField(row, [ 'message_content', 'messageContent', @@ -1245,28 +3123,14 @@ class ChatService { const content = this.decodeMessageContent(rawMessageContent, rawCompressContent); const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']) - let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10) - const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null + const parsedRawIsSend = isSendRaw === null ? null : parseInt(isSendRaw, 10) + const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) + || this.extractSenderUsernameFromContent(content) + || null + const { isSend } = this.resolveMessageIsSend(parsedRawIsSend, senderUsername) const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0) - if (senderUsername && (myWxidLower || cleanedWxidLower)) { - const senderLower = String(senderUsername).toLowerCase() - const expectedIsSend = ( - senderLower === myWxidLower || - senderLower === cleanedWxidLower || - // 兼容非 wxid 开头的账号(如果文件夹名带后缀,如 custom_backup,而 sender 是 custom) - (myWxidLower && myWxidLower.startsWith(senderLower + '_')) || - (cleanedWxidLower && cleanedWxidLower.startsWith(senderLower + '_')) - ) ? 1 : 0 - if (isSend === null) { - isSend = expectedIsSend - // [DEBUG] Issue #34: 记录 isSend 推断过程 - if (expectedIsSend === 0 && localType === 1) { - // 仅在被判为接收且是文本消息时记录,避免刷屏 - // - } - } - } else if (senderUsername && !myWxid) { + if (senderUsername && !myWxid) { // [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送 if (messages.length < 5) { console.warn(`[ChatService] Warning: myWxid not set. Cannot determine if message is sent by me. sender=${senderUsername}`) @@ -1328,8 +3192,28 @@ class ChatService { datatype: number sourcename: string sourcetime: string - datadesc: string + sourceheadurl?: string + datadesc?: string datatitle?: string + fileext?: string + datasize?: number + messageuuid?: string + dataurl?: string + datathumburl?: string + datacdnurl?: string + cdndatakey?: string + cdnthumbkey?: string + aeskey?: string + md5?: string + fullmd5?: string + thumbfullmd5?: string + srcMsgLocalid?: number + imgheight?: number + imgwidth?: number + duration?: number + chatRecordTitle?: string + chatRecordDesc?: string + chatRecordList?: any[] }> | undefined if (localType === 47 && content) { @@ -1429,12 +3313,27 @@ class ChatService { if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender } + const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0) + const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'])) + const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0) + const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime) + messages.push({ - localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0), - serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0), + messageKey: this.buildMessageKey({ + localId, + serverId, + createTime, + sortSeq, + senderUsername, + localType, + ...sourceInfo + }), + localId, + serverId, + serverIdRaw, localType, createTime, - sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime), + sortSeq, isSend, senderUsername, parsedContent: this.parseMessageContent(content, localType), @@ -1486,7 +3385,8 @@ class ChatService { transferPayerUsername, transferReceiverUsername, chatRecordTitle, - chatRecordList + chatRecordList, + _db_path: sourceInfo.dbPath }) const last = messages[messages.length - 1] if ((last.localType === 3 || last.localType === 34) && (last.localId === 0 || last.createTime === 0)) { @@ -2065,8 +3965,28 @@ class ChatService { datatype: number sourcename: string sourcetime: string - datadesc: string + sourceheadurl?: string + datadesc?: string datatitle?: string + fileext?: string + datasize?: number + messageuuid?: string + dataurl?: string + datathumburl?: string + datacdnurl?: string + cdndatakey?: string + cdnthumbkey?: string + aeskey?: string + md5?: string + fullmd5?: string + thumbfullmd5?: string + srcMsgLocalid?: number + imgheight?: number + imgwidth?: number + duration?: number + chatRecordTitle?: string + chatRecordDesc?: string + chatRecordList?: any[] }> } { try { @@ -2249,41 +4169,8 @@ class ChatService { case '19': { // 聊天记录 result.chatRecordTitle = title || '聊天记录' - - // 解析聊天记录列表 - const recordList: Array<{ - datatype: number - sourcename: string - sourcetime: string - datadesc: string - datatitle?: string - }> = [] - - // 查找所有 标签 - const recordItemRegex = /([\s\S]*?)<\/recorditem>/gi - let match: RegExpExecArray | null - - while ((match = recordItemRegex.exec(content)) !== null) { - const itemXml = match[1] - - const datatypeStr = this.extractXmlValue(itemXml, 'datatype') - const sourcename = this.extractXmlValue(itemXml, 'sourcename') - const sourcetime = this.extractXmlValue(itemXml, 'sourcetime') - const datadesc = this.extractXmlValue(itemXml, 'datadesc') - const datatitle = this.extractXmlValue(itemXml, 'datatitle') - - if (sourcename && datadesc) { - recordList.push({ - datatype: datatypeStr ? parseInt(datatypeStr, 10) : 0, - sourcename, - sourcetime: sourcetime || '', - datadesc, - datatitle: datatitle || undefined - }) - } - } - - if (recordList.length > 0) { + const recordList = this.parseForwardChatRecordList(content) + if (recordList && recordList.length > 0) { result.chatRecordList = recordList } break @@ -2350,6 +4237,224 @@ class ChatService { } } + private parseForwardChatRecordList(content: string): any[] | undefined { + const normalized = this.decodeHtmlEntities(content || '') + if (!normalized.includes('() + const recordItemRegex = /([\s\S]*?)<\/recorditem>/gi + let recordItemMatch: RegExpExecArray | null + while ((recordItemMatch = recordItemRegex.exec(normalized)) !== null) { + const parsed = this.parseForwardChatRecordContainer(recordItemMatch[1] || '') + for (const item of parsed) { + const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}` + if (!dedupe.has(key)) { + dedupe.add(key) + items.push(item) + } + } + } + + if (items.length === 0 && normalized.includes(' 0 ? items : undefined + } + + private extractTopLevelXmlElements(source: string, tagName: string): Array<{ attrs: string; inner: string }> { + const xml = source || '' + if (!xml) return [] + + const pattern = new RegExp(`<(/?)${tagName}\\b([^>]*)>`, 'gi') + const result: Array<{ attrs: string; inner: string }> = [] + let match: RegExpExecArray | null + let depth = 0 + let openEnd = -1 + let openStart = -1 + let openAttrs = '' + + while ((match = pattern.exec(xml)) !== null) { + const isClosing = match[1] === '/' + const attrs = match[2] || '' + const rawTag = match[0] || '' + const selfClosing = !isClosing && /\/\s*>$/.test(rawTag) + + if (!isClosing) { + if (depth === 0) { + openStart = match.index + openEnd = pattern.lastIndex + openAttrs = attrs + } + if (!selfClosing) { + depth += 1 + } else if (depth === 0 && openEnd >= 0) { + result.push({ attrs: openAttrs, inner: '' }) + openStart = -1 + openEnd = -1 + openAttrs = '' + } + continue + } + + if (depth <= 0) continue + depth -= 1 + if (depth === 0 && openEnd >= 0 && openStart >= 0) { + result.push({ + attrs: openAttrs, + inner: xml.slice(openEnd, match.index) + }) + openStart = -1 + openEnd = -1 + openAttrs = '' + } + } + + return result + } + + private parseForwardChatRecordContainer(containerXml: string): any[] { + const source = containerXml || '' + if (!source) return [] + + const segments: string[] = [source] + const decodedContainer = this.decodeHtmlEntities(source) + if (decodedContainer !== source) { + segments.push(decodedContainer) + } + + const cdataRegex = //g + let cdataMatch: RegExpExecArray | null + while ((cdataMatch = cdataRegex.exec(source)) !== null) { + const cdataInner = cdataMatch[1] || '' + if (!cdataInner) continue + segments.push(cdataInner) + const decodedInner = this.decodeHtmlEntities(cdataInner) + if (decodedInner !== cdataInner) { + segments.push(decodedInner) + } + } + + const items: any[] = [] + const seen = new Set() + for (const segment of segments) { + if (!segment) continue + const dataItems = this.extractTopLevelXmlElements(segment, 'dataitem') + for (const dataItem of dataItems) { + const parsed = this.parseForwardChatRecordDataItem(dataItem.inner || '', dataItem.attrs || '') + if (!parsed) continue + const key = `${parsed.datatype}|${parsed.sourcename}|${parsed.sourcetime}|${parsed.datadesc || ''}|${parsed.datatitle || ''}|${parsed.messageuuid || ''}` + if (!seen.has(key)) { + seen.add(key) + items.push(parsed) + } + } + } + + if (items.length > 0) return items + const fallback = this.parseForwardChatRecordDataItem(source, '') + return fallback ? [fallback] : [] + } + + private parseForwardChatRecordDataItem(itemXml: string, attrs: string): any | null { + const datatypeMatch = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '') + const datatype = datatypeMatch ? parseInt(datatypeMatch[1], 10) : parseInt(this.extractXmlValue(itemXml, 'datatype') || '0', 10) + const sourcename = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'sourcename') || '') + const sourcetime = this.extractXmlValue(itemXml, 'sourcetime') || '' + const sourceheadurl = this.extractXmlValue(itemXml, 'sourceheadurl') || undefined + const datadesc = this.decodeHtmlEntities( + this.extractXmlValue(itemXml, 'datadesc') || + this.extractXmlValue(itemXml, 'content') || + '' + ) || undefined + const datatitle = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'datatitle') || '') || undefined + const fileext = this.extractXmlValue(itemXml, 'fileext') || undefined + const datasize = parseInt(this.extractXmlValue(itemXml, 'datasize') || '0', 10) || undefined + const messageuuid = this.extractXmlValue(itemXml, 'messageuuid') || undefined + const dataurl = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'dataurl') || '') || undefined + const datathumburl = this.decodeHtmlEntities( + this.extractXmlValue(itemXml, 'datathumburl') || + this.extractXmlValue(itemXml, 'thumburl') || + this.extractXmlValue(itemXml, 'cdnthumburl') || + '' + ) || undefined + const datacdnurl = this.decodeHtmlEntities( + this.extractXmlValue(itemXml, 'datacdnurl') || + this.extractXmlValue(itemXml, 'cdnurl') || + this.extractXmlValue(itemXml, 'cdndataurl') || + '' + ) || undefined + const cdndatakey = this.extractXmlValue(itemXml, 'cdndatakey') || undefined + const cdnthumbkey = this.extractXmlValue(itemXml, 'cdnthumbkey') || undefined + const aeskey = this.decodeHtmlEntities( + this.extractXmlValue(itemXml, 'aeskey') || + this.extractXmlValue(itemXml, 'qaeskey') || + '' + ) || undefined + const md5 = this.extractXmlValue(itemXml, 'md5') || this.extractXmlValue(itemXml, 'datamd5') || undefined + const fullmd5 = this.extractXmlValue(itemXml, 'fullmd5') || undefined + const thumbfullmd5 = this.extractXmlValue(itemXml, 'thumbfullmd5') || undefined + const srcMsgLocalid = parseInt(this.extractXmlValue(itemXml, 'srcMsgLocalid') || '0', 10) || undefined + const imgheight = parseInt(this.extractXmlValue(itemXml, 'imgheight') || '0', 10) || undefined + const imgwidth = parseInt(this.extractXmlValue(itemXml, 'imgwidth') || '0', 10) || undefined + const duration = parseInt(this.extractXmlValue(itemXml, 'duration') || '0', 10) || undefined + const nestedRecordXml = this.extractXmlValue(itemXml, 'recordxml') || undefined + const chatRecordTitle = this.decodeHtmlEntities( + (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'title')) || + datatitle || + '' + ) || undefined + const chatRecordDesc = this.decodeHtmlEntities( + (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'desc')) || + datadesc || + '' + ) || undefined + const chatRecordList = + datatype === 17 && nestedRecordXml + ? this.parseForwardChatRecordContainer(nestedRecordXml) + : undefined + + if (!(datatype || sourcename || datadesc || datatitle || messageuuid || srcMsgLocalid)) return null + + return { + datatype: Number.isFinite(datatype) ? datatype : 0, + sourcename, + sourcetime, + sourceheadurl, + datadesc, + datatitle, + fileext, + datasize, + messageuuid, + dataurl, + datathumburl, + datacdnurl, + cdndatakey, + cdnthumbkey, + aeskey, + md5, + fullmd5, + thumbfullmd5, + srcMsgLocalid, + imgheight, + imgwidth, + duration, + chatRecordTitle, + chatRecordDesc, + chatRecordList + } + } + //手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback) private async findMediaDbsManually(): Promise { try { @@ -2475,24 +4580,6 @@ class ChatService { return candidates } - private async resolveChatNameId(dbPath: string, senderWxid: string): Promise { - const escaped = this.escapeSqlString(senderWxid) - const name2IdTable = await this.resolveName2IdTableName(dbPath) - if (!name2IdTable) return null - const info = await wcdbService.execQuery('media', dbPath, `PRAGMA table_info('${name2IdTable}')`) - if (!info.success || !info.rows) return null - const columns = info.rows.map((row) => String(row.name || row.Name || row.column || '')).filter(Boolean) - const lower = new Map(columns.map((col) => [col.toLowerCase(), col])) - const column = lower.get('name_id') || lower.get('id') || 'rowid' - const sql = `SELECT ${column} AS id FROM ${name2IdTable} WHERE user_name = '${escaped}' LIMIT 1` - const result = await wcdbService.execQuery('media', dbPath, sql) - if (!result.success || !result.rows || result.rows.length === 0) return null - const value = result.rows[0]?.id - if (value === null || value === undefined) return null - const parsed = typeof value === 'number' ? value : parseInt(String(value), 10) - return Number.isFinite(parsed) ? parsed : null - } - private decodeVoiceBlob(raw: any): Buffer | null { if (!raw) return null if (Buffer.isBuffer(raw)) return raw @@ -2515,64 +4602,79 @@ class ChatService { return null } - private async resolveVoiceInfoColumns(dbPath: string, tableName: string): Promise<{ - dataColumn: string; - chatNameIdColumn?: string; - createTimeColumn?: string; - msgLocalIdColumn?: string; - } | null> { - const info = await wcdbService.execQuery('media', dbPath, `PRAGMA table_info('${tableName}')`) - if (!info.success || !info.rows) return null - const columns = info.rows.map((row) => String(row.name || row.Name || row.column || '')).filter(Boolean) - if (columns.length === 0) return null - const lower = new Map(columns.map((col) => [col.toLowerCase(), col])) - const dataColumn = - lower.get('voice_data') || - lower.get('buf') || - lower.get('voicebuf') || - lower.get('data') - if (!dataColumn) return null - return { - dataColumn, - chatNameIdColumn: lower.get('chat_name_id') || lower.get('chatnameid') || lower.get('chat_nameid'), - createTimeColumn: lower.get('create_time') || lower.get('createtime') || lower.get('time'), - msgLocalIdColumn: lower.get('msg_local_id') || lower.get('msglocalid') || lower.get('localid') - } - } - private escapeSqlString(value: string): string { return value.replace(/'/g, "''") } - private async resolveVoiceInfoTableName(dbPath: string): Promise { - // 1. 优先尝试标准表名 'VoiceInfo' - const checkStandard = await wcdbService.execQuery( - 'media', - dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name='VoiceInfo'" - ) - if (checkStandard.success && checkStandard.rows && checkStandard.rows.length > 0) { - return 'VoiceInfo' + private async resolveMessageName2IdTableName(dbPath: string): Promise { + const normalizedDbPath = String(dbPath || '').trim() + if (!normalizedDbPath) return null + if (this.messageName2IdTableCache.has(normalizedDbPath)) { + return this.messageName2IdTableCache.get(normalizedDbPath) || null } - // 2. 只有在找不到标准表时,才尝试模糊匹配 (兼容性) + // fallback-exec: 当前缺少按 message.db 反查 Name2Id 表名的专属接口 const result = await wcdbService.execQuery( - 'media', - dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%' ORDER BY name DESC LIMIT 1" - ) - if (!result.success || !result.rows || result.rows.length === 0) return null - return result.rows[0]?.name || null - } - - private async resolveName2IdTableName(dbPath: string): Promise { - const result = await wcdbService.execQuery( - 'media', - dbPath, + 'message', + normalizedDbPath, "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%' ORDER BY name DESC LIMIT 1" ) - if (!result.success || !result.rows || result.rows.length === 0) return null - return result.rows[0]?.name || null + const tableName = result.success && result.rows && result.rows.length > 0 + ? String(result.rows[0]?.name || '').trim() || null + : null + this.messageName2IdTableCache.set(normalizedDbPath, tableName) + return tableName + } + + private async resolveMessageSenderUsernameById(dbPath: string, senderId: unknown): Promise { + const normalizedDbPath = String(dbPath || '').trim() + const numericSenderId = Number.parseInt(String(senderId ?? '').trim(), 10) + if (!normalizedDbPath || !Number.isFinite(numericSenderId) || numericSenderId <= 0) { + return null + } + + const cacheKey = `${normalizedDbPath}::${numericSenderId}` + if (this.messageSenderIdCache.has(cacheKey)) { + return this.messageSenderIdCache.get(cacheKey) || null + } + + const name2IdTable = await this.resolveMessageName2IdTableName(normalizedDbPath) + if (!name2IdTable) { + this.messageSenderIdCache.set(cacheKey, null) + return null + } + + const escapedTableName = String(name2IdTable).replace(/"/g, '""') + // fallback-exec: 当前缺少按 rowid -> user_name 的 message.db 专属接口 + const result = await wcdbService.execQuery( + 'message', + normalizedDbPath, + `SELECT user_name FROM "${escapedTableName}" WHERE rowid = ${numericSenderId} LIMIT 1` + ) + const username = result.success && result.rows && result.rows.length > 0 + ? String(result.rows[0]?.user_name || result.rows[0]?.userName || '').trim() || null + : null + this.messageSenderIdCache.set(cacheKey, username) + return username + } + + private async resolveSenderUsernameForMessageRow( + row: Record, + rawContent: string + ): Promise { + const directSender = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) + || this.extractSenderUsernameFromContent(rawContent) + if (directSender) { + return directSender + } + + const dbPath = this.getRowField(row, ['db_path', 'dbPath', '_db_path']) + const realSenderId = this.getRowField(row, ['real_sender_id', 'realSenderId']) + if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') { + return null + } + + return this.resolveMessageSenderUsernameById(String(dbPath), realSenderId) } /** @@ -2657,7 +4759,18 @@ class ChatService { } private stripSenderPrefix(content: string): string { - return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)\s*/, '') + return content.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|)\s*|\s*)/i, '') + } + + private extractSenderUsernameFromContent(content: string): string | null { + if (!content) return null + + const normalized = this.cleanUtf16(this.decodeHtmlEntities(String(content))) + const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|)/i.exec(normalized) + if (!match?.[1]) return null + + const candidate = match[1].trim() + return candidate || null } private decodeHtmlEntities(content: string): string { @@ -2710,8 +4823,8 @@ class ChatService { /** * 清理拍一拍消息 * 格式示例: - * 纯文本: 我拍了拍 "梨绒" ງ໐໐໓ ຖiງht620000wxid_... - * XML: "有幸"拍了拍"浩天空"相信未来!... + * 纯文本: 我拍了拍 "XX" + * XML: "XX"拍了拍"XX"相信未来!... */ private cleanPatMessage(content: string): string { if (!content) return '[拍一拍]' @@ -2871,8 +4984,8 @@ class ChatService { private shouldKeepSession(username: string): boolean { if (!username) return false const lowered = username.toLowerCase() - // placeholder_foldgroup 是折叠群入口,需要保留 - if (lowered.includes('@placeholder') && !lowered.includes('foldgroup')) return false + // 排除所有 placeholder 会话(包括折叠群) + if (lowered.includes('@placeholder')) return false if (username.startsWith('gh_')) return false const excludeList = [ @@ -2899,11 +5012,25 @@ class ChatService { if (!connectResult.success) return null const result = await wcdbService.getContact(username) if (!result.success || !result.contact) return null + const contact = result.contact as Record + let alias = String(contact.alias || contact.Alias || '') + // DLL 有时不返回 alias 字段,补一条直接 SQL 查询兜底 + if (!alias) { + try { + const aliasResult = await wcdbService.getContactAliasMap([username]) + if (aliasResult.success && aliasResult.map && aliasResult.map[username]) { + alias = String(aliasResult.map[username] || '') + } + } catch { + // 兜底失败不影响主流程 + } + } return { - username: result.contact.username || username, - alias: result.contact.alias || '', - remark: result.contact.remark || '', - nickName: result.contact.nickName || '' + username: String(contact.username || contact.user_name || contact.userName || username || ''), + alias, + remark: String(contact.remark || contact.Remark || ''), + // 兼容不同表结构字段,避免 nick_name 丢失导致侧边栏退化到 wxid。 + nickName: String(contact.nickName || contact.nick_name || contact.nickname || contact.NickName || '') } } catch { return null @@ -2921,14 +5048,24 @@ class ChatService { if (!connectResult.success) return null const cached = this.avatarCache.get(username) // 检查缓存是否有效,且头像不是错误的 hex 格式 - const isValidAvatar = cached?.avatarUrl && !cached.avatarUrl.includes('base64,ffd8') + const isValidAvatar = this.isValidAvatarUrl(cached?.avatarUrl) if (cached && isValidAvatar && Date.now() - cached.updatedAt < this.avatarCacheTtlMs) { return { avatarUrl: cached.avatarUrl, displayName: cached.displayName } } const contact = await this.getContact(username) const avatarResult = await wcdbService.getAvatarUrls([username]) - const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined + let avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined + if (!this.isValidAvatarUrl(avatarUrl)) { + avatarUrl = undefined + } + if (!avatarUrl) { + const headImageAvatars = await this.getAvatarsFromHeadImageDb([username]) + const fallbackAvatarUrl = headImageAvatars[username] + if (this.isValidAvatarUrl(fallbackAvatarUrl)) { + avatarUrl = fallbackAvatarUrl + } + } const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username const cacheEntry: ContactCacheEntry = { avatarUrl, @@ -3093,12 +5230,15 @@ class ChatService { this.voiceTranscriptPending.clear() } - for (const state of this.hardlinkCache.values()) { - try { - state.db?.close() - } catch { } + if (includeMessages || includeContacts) { + this.sessionStatsMemoryCache.clear() + this.sessionStatsPendingBasic.clear() + this.sessionStatsPendingFull.clear() + this.allGroupSessionIdsCache = null + this.sessionStatsCacheService.clearAll() + this.groupMyMessageCountMemoryCache.clear() + this.groupMyMessageCountCacheService.clearAll() } - this.hardlinkCache.clear() if (includeEmojis) { emojiCache.clear() @@ -3311,20 +5451,9 @@ class ChatService { /** * 获取会话详情信息 */ - async getSessionDetail(sessionId: string): Promise<{ + async getSessionDetailFast(sessionId: string): Promise<{ success: boolean - detail?: { - wxid: string - displayName: string - remark?: string - nickName?: string - alias?: string - avatarUrl?: string - messageCount: number - firstMessageTime?: number - latestMessageTime?: number - messageTables: { dbName: string; tableName: string; count: number }[] - } + detail?: SessionDetailFast error?: string }> { try { @@ -3332,81 +5461,427 @@ class ChatService { if (!connectResult.success) { return { success: false, error: connectResult.error || '数据库未连接' } } + this.refreshSessionMessageCountCacheScope() - let displayName = sessionId + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) { + return { success: false, error: '会话ID不能为空' } + } + + const now = Date.now() + const cachedDetail = this.sessionDetailFastCache.get(normalizedSessionId) + if (cachedDetail && now - cachedDetail.updatedAt <= this.sessionDetailFastCacheTtlMs) { + return { success: true, detail: cachedDetail.detail } + } + + let displayName = normalizedSessionId let remark: string | undefined let nickName: string | undefined let alias: string | undefined let avatarUrl: string | undefined - - const contactResult = await wcdbService.getContact(sessionId) - if (contactResult.success && contactResult.contact) { - remark = contactResult.contact.remark || undefined - nickName = contactResult.contact.nickName || undefined - alias = contactResult.contact.alias || undefined - displayName = remark || nickName || alias || sessionId - } - const avatarResult = await wcdbService.getAvatarUrls([sessionId]) - if (avatarResult.success && avatarResult.map) { - avatarUrl = avatarResult.map[sessionId] - } - - const countResult = await wcdbService.getMessageCount(sessionId) - const totalMessageCount = countResult.success && countResult.count ? countResult.count : 0 - - let firstMessageTime: number | undefined - let latestMessageTime: number | undefined - - const earliestCursor = await wcdbService.openMessageCursor(sessionId, 1, true, 0, 0) - if (earliestCursor.success && earliestCursor.cursor) { - const batch = await wcdbService.fetchMessageBatch(earliestCursor.cursor) - if (batch.success && batch.rows && batch.rows.length > 0) { - firstMessageTime = parseInt(batch.rows[0].create_time || '0', 10) || undefined + const cachedContact = this.avatarCache.get(normalizedSessionId) + if (cachedContact) { + displayName = cachedContact.displayName || normalizedSessionId + if (this.isValidAvatarUrl(cachedContact.avatarUrl)) { + avatarUrl = cachedContact.avatarUrl } - await wcdbService.closeMessageCursor(earliestCursor.cursor) } - const latestCursor = await wcdbService.openMessageCursor(sessionId, 1, false, 0, 0) - if (latestCursor.success && latestCursor.cursor) { - const batch = await wcdbService.fetchMessageBatch(latestCursor.cursor) - if (batch.success && batch.rows && batch.rows.length > 0) { - latestMessageTime = parseInt(batch.rows[0].create_time || '0', 10) || undefined + const contactPromise = wcdbService.getContact(normalizedSessionId) + const avatarPromise = avatarUrl + ? Promise.resolve({ success: true, map: { [normalizedSessionId]: avatarUrl } }) + : wcdbService.getAvatarUrls([normalizedSessionId]) + + let messageCount: number | undefined + const cachedCount = this.sessionMessageCountCache.get(normalizedSessionId) + if (cachedCount && now - cachedCount.updatedAt <= this.sessionMessageCountCacheTtlMs) { + messageCount = cachedCount.count + } else { + const hintCount = this.sessionMessageCountHintCache.get(normalizedSessionId) + if (typeof hintCount === 'number' && Number.isFinite(hintCount) && hintCount >= 0) { + messageCount = Math.floor(hintCount) + this.sessionMessageCountCache.set(normalizedSessionId, { + count: messageCount, + updatedAt: now + }) } - await wcdbService.closeMessageCursor(latestCursor.cursor) } + const messageCountPromise = Number.isFinite(messageCount) + ? Promise.resolve<{ success: boolean; count?: number }>({ + success: true, + count: Math.max(0, Math.floor(messageCount as number)) + }) + : wcdbService.getMessageCount(normalizedSessionId) + + const [contactResult, avatarResult, messageCountResult] = await Promise.allSettled([ + contactPromise, + avatarPromise, + messageCountPromise + ]) + + if (contactResult.status === 'fulfilled' && contactResult.value.success && contactResult.value.contact) { + remark = contactResult.value.contact.remark || undefined + nickName = contactResult.value.contact.nickName || undefined + alias = contactResult.value.contact.alias || undefined + displayName = remark || nickName || alias || displayName + } + + if (avatarResult.status === 'fulfilled' && avatarResult.value.success && avatarResult.value.map) { + const avatarCandidate = avatarResult.value.map[normalizedSessionId] + if (this.isValidAvatarUrl(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)) { + messageCount = messageCountResult.status === 'fulfilled' && + messageCountResult.value.success && + Number.isFinite(messageCountResult.value.count) + ? Math.max(0, Math.floor(messageCountResult.value.count || 0)) + : 0 + this.sessionMessageCountCache.set(normalizedSessionId, { + count: messageCount, + updatedAt: Date.now() + }) + } + + const detail: SessionDetailFast = { + wxid: normalizedSessionId, + displayName, + remark, + nickName, + alias, + avatarUrl, + messageCount: Math.max(0, Math.floor(messageCount || 0)) + } + + this.sessionDetailFastCache.set(normalizedSessionId, { + detail, + updatedAt: Date.now() + }) + + return { success: true, detail } + } catch (e) { + console.error('ChatService: 获取会话详情快速信息失败:', e) + return { success: false, error: String(e) } + } + } + + async getSessionDetailExtra(sessionId: string): Promise<{ + success: boolean + detail?: SessionDetailExtra + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + this.refreshSessionMessageCountCacheScope() + + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) { + return { success: false, error: '会话ID不能为空' } + } + + const now = Date.now() + const cachedDetail = this.sessionDetailExtraCache.get(normalizedSessionId) + if (cachedDetail && now - cachedDetail.updatedAt <= this.sessionDetailExtraCacheTtlMs) { + return { success: true, detail: cachedDetail.detail } + } + + const tableStatsResult = await wcdbService.getMessageTableStats(normalizedSessionId) + const messageTables: { dbName: string; tableName: string; count: number }[] = [] - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (tableStats.success && tableStats.tables) { - for (const row of tableStats.tables) { + let firstMessageTime: number | undefined + let latestMessageTime: number | undefined + if (tableStatsResult.success && tableStatsResult.tables) { + for (const row of tableStatsResult.tables) { messageTables.push({ dbName: basename(row.db_path || ''), tableName: row.table_name || '', count: parseInt(row.count || '0', 10) }) + + const firstTs = this.getRowInt( + row, + ['first_timestamp', 'firstTimestamp', 'first_time', 'firstTime', 'min_create_time', 'minCreateTime'], + 0 + ) + if (firstTs > 0 && (firstMessageTime === undefined || firstTs < firstMessageTime)) { + firstMessageTime = firstTs + } + + const lastTs = this.getRowInt( + row, + ['last_timestamp', 'lastTimestamp', 'last_time', 'lastTime', 'max_create_time', 'maxCreateTime'], + 0 + ) + if (lastTs > 0 && (latestMessageTime === undefined || lastTs > latestMessageTime)) { + latestMessageTime = lastTs + } } } + const detail: SessionDetailExtra = { + firstMessageTime, + latestMessageTime, + messageTables + } + + this.sessionDetailExtraCache.set(normalizedSessionId, { + detail, + updatedAt: Date.now() + }) + return { success: true, - detail: { - wxid: sessionId, - displayName, - remark, - nickName, - alias, - avatarUrl, - messageCount: totalMessageCount, - firstMessageTime, - latestMessageTime, - messageTables - } + detail } + } catch (e) { + console.error('ChatService: 获取会话详情补充统计失败:', e) + return { success: false, error: String(e) } + } + } + + async getSessionDetail(sessionId: string): Promise<{ + success: boolean + detail?: SessionDetail + error?: string + }> { + try { + const fastResult = await this.getSessionDetailFast(sessionId) + if (!fastResult.success || !fastResult.detail) { + return { success: false, error: fastResult.error || '获取会话详情失败' } + } + + const extraResult = await this.getSessionDetailExtra(sessionId) + const detail: SessionDetail = { + ...fastResult.detail, + firstMessageTime: extraResult.success ? extraResult.detail?.firstMessageTime : undefined, + latestMessageTime: extraResult.success ? extraResult.detail?.latestMessageTime : undefined, + messageTables: extraResult.success && extraResult.detail?.messageTables + ? extraResult.detail.messageTables + : [] + } + + return { success: true, detail } } catch (e) { console.error('ChatService: 获取会话详情失败:', e) return { success: false, error: String(e) } } } + + async getGroupMyMessageCountHint(chatroomId: string): Promise<{ + success: boolean + count?: number + updatedAt?: number + source?: 'memory' | 'disk' + error?: string + }> { + try { + this.refreshSessionMessageCountCacheScope() + const normalizedChatroomId = String(chatroomId || '').trim() + if (!normalizedChatroomId || !normalizedChatroomId.endsWith('@chatroom')) { + return { success: false, error: '群聊ID无效' } + } + + const cached = this.getGroupMyMessageCountHintEntry(normalizedChatroomId) + if (!cached) return { success: true } + return { + success: true, + count: cached.entry.messageCount, + updatedAt: cached.entry.updatedAt, + source: cached.source + } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async setGroupMyMessageCountHint( + chatroomId: string, + messageCount: number, + updatedAt?: number + ): Promise<{ success: boolean; updatedAt?: number; error?: string }> { + try { + this.refreshSessionMessageCountCacheScope() + const normalizedChatroomId = String(chatroomId || '').trim() + if (!normalizedChatroomId || !normalizedChatroomId.endsWith('@chatroom')) { + return { success: false, error: '群聊ID无效' } + } + const savedAt = this.setGroupMyMessageCountHintEntry(normalizedChatroomId, messageCount, updatedAt) + return { success: true, updatedAt: savedAt } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getExportSessionStats(sessionIds: string[], options: ExportSessionStatsOptions = {}): Promise<{ + success: boolean + data?: Record + cache?: Record + needsRefresh?: string[] + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + this.refreshSessionMessageCountCacheScope() + + const includeRelations = options.includeRelations ?? true + const forceRefresh = options.forceRefresh === true + const allowStaleCache = options.allowStaleCache === true + const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true + const cacheOnly = options.cacheOnly === true + + const normalizedSessionIds = Array.from( + new Set( + (sessionIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedSessionIds.length === 0) { + return { success: true, data: {}, cache: {} } + } + + const resultMap: Record = {} + const cacheMeta: Record = {} + const needsRefreshSet = new Set() + const pendingSessionIds: string[] = [] + const now = Date.now() + + for (const sessionId of normalizedSessionIds) { + const groupMyMessagesHint = sessionId.endsWith('@chatroom') + ? this.getGroupMyMessageCountHintEntry(sessionId) + : null + const cachedResult = this.getSessionStatsCacheEntry(sessionId) + const canUseCache = cacheOnly || (!forceRefresh && !preferAccurateSpecialTypes) + if (canUseCache && cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) { + const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs + if (!stale || allowStaleCache || cacheOnly) { + resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats) + if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) { + resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount + } + cacheMeta[sessionId] = { + updatedAt: cachedResult.entry.updatedAt, + stale, + includeRelations: cachedResult.entry.includeRelations, + source: cachedResult.source + } + if (stale) { + needsRefreshSet.add(sessionId) + } + continue + } + } + // allowStaleCache/cacheOnly 仅对“已有缓存”生效;无缓存会话不会直接算重查询。 + if (canUseCache && allowStaleCache && cachedResult) { + needsRefreshSet.add(sessionId) + continue + } + if (cacheOnly) { + continue + } + pendingSessionIds.push(sessionId) + } + + if (pendingSessionIds.length > 0) { + const myWxid = this.configService.get('myWxid') || '' + const selfIdentitySet = new Set(this.buildIdentityKeys(myWxid)) + let usedBatchedCompute = false + if (pendingSessionIds.length === 1) { + const sessionId = pendingSessionIds[0] + try { + const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet, preferAccurateSpecialTypes) + resultMap[sessionId] = stats + const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) + cacheMeta[sessionId] = { + updatedAt, + stale: false, + includeRelations, + source: 'fresh' + } + usedBatchedCompute = true + } catch { + usedBatchedCompute = false + } + } else { + try { + const batchedStatsMap = await this.computeSessionExportStatsBatch( + pendingSessionIds, + includeRelations, + selfIdentitySet, + preferAccurateSpecialTypes + ) + for (const sessionId of pendingSessionIds) { + const stats = batchedStatsMap[sessionId] + if (!stats) continue + resultMap[sessionId] = stats + const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) + cacheMeta[sessionId] = { + updatedAt, + stale: false, + includeRelations, + source: 'fresh' + } + } + usedBatchedCompute = true + } catch { + usedBatchedCompute = false + } + } + + if (!usedBatchedCompute) { + await this.forEachWithConcurrency(pendingSessionIds, 3, async (sessionId) => { + try { + const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet, preferAccurateSpecialTypes) + resultMap[sessionId] = stats + const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations) + cacheMeta[sessionId] = { + updatedAt, + stale: false, + includeRelations, + source: 'fresh' + } + } catch { + resultMap[sessionId] = this.buildEmptyExportSessionStats(sessionId, includeRelations) + } + }) + } + } + + const response: { + success: boolean + data?: Record + cache?: Record + needsRefresh?: string[] + } = { + success: true, + data: resultMap, + cache: cacheMeta + } + if (needsRefreshSet.size > 0) { + response.needsRefresh = Array.from(needsRefreshSet) + } + return response + } catch (e) { + console.error('ChatService: 获取导出会话统计失败:', e) + return { success: false, error: String(e) } + } + } /** * 获取图片数据(解密后的) */ @@ -3415,59 +5890,39 @@ class ChatService { const localId = parseInt(msgId, 10) if (!this.connected) await this.connect() - // 1. 获取消息详情以拿到 MD5 和 AES Key + // 1. 获取消息详情 const msgResult = await this.getMessageByLocalId(sessionId, localId) if (!msgResult.success || !msgResult.message) { return { success: false, error: '未找到消息' } } const msg = msgResult.message - // 2. 确定搜索的基础名 - const baseName = msg.imageMd5 || msg.imageDatName || String(msg.localId) + // 2. 使用 imageDecryptService 解密图片 + const result = await this.imageDecryptService.decryptImage({ + sessionId, + imageMd5: msg.imageMd5, + imageDatName: msg.imageDatName || String(msg.localId), + force: false + }) - // 3. 查找 .dat 文件 - const myWxid = this.configService.get('myWxid') - const dbPath = this.configService.get('dbPath') - if (!myWxid || !dbPath) return { success: false, error: '配置缺失' } - - const accountDir = dirname(dirname(dbPath)) // dbPath 是 db_storage 里面的路径或同级 - // 实际上 dbPath 指向 db_storage,accountDir 应该是其父目录 - const actualAccountDir = this.resolveAccountDir(dbPath, myWxid) - if (!actualAccountDir) return { success: false, error: '无法定位账号目录' } - - const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId) - if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' } - - // 4. 获取解密密钥(优先使用当前 wxid 对应的密钥) - const imageKeys = this.configService.getImageKeysForCurrentWxid() - const xorKeyRaw = imageKeys.xorKey - const aesKeyRaw = imageKeys.aesKey || msg.aesKey - - if (!xorKeyRaw) return { success: false, error: '未配置图片 XOR 密钥,请在设置中自动获取' } - - const xorKey = this.parseXorKey(xorKeyRaw) - const data = readFileSync(datPath) - - // 5. 解密 - let decrypted: Buffer - const version = this.getDatVersion(data) - - if (version === 0) { - decrypted = this.decryptDatV3(data, xorKey) - } else if (version === 1) { - const aesKey = this.asciiKey16(this.defaultV1AesKey) - decrypted = this.decryptDatV4(data, xorKey, aesKey) - } else { - const trimmed = String(aesKeyRaw ?? '').trim() - if (!trimmed || trimmed.length < 16) { - return { success: false, error: 'V4版本需要16字节AES密钥' } - } - const aesKey = this.asciiKey16(trimmed) - decrypted = this.decryptDatV4(data, xorKey, aesKey) + if (!result.success || !result.localPath) { + return { success: false, error: result.error || '图片解密失败' } } - // 返回 base64 - return { success: true, data: decrypted.toString('base64') } + // 3. 读取解密后的文件并转成 base64 + // 如果已经是 data URL,直接返回 base64 部分 + if (result.localPath.startsWith('data:')) { + const base64Data = result.localPath.split(',')[1] + return { success: true, data: base64Data } + } + + // localPath 是 file:// URL,需要转换成文件路径 + const filePath = result.localPath.startsWith('file://') + ? result.localPath.replace(/^file:\/\//, '') + : result.localPath + + const imageData = readFileSync(filePath) + return { success: true, data: imageData.toString('base64') } } catch (e) { console.error('ChatService: getImageData 失败:', e) return { success: false, error: String(e) } @@ -3475,44 +5930,128 @@ class ChatService { } /** - * getVoiceData (绕过WCDB的buggy getVoiceData,直接用execQuery读取) + * getVoiceData(主用批量专属接口读取语音数据) */ async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number, senderWxidOpt?: string): Promise<{ success: boolean; data?: string; error?: string }> { const startTime = Date.now() + const verboseVoiceTrace = process.env.WEFLOW_VOICE_TRACE === '1' + const msgCreateTimeLabel = (value?: number): string => { + return Number.isFinite(Number(value)) ? String(Math.floor(Number(value))) : '无' + } + const lookupPath: string[] = [] + const logLookupPath = (status: 'success' | 'fail', error?: string): void => { + const timeline = lookupPath.map((step, idx) => `${idx + 1}.${step}`).join(' -> ') + if (status === 'success') { + if (verboseVoiceTrace) { + console.info(`[Voice] 定位流程成功: ${timeline}`) + } + } else { + console.warn(`[Voice] 定位流程失败${error ? `(${error})` : ''}: ${timeline}`) + } + } + try { + lookupPath.push(`会话=${sessionId}, 消息=${msgId}, 传入createTime=${msgCreateTimeLabel(createTime)}, serverId=${String(serverId || 0)}`) + lookupPath.push(`消息来源提示=${senderWxidOpt || '无'}`) + const localId = parseInt(msgId, 10) if (isNaN(localId)) { + logLookupPath('fail', '无效的消息ID') return { success: false, error: '无效的消息ID' } } let msgCreateTime = createTime let senderWxid: string | null = senderWxidOpt || null + let resolvedServerId: string | number = this.normalizeUnsignedIntegerToken(serverId) || 0 + let locatedMsg: Message | null = null + let rejectedNonVoiceLookup = false - // 如果前端没传 createTime,才需要查询消息(这个很慢) - if (!msgCreateTime) { + lookupPath.push(`初始解析localId=${localId}成功`) + + // 已提供强键(createTime + serverId)时,直接走语音定位,避免 localId 反查噪音与误导 + const hasStrongInput = Number.isFinite(Number(msgCreateTime)) && Number(msgCreateTime) > 0 + && Boolean(this.normalizeUnsignedIntegerToken(serverId)) + + if (hasStrongInput) { + lookupPath.push('调用入参已具备强键(createTime+serverId),跳过localId反查') + } else { const t1 = Date.now() const msgResult = await this.getMessageByLocalId(sessionId, localId) const t2 = Date.now() + lookupPath.push(`消息反查耗时=${t2 - t1}ms`) + if (!msgResult.success || !msgResult.message) { + lookupPath.push('未命中: getMessageByLocalId') + } else { + const dbMsg = msgResult.message as Message + const locatedServerId = this.normalizeUnsignedIntegerToken(dbMsg.serverIdRaw ?? dbMsg.serverId) + const incomingServerId = this.normalizeUnsignedIntegerToken(serverId) + lookupPath.push(`命中消息定位: localId=${dbMsg.localId}, createTime=${dbMsg.createTime}, sender=${dbMsg.senderUsername || ''}, serverId=${locatedServerId || '0'}, localType=${dbMsg.localType}, voice时长=${dbMsg.voiceDurationSeconds ?? 0}`) + if (incomingServerId && locatedServerId && incomingServerId !== locatedServerId) { + lookupPath.push(`serverId纠正: input=${incomingServerId}, db=${locatedServerId}`) + } - if (msgResult.success && msgResult.message) { - const msg = msgResult.message as any - msgCreateTime = msg.createTime - senderWxid = msg.senderUsername || null + // localId 在不同表可能重复,反查命中非语音时不覆盖调用侧入参 + if (Number(dbMsg.localType) === 34) { + locatedMsg = dbMsg + msgCreateTime = dbMsg.createTime || msgCreateTime + senderWxid = dbMsg.senderUsername || senderWxid || null + if (locatedServerId) { + resolvedServerId = locatedServerId + } + } else { + rejectedNonVoiceLookup = true + lookupPath.push('消息反查命中但localType!=34,忽略反查覆盖,继续使用调用入参定位') + } } } if (!msgCreateTime) { + lookupPath.push('定位失败: 未找到消息时间戳') + logLookupPath('fail', '未找到消息时间戳') return { success: false, error: '未找到消息时间戳' } } + if (!locatedMsg) { + lookupPath.push(rejectedNonVoiceLookup + ? `定位结果: 反查命中非语音并已忽略, createTime=${msgCreateTime}, sender=${senderWxid || '无'}` + : `定位结果: 未走消息反查流程, createTime=${msgCreateTime}, sender=${senderWxid || '无'}`) + } else { + lookupPath.push(`定位结果: 语音消息被确认 localId=${localId}, createTime=${msgCreateTime}, sender=${senderWxid || '无'}`) + } + lookupPath.push(`最终serverId=${String(resolvedServerId || 0)}`) - // 使用 sessionId + createTime 作为缓存key - const cacheKey = `${sessionId}_${msgCreateTime}` + if (verboseVoiceTrace) { + if (locatedMsg) { + console.log('[Voice] 定位到的具体语音消息:', { + sessionId, + msgId, + localId: locatedMsg.localId, + createTime: locatedMsg.createTime, + senderUsername: locatedMsg.senderUsername, + serverId: locatedMsg.serverIdRaw || locatedMsg.serverId, + localType: locatedMsg.localType, + voiceDurationSeconds: locatedMsg.voiceDurationSeconds + }) + } else { + console.log('[Voice] 定位到的语音消息:', { + sessionId, + msgId, + localId, + createTime: msgCreateTime, + senderUsername: senderWxid, + serverId: resolvedServerId + }) + } + } + + // 使用 sessionId + createTime + msgId 作为缓存 key,避免同秒语音串音 + const cacheKey = this.getVoiceCacheKey(sessionId, String(localId), msgCreateTime) // 检查 WAV 内存缓存 const wavCache = this.voiceWavCache.get(cacheKey) if (wavCache) { - + lookupPath.push('命中内存WAV缓存') + logLookupPath('success', '内存缓存') return { success: true, data: wavCache.toString('base64') } } @@ -3522,14 +6061,16 @@ class ChatService { if (existsSync(wavFilePath)) { try { const wavData = readFileSync(wavFilePath) - // 同时缓存到内存 this.cacheVoiceWav(cacheKey, wavData) - + lookupPath.push('命中磁盘WAV缓存') + logLookupPath('success', '磁盘缓存') return { success: true, data: wavData.toString('base64') } } catch (e) { + lookupPath.push('命中磁盘WAV缓存但读取失败') console.error('[Voice] 读取缓存文件失败:', e) } } + lookupPath.push('缓存未命中,进入DB定位') // 构建查找候选 const candidates: string[] = [] @@ -3549,31 +6090,39 @@ class ChatService { if (myWxid && !candidates.includes(myWxid)) { candidates.push(myWxid) } + lookupPath.push(`定位候选链=${JSON.stringify(candidates)}`) const t3 = Date.now() // 从数据库读取 silk 数据 - const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates) + const silkData = await this.getVoiceDataFromMediaDb(sessionId, msgCreateTime, localId, resolvedServerId || 0, candidates, lookupPath, myWxid) const t4 = Date.now() + lookupPath.push(`DB定位耗时=${t4 - t3}ms`) if (!silkData) { + logLookupPath('fail', '未找到语音数据') return { success: false, error: '未找到语音数据 (请确保已在微信中播放过该语音)' } } + lookupPath.push('语音二进制定位完成') const t5 = Date.now() // 使用 silk-wasm 解码 const pcmData = await this.decodeSilkToPcm(silkData, 24000) const t6 = Date.now() + lookupPath.push(`silk解码耗时=${t6 - t5}ms`) if (!pcmData) { + logLookupPath('fail', 'Silk解码失败') return { success: false, error: 'Silk 解码失败' } } + lookupPath.push('silk解码成功') const t7 = Date.now() // PCM -> WAV const wavData = this.createWavBuffer(pcmData, 24000) const t8 = Date.now() + lookupPath.push(`WAV转码耗时=${t8 - t7}ms`) // 缓存 WAV 数据到内存 @@ -3582,9 +6131,13 @@ class ChatService { // 缓存 WAV 数据到文件(异步,不阻塞返回) this.cacheVoiceWavToFile(cacheKey, wavData) + lookupPath.push(`总耗时=${t8 - startTime}ms`) + logLookupPath('success') return { success: true, data: wavData.toString('base64') } } catch (e) { + lookupPath.push(`异常: ${String(e)}`) + logLookupPath('fail', String(e)) console.error('ChatService: getVoiceData 失败:', e) return { success: false, error: String(e) } } @@ -3596,216 +6149,230 @@ class ChatService { private async cacheVoiceWavToFile(cacheKey: string, wavData: Buffer): Promise { try { const voiceCacheDir = this.getVoiceCacheDir() - if (!existsSync(voiceCacheDir)) { - mkdirSync(voiceCacheDir, { recursive: true }) - } - + await fsPromises.mkdir(voiceCacheDir, { recursive: true }) const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`) - writeFileSync(wavFilePath, wavData) + await fsPromises.writeFile(wavFilePath, wavData) } catch (e) { console.error('[Voice] 缓存文件失败:', e) } } /** - * 通过 WCDB 的 execQuery 直接查询 media.db(绕过有bug的getVoiceData接口) - * 策略:批量查询 + 多种兜底方案 + * 通过 WCDB 专属接口查询语音数据 + * 策略:批量查询 + 单条 native 兜底 */ - private async getVoiceDataFromMediaDb(createTime: number, candidates: string[]): Promise { - const startTime = Date.now() + private async getVoiceDataFromMediaDb( + sessionId: string, + createTime: number, + localId: number, + svrId: string | number, + candidates: string[], + lookupPath?: string[], + myWxid?: string + ): Promise { try { - const t1 = Date.now() - // 获取所有 media 数据库(永久缓存,直到应用重启) - let mediaDbFiles: string[] - if (this.mediaDbsCache) { - mediaDbFiles = this.mediaDbsCache + const candidatesList = Array.isArray(candidates) + ? candidates.filter((value, index, arr) => { + const key = String(value || '').trim() + return Boolean(key) && arr.findIndex(v => String(v || '').trim() === key) === index + }) + : [] + const createTimeInt = Math.max(0, Math.floor(Number(createTime || 0))) + const localIdInt = Math.max(0, Math.floor(Number(localId || 0))) + const svrIdToken = svrId || 0 + const plans: Array<{ label: string; list: string[] }> = [] + if (candidatesList.length > 0) { + const strict = String(myWxid || '').trim() + ? candidatesList.filter(item => item !== String(myWxid || '').trim()) + : candidatesList.slice() + if (strict.length > 0 && strict.length !== candidatesList.length) { + plans.push({ label: 'strict(no-self)', list: strict }) + } + plans.push({ label: 'full', list: candidatesList }) } else { - const mediaDbsResult = await wcdbService.listMediaDbs() - const t2 = Date.now() - - - let files = mediaDbsResult.success && mediaDbsResult.data ? (mediaDbsResult.data as string[]) : [] - - // Fallback: 如果 WCDB DLL 没找到,手动查找 - if (files.length === 0) { - console.warn('[Voice] listMediaDbs returned empty, trying manual search') - files = await this.findMediaDbsManually() - } - - if (files.length === 0) { - console.error('[Voice] No media DBs found') - return null - } - - mediaDbFiles = files - this.mediaDbsCache = mediaDbFiles // 永久缓存 + plans.push({ label: 'empty', list: [] }) } - // 在所有 media 数据库中查找 - for (const dbPath of mediaDbFiles) { - try { - // 检查缓存 - let schema = this.mediaDbSchemaCache.get(dbPath) + lookupPath?.push(`构建音频查询参数 createTime=${createTimeInt}, localId=${localIdInt}, svrId=${svrIdToken}, plans=${plans.map(p => `${p.label}:${p.list.length}`).join('|')}`) - if (!schema) { - const t3 = Date.now() - // 第一次查询,获取表结构并缓存 - const tablesResult = await wcdbService.execQuery('media', dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'" - ) - const t4 = Date.now() - - - if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) { - continue - } - - const voiceTable = tablesResult.rows[0].name - - const t5 = Date.now() - const columnsResult = await wcdbService.execQuery('media', dbPath, - `PRAGMA table_info('${voiceTable}')` - ) - const t6 = Date.now() - - - if (!columnsResult.success || !columnsResult.rows) { - continue - } - - // 创建列名映射(原始名称 -> 小写名称) - const columnMap = new Map() - for (const c of columnsResult.rows) { - const name = String(c.name || '') - if (name) { - columnMap.set(name.toLowerCase(), name) - } - } - - // 查找数据列(使用原始列名) - const dataColumnLower = ['voice_data', 'buf', 'voicebuf', 'data'].find(n => columnMap.has(n)) - const dataColumn = dataColumnLower ? columnMap.get(dataColumnLower) : undefined - - if (!dataColumn) { - continue - } - - // 查找 chat_name_id 列 - const chatNameIdColumnLower = ['chat_name_id', 'chatnameid', 'chat_nameid'].find(n => columnMap.has(n)) - const chatNameIdColumn = chatNameIdColumnLower ? columnMap.get(chatNameIdColumnLower) : undefined - - // 查找时间列 - const timeColumnLower = ['create_time', 'createtime', 'time'].find(n => columnMap.has(n)) - const timeColumn = timeColumnLower ? columnMap.get(timeColumnLower) : undefined - - const t7 = Date.now() - // 查找 Name2Id 表 - const name2IdTablesResult = await wcdbService.execQuery('media', dbPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%'" - ) - const t8 = Date.now() - - - const name2IdTable = (name2IdTablesResult.success && name2IdTablesResult.rows && name2IdTablesResult.rows.length > 0) - ? name2IdTablesResult.rows[0].name - : undefined - - schema = { - voiceTable, - dataColumn, - chatNameIdColumn, - timeColumn, - name2IdTable - } - - // 缓存表结构 - this.mediaDbSchemaCache.set(dbPath, schema) + for (const plan of plans) { + lookupPath?.push(`尝试候选集[${plan.label}]=${JSON.stringify(plan.list)}`) + // 先走单条 native:svr_id 通过 int64 直传,避免 batch JSON 的大整数精度/解析差异 + lookupPath?.push(`先尝试单条查询(${plan.label})`) + const single = await wcdbService.getVoiceData( + sessionId, + createTimeInt, + plan.list, + localIdInt, + svrIdToken + ) + lookupPath?.push(`单条查询(${plan.label})结果: success=${single.success}, hasHex=${Boolean(single.hex)}`) + if (single.success && single.hex) { + const decoded = this.decodeVoiceBlob(single.hex) + if (decoded && decoded.length > 0) { + lookupPath?.push(`单条查询(${plan.label})解码成功`) + return decoded } + lookupPath?.push(`单条查询(${plan.label})解码为空`) + } - // 策略1: 通过 chat_name_id + create_time 查找(最准确) - if (schema.chatNameIdColumn && schema.timeColumn && schema.name2IdTable) { - const t9 = Date.now() - // 批量获取所有 candidates 的 chat_name_id(减少查询次数) - const candidatesStr = candidates.map(c => `'${c.replace(/'/g, "''")}'`).join(',') - const name2IdResult = await wcdbService.execQuery('media', dbPath, - `SELECT user_name, rowid FROM ${schema.name2IdTable} WHERE user_name IN (${candidatesStr})` - ) - const t10 = Date.now() + const batchResult = await wcdbService.getVoiceDataBatch([{ + session_id: sessionId, + create_time: createTimeInt, + local_id: localIdInt, + svr_id: svrIdToken, + candidates: plan.list + }]) + lookupPath?.push(`批量查询(${plan.label})结果: success=${batchResult.success}, rows=${Array.isArray(batchResult.rows) ? batchResult.rows.length : 0}`) + if (!batchResult.success) { + lookupPath?.push(`批量查询(${plan.label})失败: ${batchResult.error || '无错误信息'}`) + } - - if (name2IdResult.success && name2IdResult.rows && name2IdResult.rows.length > 0) { - // 构建 chat_name_id 列表 - const chatNameIds = name2IdResult.rows.map((r: any) => r.rowid) - const chatNameIdsStr = chatNameIds.join(',') - - const t11 = Date.now() - // 一次查询所有可能的语音 - const voiceResult = await wcdbService.execQuery('media', dbPath, - `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.chatNameIdColumn} IN (${chatNameIdsStr}) AND ${schema.timeColumn} = ${createTime} LIMIT 1` - ) - const t12 = Date.now() - - - if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { - const row = voiceResult.rows[0] - const silkData = this.decodeVoiceBlob(row.data) - if (silkData) { - - return silkData - } - } + if (batchResult.success && Array.isArray(batchResult.rows) && batchResult.rows.length > 0) { + const hex = String(batchResult.rows[0]?.hex || '').trim() + lookupPath?.push(`命中批量结果(${plan.label})[0], hexLen=${hex.length}`) + if (hex) { + const decoded = this.decodeVoiceBlob(hex) + if (decoded && decoded.length > 0) { + lookupPath?.push(`批量结果(${plan.label})解码成功`) + return decoded } + lookupPath?.push(`批量结果(${plan.label})解码为空`) } - - // 策略2: 只通过 create_time 查找(兜底) - if (schema.timeColumn) { - const t13 = Date.now() - const voiceResult = await wcdbService.execQuery('media', dbPath, - `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} = ${createTime} LIMIT 1` - ) - const t14 = Date.now() - - - if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { - const row = voiceResult.rows[0] - const silkData = this.decodeVoiceBlob(row.data) - if (silkData) { - - return silkData - } - } - } - - // 策略3: 时间范围查找(±5秒,处理时间戳不精确的情况) - if (schema.timeColumn) { - const t15 = Date.now() - const voiceResult = await wcdbService.execQuery('media', dbPath, - `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} BETWEEN ${createTime - 5} AND ${createTime + 5} ORDER BY ABS(${schema.timeColumn} - ${createTime}) LIMIT 1` - ) - const t16 = Date.now() - - - if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { - const row = voiceResult.rows[0] - const silkData = this.decodeVoiceBlob(row.data) - if (silkData) { - - return silkData - } - } - } - } catch (e) { - // 静默失败,继续尝试下一个数据库 + } else { + lookupPath?.push(`批量结果(${plan.label})未命中`) } } + lookupPath?.push('音频定位失败:未命中任何结果') return null } catch (e) { + lookupPath?.push(`音频定位异常: ${String(e)}`) return null } } + async preloadVoiceDataBatch( + sessionId: string, + messages: Array<{ + localId?: number | string + createTime?: number | string + serverId?: number | string + senderWxid?: string | null + }>, + options?: { chunkSize?: number; decodeConcurrency?: number } + ): Promise<{ success: boolean; prepared?: number; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return { success: true, prepared: 0 } + if (!Array.isArray(messages) || messages.length === 0) return { success: true, prepared: 0 } + + const myWxid = String(this.configService.get('myWxid') || '').trim() + const nowPrepared = new Set() + const pending: Array<{ + cacheKey: string + request: { session_id: string; create_time: number; local_id: number; svr_id: string | number; candidates: string[] } + }> = [] + + for (const item of messages) { + const localId = Math.max(0, Math.floor(Number(item?.localId || 0))) + const createTime = Math.max(0, Math.floor(Number(item?.createTime || 0))) + if (!localId || !createTime) continue + + const cacheKey = this.getVoiceCacheKey(normalizedSessionId, String(localId), createTime) + if (nowPrepared.has(cacheKey)) continue + nowPrepared.add(cacheKey) + + const inMemory = this.voiceWavCache.get(cacheKey) + if (inMemory && inMemory.length > 0) continue + + const wavFilePath = join(this.getVoiceCacheDir(), `${cacheKey}.wav`) + if (existsSync(wavFilePath)) { + try { + const wavData = readFileSync(wavFilePath) + if (wavData.length > 0) { + this.cacheVoiceWav(cacheKey, wavData) + continue + } + } catch { + // ignore corrupted cache file + } + } + + const senderWxid = String(item?.senderWxid || '').trim() + const candidates: string[] = [] + if (senderWxid) candidates.push(senderWxid) + if (!candidates.includes(normalizedSessionId)) candidates.push(normalizedSessionId) + if (myWxid && !candidates.includes(myWxid)) candidates.push(myWxid) + + pending.push({ + cacheKey, + request: { + session_id: normalizedSessionId, + create_time: createTime, + local_id: localId, + svr_id: item?.serverId || 0, + candidates + } + }) + } + + if (pending.length === 0) { + return { success: true, prepared: nowPrepared.size } + } + + const chunkSize = Math.max(8, Math.min(128, Math.floor(Number(options?.chunkSize || 48)))) + const decodeConcurrency = Math.max(1, Math.min(6, Math.floor(Number(options?.decodeConcurrency || 3)))) + let prepared = nowPrepared.size - pending.length + + for (let i = 0; i < pending.length; i += chunkSize) { + const chunk = pending.slice(i, i + chunkSize) + const batchResult = await wcdbService.getVoiceDataBatch(chunk.map(item => item.request)) + if (!batchResult.success || !Array.isArray(batchResult.rows)) { + continue + } + + const byIndex = new Map() + for (const row of batchResult.rows as Array>) { + const idx = Number.parseInt(String(row?.index ?? ''), 10) + const hex = String(row?.hex || '').trim() + if (!Number.isFinite(idx) || idx < 0 || !hex) continue + byIndex.set(idx, hex) + } + + const readyItems: Array<{ cacheKey: string; hex: string }> = [] + for (let rowIdx = 0; rowIdx < chunk.length; rowIdx += 1) { + const hex = byIndex.get(rowIdx) + if (!hex) continue + readyItems.push({ cacheKey: chunk[rowIdx].cacheKey, hex }) + } + + await this.forEachWithConcurrency(readyItems, decodeConcurrency, async (item) => { + const silkData = this.decodeVoiceBlob(item.hex) + if (!silkData || silkData.length === 0) return + + const pcmData = await this.decodeSilkToPcm(silkData, 24000) + if (!pcmData || pcmData.length === 0) return + + const wavData = this.createWavBuffer(pcmData, 24000) + this.cacheVoiceWav(item.cacheKey, wavData) + this.cacheVoiceWavToFile(item.cacheKey, wavData) + prepared += 1 + }) + } + + return { success: true, prepared } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 检查语音是否已有缓存(只检查内存,不查询数据库) */ @@ -3834,121 +6401,8 @@ class ChatService { const msgResult = await this.getMessageByLocalId(sessionId, localId) if (!msgResult.success || !msgResult.message) return { success: false, error: '未找到该消息' } const msg = msgResult.message - if (msg.isSend === 1) { - console.info('[ChatService][Voice] self-sent voice, continue decrypt flow') - } - - const candidates = this.getVoiceLookupCandidates(sessionId, msg) - if (candidates.length === 0) { - return { success: false, error: '未找到语音关联账号' } - } - console.info('[ChatService][Voice] request', { - sessionId, - localId: msg.localId, - createTime: msg.createTime, - candidates - }) - - // 2. 查找所有的 media_*.db - let mediaDbs = await wcdbService.listMediaDbs() - // Fallback: 如果 WCDB DLL 不支持 listMediaDbs,手动查找 - if (!mediaDbs.success || !mediaDbs.data || mediaDbs.data.length === 0) { - const manualMediaDbs = await this.findMediaDbsManually() - if (manualMediaDbs.length > 0) { - mediaDbs = { success: true, data: manualMediaDbs } - } else { - return { success: false, error: '未找到媒体库文件 (media_*.db)' } - } - } - - // 3. 在所有媒体库中查找该消息的语音数据 - let silkData: Buffer | null = null - for (const dbPath of (mediaDbs.data || [])) { - const voiceTable = await this.resolveVoiceInfoTableName(dbPath) - if (!voiceTable) { - continue - } - const columns = await this.resolveVoiceInfoColumns(dbPath, voiceTable) - if (!columns) { - continue - } - for (const candidate of candidates) { - const chatNameId = await this.resolveChatNameId(dbPath, candidate) - // 策略 1: 使用 ChatNameId + CreateTime (最准确) - if (chatNameId) { - let whereClause = '' - if (columns.chatNameIdColumn && columns.createTimeColumn) { - whereClause = `${columns.chatNameIdColumn} = ${chatNameId} AND ${columns.createTimeColumn} = ${msg.createTime}` - const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1` - const result = await wcdbService.execQuery('media', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const raw = result.rows[0]?.data - const decoded = this.decodeVoiceBlob(raw) - if (decoded && decoded.length > 0) { - console.info('[ChatService][Voice] hit by createTime', { dbPath, voiceTable, whereClause, bytes: decoded.length }) - silkData = decoded - break - } - } - } - } - - // 策略 2: 使用 MsgLocalId (兜底,如果表支持) - if (columns.msgLocalIdColumn) { - const whereClause = `${columns.msgLocalIdColumn} = ${msg.localId}` - const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1` - const result = await wcdbService.execQuery('media', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const raw = result.rows[0]?.data - const decoded = this.decodeVoiceBlob(raw) - if (decoded && decoded.length > 0) { - console.info('[ChatService][Voice] hit by localId', { dbPath, voiceTable, whereClause, bytes: decoded.length }) - silkData = decoded - break - } - } - } - } - if (silkData) break - - // 策略 3: 只使用 CreateTime (兜底) - if (!silkData && columns.createTimeColumn) { - const whereClause = `${columns.createTimeColumn} = ${msg.createTime}` - const sql = `SELECT ${columns.dataColumn} AS data FROM ${voiceTable} WHERE ${whereClause} LIMIT 1` - const result = await wcdbService.execQuery('media', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const raw = result.rows[0]?.data - const decoded = this.decodeVoiceBlob(raw) - if (decoded && decoded.length > 0) { - console.info('[ChatService][Voice] hit by createTime only', { dbPath, voiceTable, whereClause, bytes: decoded.length }) - silkData = decoded - } - } - } - if (silkData) break - } - - if (!silkData) return { success: false, error: '未找到语音数据' } - - // 4. 使用 silk-wasm 解码 - try { - const pcmData = await this.decodeSilkToPcm(silkData, 24000) - if (!pcmData) { - return { success: false, error: 'Silk 解码失败' } - } - - // PCM -> WAV - const wavData = this.createWavBuffer(pcmData, 24000) - - // 缓存 WAV 数据 (内存缓存) - const cacheKey = this.getVoiceCacheKey(sessionId, msgId) - this.cacheVoiceWav(cacheKey, wavData) - - return { success: true, data: wavData.toString('base64') } - } catch (e) { - console.error('[ChatService][Voice] decoding error:', e) - return { success: false, error: '语音解码失败: ' + String(e) } - } + const senderWxid = msg.senderUsername || undefined + return this.getVoiceData(sessionId, msgId, msg.createTime, msg.serverIdRaw || msg.serverId, senderWxid) } catch (e) { console.error('ChatService: getVoiceData 失败:', e) return { success: false, error: String(e) } @@ -4038,7 +6492,7 @@ class ChatService { if (msgResult.success && msgResult.message) { msgCreateTime = msgResult.message.createTime - serverId = msgResult.message.serverId + serverId = msgResult.message.serverIdRaw || msgResult.message.serverId } } @@ -4142,9 +6596,9 @@ class ChatService { private getVoiceCacheKey(sessionId: string, msgId: string, createTime?: number): string { - // 优先使用 createTime 作为key,避免不同会话中localId相同导致的混乱 + // createTime + msgId 可避免同会话同秒多条语音互相覆盖 if (createTime) { - return `${sessionId}_${createTime}` + return `${sessionId}_${createTime}_${msgId}` } return `${sessionId}_${msgId}` } @@ -4220,6 +6674,36 @@ class ChatService { return this.voiceTranscriptCache.has(cacheKey) } + /** + * 批量统计转写缓存命中数(按会话维度)。 + * 仅基于本地 transcripts cache key 统计,用于导出前快速预估。 + */ + getCachedVoiceTranscriptCountMap(sessionIds: string[]): Record { + this.loadTranscriptCacheIfNeeded() + const normalizedIds = Array.from( + new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)) + ) + const targetSet = new Set(normalizedIds) + const countMap: Record = {} + for (const sessionId of normalizedIds) { + countMap[sessionId] = 0 + } + if (targetSet.size === 0) return countMap + + for (const key of this.voiceTranscriptCache.keys()) { + const rawKey = String(key || '') + if (!rawKey) continue + // 新 key: `${sessionId}_${createTime}_${msgId}`;旧 key: `${sessionId}_${createTime}` + const matchNew = /^(.*)_(\d+)_(\d+)$/.exec(rawKey) + const matchOld = matchNew ? null : /^(.*)_(\d+)$/.exec(rawKey) + const sessionId = String((matchNew ? matchNew[1] : (matchOld ? matchOld[1] : '')) || '').trim() + if (!sessionId || !targetSet.has(sessionId)) continue + countMap[sessionId] = (countMap[sessionId] || 0) + 1 + } + + return countMap + } + /** * 获取某会话的所有语音消息(localType=34),用于批量转写 */ @@ -4230,36 +6714,12 @@ class ChatService { return { success: false, error: connectResult.error || '数据库未连接' } } - // 获取会话表信息 - let tables = this.sessionTablesCache.get(sessionId) - if (!tables) { - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { - return { success: false, error: '未找到会话消息表' } - } - tables = tableStats.tables - .map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path })) - .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> - if (tables.length > 0) { - this.sessionTablesCache.set(sessionId, tables) - setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl) - } + const result = await wcdbService.getMessagesByType(sessionId, 34, false, 0, 0) + if (!result.success || !Array.isArray(result.rows)) { + return { success: false, error: result.error || '查询语音消息失败' } } - let allVoiceMessages: Message[] = [] - - for (const { tableName, dbPath } of tables) { - try { - const sql = `SELECT * FROM ${tableName} WHERE local_type = 34 ORDER BY create_time DESC` - const result = await wcdbService.execQuery('message', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const mapped = this.mapRowsToMessages(result.rows as Record[]) - allVoiceMessages.push(...mapped) - } - } catch (e) { - console.error(`[ChatService] 查询语音消息失败 (${dbPath}):`, e) - } - } + let allVoiceMessages: Message[] = this.mapRowsToMessages(result.rows as Record[]) // 按 createTime 降序排序 allVoiceMessages.sort((a, b) => b.createTime - a.createTime) @@ -4297,43 +6757,20 @@ class ChatService { return { success: false, error: connectResult.error || '数据库未连接' } } - let tables = this.sessionTablesCache.get(sessionId) - if (!tables) { - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { - return { success: false, error: '未找到会话消息表' } - } - tables = tableStats.tables - .map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path })) - .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> - if (tables.length > 0) { - this.sessionTablesCache.set(sessionId, tables) - setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl) - } + const result = await wcdbService.getMessagesByType(sessionId, 3, false, 0, 0) + if (!result.success || !Array.isArray(result.rows)) { + return { success: false, error: result.error || '查询图片消息失败' } } - let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = [] - - for (const { tableName, dbPath } of tables) { - try { - const sql = `SELECT * FROM ${tableName} WHERE local_type = 3 ORDER BY create_time DESC` - const result = await wcdbService.execQuery('message', dbPath, sql) - if (result.success && result.rows && result.rows.length > 0) { - const mapped = this.mapRowsToMessages(result.rows as Record[]) - const images = mapped - .filter(msg => msg.localType === 3) - .map(msg => ({ - imageMd5: msg.imageMd5 || undefined, - imageDatName: msg.imageDatName || undefined, - createTime: msg.createTime || undefined - })) - .filter(img => Boolean(img.imageMd5 || img.imageDatName)) - allImages.push(...images) - } - } catch (e) { - console.error(`[ChatService] 查询图片消息失败 (${dbPath}):`, e) - } - } + const mapped = this.mapRowsToMessages(result.rows as Record[]) + let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = mapped + .filter(msg => msg.localType === 3) + .map(msg => ({ + imageMd5: msg.imageMd5 || undefined, + imageDatName: msg.imageDatName || undefined, + createTime: msg.createTime || undefined + })) + .filter(img => Boolean(img.imageMd5 || img.imageDatName)) allImages.sort((a, b) => (b.createTime || 0) - (a.createTime || 0)) @@ -4375,59 +6812,90 @@ class ChatService { } } + async getMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const result = await wcdbService.getSessionMessageDateCounts(sessionId) + if (!result.success || !result.counts) { + return { success: false, error: result.error || '查询每日消息数失败' } + } + const counts = result.counts + + console.log(`[ChatService] 会话 ${sessionId} 获取到 ${Object.keys(counts).length} 个日期的消息计数`) + return { success: true, counts } + } catch (error) { + console.error('[ChatService] 获取每日消息数失败:', error) + return { success: false, error: String(error) } + } + } + async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { try { - // 1. 尝试从缓存获取会话表信息 - let tables = this.sessionTablesCache.get(sessionId) - - if (!tables) { - // 缓存未命中,查询数据库 - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { - return { success: false, error: '未找到会话消息表' } - } - - // 提取表信息并缓存 - tables = tableStats.tables - .map(t => ({ - tableName: t.table_name || t.name, - dbPath: t.db_path - })) - .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> - - if (tables.length > 0) { - this.sessionTablesCache.set(sessionId, tables) - // 设置过期清理 - setTimeout(() => { - this.sessionTablesCache.delete(sessionId) - }, this.sessionTablesCacheTtl) - } + const nativeResult = await wcdbService.getMessageById(sessionId, localId) + if (nativeResult.success && nativeResult.message) { + const message = await this.parseMessage(nativeResult.message as Record, { source: 'detail', sessionId }) + if (message.localId !== 0) return { success: true, message } } - - // 2. 遍历表查找消息 (通常只有一个主表,但可能有归档) - for (const { tableName, dbPath } of tables) { - // 构造查询 - const sql = `SELECT * FROM ${tableName} WHERE local_id = ${localId} LIMIT 1` - const result = await wcdbService.execQuery('message', dbPath, sql) - - if (result.success && result.rows && result.rows.length > 0) { - const row = result.rows[0] - const message = this.parseMessage(row) - - if (message.localId !== 0) { - return { success: true, message } - } - } - } - - return { success: false, error: '未找到消息' } + return { success: false, error: nativeResult.error || '未找到消息' } } catch (e) { console.error('ChatService: getMessageById 失败:', e) return { success: false, error: String(e) } } } - private parseMessage(row: any): Message { + async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: Message[]; error?: string }> { + try { + const result = await wcdbService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp) + if (!result.success || !result.messages) { + return { success: false, error: result.error || '搜索失败' } + } + const messages: Message[] = [] + const isGroupSearch = Boolean(String(sessionId || '').trim().endsWith('@chatroom')) + + for (const row of result.messages) { + let message = await this.parseMessage(row, { source: 'search', sessionId }) + const resolvedSessionId = String( + sessionId || + this.getRowField(row, ['_session_id', 'session_id', 'sessionId', 'talker', 'username']) + || '' + ).trim() + const needsDetailHydration = isGroupSearch && + Boolean(sessionId) && + message.localId > 0 && + (!message.senderUsername || message.isSend === null) + + if (needsDetailHydration && sessionId) { + const detail = await this.getMessageById(sessionId, message.localId) + if (detail.success && detail.message) { + message = { + ...message, + ...detail.message, + parsedContent: message.parsedContent || detail.message.parsedContent, + rawContent: message.rawContent || detail.message.rawContent, + content: message.content || detail.message.content + } + } + } + + if (resolvedSessionId) { + ;(message as Message & { sessionId?: string }).sessionId = resolvedSessionId + } + messages.push(message) + } + + return { success: true, messages } + } catch (e) { + console.error('ChatService: searchMessages 失败:', e) + return { success: false, error: String(e) } + } + } + + private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise { + const sourceInfo = this.getMessageSourceInfo(row) const rawContent = this.decodeMessageContent( this.getRowField(row, [ 'message_content', @@ -4448,17 +6916,37 @@ class ChatService { ) // 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的 // 实际项目中建议抽取 parseRawMessage(row) 供多处使用 + const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0) + const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'])) + const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0) + const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0) + const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0) + const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime) + const rawIsSend = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']) + const senderUsername = await this.resolveSenderUsernameForMessageRow(row, rawContent) + const sendState = this.resolveMessageIsSend(rawIsSend === null ? null : parseInt(rawIsSend, 10), senderUsername) const msg: Message = { - localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0), - serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0), - localType: this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0), - createTime: this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0), - sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)), - isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0), - senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null, + messageKey: this.buildMessageKey({ + localId, + serverId, + createTime, + sortSeq, + senderUsername, + localType, + ...sourceInfo + }), + localId, + serverId, + serverIdRaw, + localType, + createTime, + sortSeq, + isSend: sendState.isSend, + senderUsername, rawContent: rawContent, content: rawContent, // 添加原始内容供视频MD5解析使用 - parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)) + parsedContent: this.parseMessageContent(rawContent, localType), + _db_path: sourceInfo.dbPath } if (msg.localId === 0 || msg.createTime === 0) { @@ -4530,10 +7018,6 @@ class ChatService { private async findDatFile(accountDir: string, baseName: string, sessionId?: string): Promise { const normalized = this.normalizeDatBase(baseName) - if (this.looksLikeMd5(normalized)) { - const hardlinkPath = this.resolveHardlinkPath(accountDir, normalized, sessionId) - if (hardlinkPath) return hardlinkPath - } const searchPaths = [ join(accountDir, 'FileStorage', 'Image'), @@ -4599,68 +7083,6 @@ class ChatService { return /[._][a-z]$/.test(baseLower) } - private resolveHardlinkPath(accountDir: string, md5: string, sessionId?: string): string | null { - try { - const hardlinkPath = join(accountDir, 'hardlink.db') - if (!existsSync(hardlinkPath)) return null - - const state = this.getHardlinkState(accountDir, hardlinkPath) - if (!state.imageTable) return null - - const row = state.db - .prepare(`SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE md5 = ? LIMIT 1`) - .get(md5) as { dir1?: string; dir2?: string; file_name?: string } | undefined - - if (!row) return null - const dir1 = row.dir1 as string | undefined - const dir2 = row.dir2 as string | undefined - const fileName = row.file_name as string | undefined - if (!dir1 || !dir2 || !fileName) return null - const lowerFileName = fileName.toLowerCase() - if (lowerFileName.endsWith('.dat')) { - const baseLower = lowerFileName.slice(0, -4) - if (!this.hasXVariant(baseLower)) return null - } - - let dirName = dir2 - if (state.dirTable && sessionId) { - try { - const dirRow = state.db - .prepare(`SELECT dir_name FROM ${state.dirTable} WHERE dir_id = ? AND username = ? LIMIT 1`) - .get(dir2, sessionId) as { dir_name?: string } | undefined - if (dirRow?.dir_name) dirName = dirRow.dir_name as string - } catch { } - } - - const fullPath = join(accountDir, dir1, dirName, fileName) - if (existsSync(fullPath)) return fullPath - - const withDat = `${fullPath}.dat` - if (existsSync(withDat)) return withDat - } catch { } - return null - } - - private getHardlinkState(accountDir: string, hardlinkPath: string): HardlinkState { - const cached = this.hardlinkCache.get(accountDir) - if (cached) return cached - - const db = new Database(hardlinkPath, { readonly: true, fileMustExist: true }) - const imageRow = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1") - .get() as { name?: string } | undefined - const dirRow = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1") - .get() as { name?: string } | undefined - const state: HardlinkState = { - db, - imageTable: imageRow?.name as string | undefined, - dirTable: dirRow?.name as string | undefined - } - this.hardlinkCache.set(accountDir, state) - return state - } - private getDatVersion(data: Buffer): number { if (data.length < 6) return 0 const sigV1 = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]) @@ -4799,6 +7221,7 @@ class ChatService { if (!connectResult.success) { return { success: false, error: connectResult.error || '数据库未连接' } } + // fallback-exec: 仅用于诊断/低频兼容,不作为业务主路径 return wcdbService.execQuery(kind, path, sql) } catch (e) { console.error('ChatService: 执行自定义查询失败:', e) diff --git a/electron/services/cloudControlService.ts b/electron/services/cloudControlService.ts new file mode 100644 index 0000000..c43dcd2 --- /dev/null +++ b/electron/services/cloudControlService.ts @@ -0,0 +1,161 @@ +import { app } from 'electron' +import { wcdbService } from './wcdbService' + +interface UsageStats { + appVersion: string + platform: string + deviceId: string + timestamp: number + online: boolean + pages: string[] +} + +class CloudControlService { + private deviceId: string = '' + private timer: NodeJS.Timeout | null = null + private pages: Set = new Set() + private platformVersionCache: string | null = null + + async init() { + this.deviceId = this.getDeviceId() + await wcdbService.cloudInit(300) + await this.reportOnline() + + this.timer = setInterval(() => { + this.reportOnline() + }, 300000) + } + + private getDeviceId(): string { + const crypto = require('crypto') + const os = require('os') + const machineId = os.hostname() + os.platform() + os.arch() + return crypto.createHash('md5').update(machineId).digest('hex') + } + + private async reportOnline() { + const data: UsageStats = { + appVersion: app.getVersion(), + platform: this.getPlatformVersion(), + deviceId: this.deviceId, + timestamp: Date.now(), + online: true, + pages: Array.from(this.pages) + } + + await wcdbService.cloudReport(JSON.stringify(data)) + this.pages.clear() + } + + private getPlatformVersion(): string { + if (this.platformVersionCache) { + return this.platformVersionCache + } + + const os = require('os') + const fs = require('fs') + const platform = process.platform + + if (platform === 'win32') { + const release = os.release() + const parts = release.split('.') + const major = parseInt(parts[0]) + const minor = parseInt(parts[1] || '0') + const build = parseInt(parts[2] || '0') + + // Windows 11 是 10.0.22000+,且主版本必须是 10.0 + if (major === 10 && minor === 0 && build >= 22000) { + this.platformVersionCache = 'Windows 11' + return this.platformVersionCache + } else if (major === 10) { + this.platformVersionCache = 'Windows 10' + return this.platformVersionCache + } + this.platformVersionCache = `Windows ${release}` + return this.platformVersionCache + } + + if (platform === 'darwin') { + // `os.release()` returns Darwin kernel version (e.g. 25.3.0), + // while cloud reporting expects the macOS product version (e.g. 26.3). + const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release() + this.platformVersionCache = `macOS ${macVersion}` + return this.platformVersionCache + } + + if (platform === 'linux') { + try { + const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release'] + for (const filePath of osReleasePaths) { + if (!fs.existsSync(filePath)) { + continue + } + + const content = fs.readFileSync(filePath, 'utf8') + const values: Record = {} + + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) { + continue + } + + const separatorIndex = trimmed.indexOf('=') + if (separatorIndex <= 0) { + continue + } + + const key = trimmed.slice(0, separatorIndex) + let value = trimmed.slice(separatorIndex + 1).trim() + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) { + value = value.slice(1, -1) + } + values[key] = value + } + + if (values.PRETTY_NAME) { + this.platformVersionCache = values.PRETTY_NAME + return this.platformVersionCache + } + + if (values.NAME && values.VERSION_ID) { + this.platformVersionCache = `${values.NAME} ${values.VERSION_ID}` + return this.platformVersionCache + } + + if (values.NAME) { + this.platformVersionCache = values.NAME + return this.platformVersionCache + } + } + } catch (error) { + console.warn('[CloudControl] Failed to detect Linux distro version:', error) + } + + this.platformVersionCache = `Linux ${os.release()}` + return this.platformVersionCache + } + + this.platformVersionCache = platform + return this.platformVersionCache + } + + recordPage(pageName: string) { + this.pages.add(pageName) + } + + stop() { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + wcdbService.cloudStop() + } + + async getLogs() { + return wcdbService.getLogs() + } +} + +export const cloudControlService = new CloudControlService() + diff --git a/electron/services/config.ts b/electron/services/config.ts index 41f2b9d..c293ee1 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -16,7 +16,7 @@ interface ConfigSchema { imageXorKey: number imageAesKey: string wxidConfigs: Record - + exportPath?: string; // 缓存相关 cachePath: string lastOpenedDb: string @@ -34,6 +34,7 @@ interface ConfigSchema { autoTranscribeVoice: boolean transcribeLanguages: string[] exportDefaultConcurrency: number + exportDefaultImageDeepSearchOnMiss: boolean analyticsExcludedUsernames: string[] // 安全相关 @@ -47,9 +48,12 @@ interface ConfigSchema { // 通知 notificationEnabled: boolean - notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' + notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' notificationFilterMode: 'all' | 'whitelist' | 'blacklist' notificationFilterList: string[] + messagePushEnabled: boolean + windowCloseBehavior: 'ask' | 'tray' | 'quit' + quoteLayout: 'quote-top' | 'quote-bottom' wordCloudExcludeWords: string[] } @@ -82,43 +86,73 @@ export class ConfigService { return ConfigService.instance } ConfigService.instance = this - this.store = new Store({ + const defaults: ConfigSchema = { + dbPath: '', + decryptKey: '', + myWxid: '', + onboardingDone: false, + imageXorKey: 0, + imageAesKey: '', + wxidConfigs: {}, + cachePath: '', + lastOpenedDb: '', + lastSession: '', + theme: 'system', + themeId: 'cloud-dancer', + language: 'zh-CN', + logEnabled: false, + llmModelPath: '', + whisperModelName: 'base', + whisperModelDir: '', + whisperDownloadSource: 'tsinghua', + autoTranscribeVoice: false, + transcribeLanguages: ['zh'], + exportDefaultConcurrency: 4, + exportDefaultImageDeepSearchOnMiss: true, + analyticsExcludedUsernames: [], + authEnabled: false, + authPassword: '', + authUseHello: false, + authHelloSecret: '', + ignoredUpdateVersion: '', + notificationEnabled: true, + notificationPosition: 'top-right', + notificationFilterMode: 'all', + notificationFilterList: [], + messagePushEnabled: false, + windowCloseBehavior: 'ask', + quoteLayout: 'quote-top', + wordCloudExcludeWords: [] + } + + const storeOptions: any = { name: 'WeFlow-config', - defaults: { - dbPath: '', - decryptKey: '', - myWxid: '', - onboardingDone: false, - imageXorKey: 0, - imageAesKey: '', - wxidConfigs: {}, - cachePath: '', - lastOpenedDb: '', - lastSession: '', - theme: 'system', - themeId: 'cloud-dancer', - language: 'zh-CN', - logEnabled: false, - llmModelPath: '', - whisperModelName: 'base', - whisperModelDir: '', - whisperDownloadSource: 'tsinghua', - autoTranscribeVoice: false, - transcribeLanguages: ['zh'], - exportDefaultConcurrency: 2, - analyticsExcludedUsernames: [], - authEnabled: false, - authPassword: '', - authUseHello: false, - authHelloSecret: '', - ignoredUpdateVersion: '', - notificationEnabled: true, - notificationPosition: 'top-right', - notificationFilterMode: 'all', - notificationFilterList: [], - wordCloudExcludeWords: [] + defaults, + projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow' + } + const runningInWorker = process.env.WEFLOW_WORKER === '1' + if (runningInWorker) { + const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim() + if (cwd) { + storeOptions.cwd = cwd } - }) + } + + try { + this.store = new Store(storeOptions) + } catch (error) { + const message = String((error as Error)?.message || error || '') + if (message.includes('projectName')) { + const fallbackOptions = { + ...storeOptions, + projectName: 'WeFlow', + cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd() + } + this.store = new Store(fallbackOptions) + } else { + throw error + } + } this.migrateAuthFields() } @@ -658,8 +692,16 @@ export class ConfigService { } } + private getUserDataPath(): string { + const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() + if (workerUserDataPath) { + return workerUserDataPath + } + return app?.getPath?.('userData') || process.cwd() + } + getCacheBasePath(): string { - return join(app.getPath('userData'), 'cache') + return join(this.getUserDataPath(), 'cache') } getAll(): Partial { @@ -671,4 +713,4 @@ export class ConfigService { this.unlockedKeys.clear() this.unlockPassword = null } -} \ No newline at end of file +} diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index ee15b02..b6fb973 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -1,13 +1,90 @@ import { join, basename } from 'path' -import { existsSync, readdirSync, statSync } from 'fs' +import { existsSync, readdirSync, statSync, readFileSync } from 'fs' import { homedir } from 'os' +import { createDecipheriv } from 'crypto' export interface WxidInfo { wxid: string modifiedTime: number + nickname?: string + avatarUrl?: string } export class DbPathService { + private readVarint(buf: Buffer, offset: number): { value: number, length: number } { + let value = 0; + let length = 0; + let shift = 0; + while (offset < buf.length && shift < 32) { + const b = buf[offset++]; + value |= (b & 0x7f) << shift; + length++; + if ((b & 0x80) === 0) break; + shift += 7; + } + return { value, length }; + } + + private extractMmkvString(buf: Buffer, keyName: string): string { + const keyBuf = Buffer.from(keyName, 'utf8'); + const idx = buf.indexOf(keyBuf); + if (idx === -1) return ''; + + try { + let offset = idx + keyBuf.length; + const v1 = this.readVarint(buf, offset); + offset += v1.length; + const v2 = this.readVarint(buf, offset); + offset += v2.length; + + // 合理性检查 + if (v2.value > 0 && v2.value <= 10000 && offset + v2.value <= buf.length) { + return buf.toString('utf8', offset, offset + v2.value); + } + } catch { } + return ''; + } + + private parseGlobalConfig(rootPath: string): { wxid: string, nickname: string, avatarUrl: string } | null { + try { + const configPath = join(rootPath, 'all_users', 'config', 'global_config'); + if (!existsSync(configPath)) return null; + + const fullData = readFileSync(configPath); + if (fullData.length <= 4) return null; + const encryptedData = fullData.subarray(4); + + const key = Buffer.alloc(16, 0); + Buffer.from('xwechat_crypt_key').copy(key); // 直接硬编码,iv更是不重要 + const iv = Buffer.alloc(16, 0); + + const decipher = createDecipheriv('aes-128-cfb', key, iv); + decipher.setAutoPadding(false); + const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]); + + const wxid = this.extractMmkvString(decrypted, 'mmkv_key_user_name'); + const nickname = this.extractMmkvString(decrypted, 'mmkv_key_nick_name'); + let avatarUrl = this.extractMmkvString(decrypted, 'mmkv_key_head_img_url'); + + if (!avatarUrl && decrypted.includes('http')) { + const httpIdx = decrypted.indexOf('http'); + const nullIdx = decrypted.indexOf(0x00, httpIdx); + if (nullIdx !== -1) { + avatarUrl = decrypted.toString('utf8', httpIdx, nullIdx); + } + } + + if (wxid || nickname) { + return { wxid, nickname, avatarUrl }; + } + return null; + } catch (e) { + console.error('解析 global_config 失败:', e); + return null; + } + } + + /** * 自动检测微信数据库根目录 */ @@ -16,8 +93,13 @@ export class DbPathService { const possiblePaths: string[] = [] const home = homedir() - // 微信4.x 数据目录 - possiblePaths.push(join(home, 'Documents', 'xwechat_files')) + // macOS 微信路径(固定) + if (process.platform === 'darwin') { + possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')) + } else { + // Windows 微信4.x 数据目录 + possiblePaths.push(join(home, 'Documents', 'xwechat_files')) + } for (const path of possiblePaths) { @@ -130,21 +212,16 @@ export class DbPathService { for (const entry of entries) { const entryPath = join(rootPath, entry) let stat: ReturnType - try { - stat = statSync(entryPath) - } catch { - continue - } - + try { stat = statSync(entryPath) } catch { continue } if (!stat.isDirectory()) continue const lower = entry.toLowerCase() if (lower === 'all_users') continue if (!entry.includes('_')) continue - wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs }) } } + if (wxids.length === 0) { const rootName = basename(rootPath) if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') { @@ -154,12 +231,25 @@ export class DbPathService { } } catch { } - return wxids.sort((a, b) => { + const sorted = wxids.sort((a, b) => { if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime return a.wxid.localeCompare(b.wxid) - }) + }); + + const globalInfo = this.parseGlobalConfig(rootPath); + if (globalInfo) { + for (const w of sorted) { + if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) { + w.nickname = globalInfo.nickname; + w.avatarUrl = globalInfo.avatarUrl; + } + } + } + + return sorted; } + /** * 扫描 wxid 列表 */ @@ -182,10 +272,21 @@ export class DbPathService { } } catch { } - return wxids.sort((a, b) => { + const sorted = wxids.sort((a, b) => { if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime return a.wxid.localeCompare(b.wxid) - }) + }); + + const globalInfo = this.parseGlobalConfig(rootPath); + if (globalInfo) { + for (const w of sorted) { + if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) { + w.nickname = globalInfo.nickname; + w.avatarUrl = globalInfo.avatarUrl; + } + } + } + return sorted; } /** @@ -193,6 +294,9 @@ export class DbPathService { */ getDefaultPath(): string { const home = homedir() + if (process.platform === 'darwin') { + return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files') + } return join(home, 'Documents', 'xwechat_files') } } diff --git a/electron/services/exportCardDiagnosticsService.ts b/electron/services/exportCardDiagnosticsService.ts new file mode 100644 index 0000000..37768a0 --- /dev/null +++ b/electron/services/exportCardDiagnosticsService.ts @@ -0,0 +1,354 @@ +import { mkdir, writeFile } from 'fs/promises' +import { basename, dirname, extname, join } from 'path' + +export type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker' +export type ExportCardDiagLevel = 'debug' | 'info' | 'warn' | 'error' +export type ExportCardDiagStatus = 'running' | 'done' | 'failed' | 'timeout' + +export interface ExportCardDiagLogEntry { + id: string + ts: number + source: ExportCardDiagSource + level: ExportCardDiagLevel + message: string + traceId?: string + stepId?: string + stepName?: string + status?: ExportCardDiagStatus + durationMs?: number + data?: Record +} + +interface ActiveStepState { + key: string + traceId: string + stepId: string + stepName: string + source: ExportCardDiagSource + startedAt: number + lastUpdatedAt: number + message?: string +} + +interface StepStartInput { + traceId: string + stepId: string + stepName: string + source: ExportCardDiagSource + level?: ExportCardDiagLevel + message?: string + data?: Record +} + +interface StepEndInput { + traceId: string + stepId: string + stepName: string + source: ExportCardDiagSource + status?: Extract + level?: ExportCardDiagLevel + message?: string + data?: Record + durationMs?: number +} + +interface LogInput { + ts?: number + source: ExportCardDiagSource + level?: ExportCardDiagLevel + message: string + traceId?: string + stepId?: string + stepName?: string + status?: ExportCardDiagStatus + durationMs?: number + data?: Record +} + +export interface ExportCardDiagSnapshot { + logs: ExportCardDiagLogEntry[] + activeSteps: Array<{ + traceId: string + stepId: string + stepName: string + source: ExportCardDiagSource + elapsedMs: number + stallMs: number + startedAt: number + lastUpdatedAt: number + message?: string + }> + summary: { + totalLogs: number + activeStepCount: number + errorCount: number + warnCount: number + timeoutCount: number + lastUpdatedAt: number + } +} + +export class ExportCardDiagnosticsService { + private readonly maxLogs = 6000 + private logs: ExportCardDiagLogEntry[] = [] + private activeSteps = new Map() + private seq = 0 + + private nextId(ts: number): string { + this.seq += 1 + return `export-card-diag-${ts}-${this.seq}` + } + + private trimLogs() { + if (this.logs.length <= this.maxLogs) return + const drop = this.logs.length - this.maxLogs + this.logs.splice(0, drop) + } + + log(input: LogInput): ExportCardDiagLogEntry { + const ts = Number.isFinite(input.ts) ? Math.max(0, Math.floor(input.ts as number)) : Date.now() + const entry: ExportCardDiagLogEntry = { + id: this.nextId(ts), + ts, + source: input.source, + level: input.level || 'info', + message: input.message, + traceId: input.traceId, + stepId: input.stepId, + stepName: input.stepName, + status: input.status, + durationMs: Number.isFinite(input.durationMs) ? Math.max(0, Math.floor(input.durationMs as number)) : undefined, + data: input.data + } + + this.logs.push(entry) + this.trimLogs() + + if (entry.traceId && entry.stepId && entry.stepName) { + const key = `${entry.traceId}::${entry.stepId}` + if (entry.status === 'running') { + const previous = this.activeSteps.get(key) + this.activeSteps.set(key, { + key, + traceId: entry.traceId, + stepId: entry.stepId, + stepName: entry.stepName, + source: entry.source, + startedAt: previous?.startedAt || entry.ts, + lastUpdatedAt: entry.ts, + message: entry.message + }) + } else if (entry.status === 'done' || entry.status === 'failed' || entry.status === 'timeout') { + this.activeSteps.delete(key) + } + } + + return entry + } + + stepStart(input: StepStartInput): ExportCardDiagLogEntry { + return this.log({ + source: input.source, + level: input.level || 'info', + message: input.message || `${input.stepName} 开始`, + traceId: input.traceId, + stepId: input.stepId, + stepName: input.stepName, + status: 'running', + data: input.data + }) + } + + stepEnd(input: StepEndInput): ExportCardDiagLogEntry { + return this.log({ + source: input.source, + level: input.level || (input.status === 'done' ? 'info' : 'warn'), + message: input.message || `${input.stepName} ${input.status === 'done' ? '完成' : '结束'}`, + traceId: input.traceId, + stepId: input.stepId, + stepName: input.stepName, + status: input.status || 'done', + durationMs: input.durationMs, + data: input.data + }) + } + + clear() { + this.logs = [] + this.activeSteps.clear() + } + + snapshot(limit = 1200): ExportCardDiagSnapshot { + const capped = Number.isFinite(limit) ? Math.max(100, Math.min(5000, Math.floor(limit))) : 1200 + const logs = this.logs.slice(-capped) + const now = Date.now() + + const activeSteps = Array.from(this.activeSteps.values()) + .map(step => ({ + traceId: step.traceId, + stepId: step.stepId, + stepName: step.stepName, + source: step.source, + startedAt: step.startedAt, + lastUpdatedAt: step.lastUpdatedAt, + elapsedMs: Math.max(0, now - step.startedAt), + stallMs: Math.max(0, now - step.lastUpdatedAt), + message: step.message + })) + .sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt) + + let errorCount = 0 + let warnCount = 0 + let timeoutCount = 0 + for (const item of logs) { + if (item.level === 'error') errorCount += 1 + if (item.level === 'warn') warnCount += 1 + if (item.status === 'timeout') timeoutCount += 1 + } + + return { + logs, + activeSteps, + summary: { + totalLogs: this.logs.length, + activeStepCount: activeSteps.length, + errorCount, + warnCount, + timeoutCount, + lastUpdatedAt: logs.length > 0 ? logs[logs.length - 1].ts : 0 + } + } + } + + private normalizeExternalLogs(value: unknown[]): ExportCardDiagLogEntry[] { + const result: ExportCardDiagLogEntry[] = [] + for (const item of value) { + if (!item || typeof item !== 'object') continue + const row = item as Record + const tsRaw = row.ts ?? row.timestamp + const tsNum = Number(tsRaw) + const ts = Number.isFinite(tsNum) && tsNum > 0 ? Math.floor(tsNum) : Date.now() + + const sourceRaw = String(row.source || 'frontend') + const source: ExportCardDiagSource = sourceRaw === 'main' || sourceRaw === 'backend' || sourceRaw === 'worker' + ? sourceRaw + : 'frontend' + const levelRaw = String(row.level || 'info') + const level: ExportCardDiagLevel = levelRaw === 'debug' || levelRaw === 'warn' || levelRaw === 'error' + ? levelRaw + : 'info' + + const statusRaw = String(row.status || '') + const status: ExportCardDiagStatus | undefined = statusRaw === 'running' || statusRaw === 'done' || statusRaw === 'failed' || statusRaw === 'timeout' + ? statusRaw + : undefined + + const durationRaw = Number(row.durationMs) + result.push({ + id: String(row.id || this.nextId(ts)), + ts, + source, + level, + message: String(row.message || ''), + traceId: typeof row.traceId === 'string' ? row.traceId : undefined, + stepId: typeof row.stepId === 'string' ? row.stepId : undefined, + stepName: typeof row.stepName === 'string' ? row.stepName : undefined, + status, + durationMs: Number.isFinite(durationRaw) ? Math.max(0, Math.floor(durationRaw)) : undefined, + data: row.data && typeof row.data === 'object' ? row.data as Record : undefined + }) + } + return result + } + + private serializeLogEntry(log: ExportCardDiagLogEntry): string { + return JSON.stringify(log) + } + + private buildSummaryText(logs: ExportCardDiagLogEntry[], activeSteps: ExportCardDiagSnapshot['activeSteps']): string { + const total = logs.length + let errorCount = 0 + let warnCount = 0 + let timeoutCount = 0 + let frontendCount = 0 + let backendCount = 0 + let mainCount = 0 + let workerCount = 0 + + for (const item of logs) { + if (item.level === 'error') errorCount += 1 + if (item.level === 'warn') warnCount += 1 + if (item.status === 'timeout') timeoutCount += 1 + if (item.source === 'frontend') frontendCount += 1 + if (item.source === 'backend') backendCount += 1 + if (item.source === 'main') mainCount += 1 + if (item.source === 'worker') workerCount += 1 + } + + const lines: string[] = [] + lines.push('WeFlow 导出卡片诊断摘要') + lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`) + lines.push(`日志总数: ${total}`) + lines.push(`来源统计: frontend=${frontendCount}, main=${mainCount}, backend=${backendCount}, worker=${workerCount}`) + lines.push(`级别统计: warn=${warnCount}, error=${errorCount}, timeout=${timeoutCount}`) + lines.push(`当前活跃步骤: ${activeSteps.length}`) + + if (activeSteps.length > 0) { + lines.push('') + lines.push('活跃步骤:') + for (const step of activeSteps.slice(0, 12)) { + lines.push(`- [${step.source}] ${step.stepName} trace=${step.traceId} elapsed=${step.elapsedMs}ms stall=${step.stallMs}ms`) + } + } + + const latestErrors = logs.filter(item => item.level === 'error' || item.status === 'failed' || item.status === 'timeout').slice(-12) + if (latestErrors.length > 0) { + lines.push('') + lines.push('最近异常:') + for (const item of latestErrors) { + lines.push(`- ${new Date(item.ts).toLocaleTimeString('zh-CN')} [${item.source}] ${item.stepName || item.stepId || 'unknown'} ${item.status || item.level} ${item.message}`) + } + } + + return lines.join('\n') + } + + async exportCombinedLogs(filePath: string, frontendLogs: unknown[] = []): Promise<{ + success: boolean + filePath?: string + summaryPath?: string + count?: number + error?: string + }> { + try { + const normalizedFrontend = this.normalizeExternalLogs(Array.isArray(frontendLogs) ? frontendLogs : []) + const merged = [...this.logs, ...normalizedFrontend] + .sort((a, b) => (a.ts - b.ts) || a.id.localeCompare(b.id)) + + const lines = merged.map(item => this.serializeLogEntry(item)).join('\n') + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, lines ? `${lines}\n` : '', 'utf8') + + const ext = extname(filePath) + const baseName = ext ? basename(filePath, ext) : basename(filePath) + const summaryPath = join(dirname(filePath), `${baseName}.txt`) + const snapshot = this.snapshot(1500) + const summaryText = this.buildSummaryText(merged, snapshot.activeSteps) + await writeFile(summaryPath, summaryText, 'utf8') + + return { + success: true, + filePath, + summaryPath, + count: merged.length + } + } catch (error) { + return { + success: false, + error: String(error) + } + } + } +} + +export const exportCardDiagnosticsService = new ExportCardDiagnosticsService() diff --git a/electron/services/exportContentStatsCacheService.ts b/electron/services/exportContentStatsCacheService.ts new file mode 100644 index 0000000..ee8fd5f --- /dev/null +++ b/electron/services/exportContentStatsCacheService.ts @@ -0,0 +1,229 @@ +import { join, dirname } from 'path' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { ConfigService } from './config' + +const CACHE_VERSION = 1 +const MAX_SCOPE_ENTRIES = 12 +const MAX_SESSION_ENTRIES_PER_SCOPE = 6000 + +export interface ExportContentSessionStatsEntry { + updatedAt: number + hasAny: boolean + hasVoice: boolean + hasImage: boolean + hasVideo: boolean + hasEmoji: boolean + mediaReady: boolean +} + +export interface ExportContentScopeStatsEntry { + updatedAt: number + sessions: Record +} + +interface ExportContentStatsStore { + version: number + scopes: Record +} + +function toNonNegativeInt(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined + return Math.max(0, Math.floor(value)) +} + +function toBoolean(value: unknown, fallback = false): boolean { + if (typeof value === 'boolean') return value + return fallback +} + +function normalizeSessionStatsEntry(raw: unknown): ExportContentSessionStatsEntry | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + const updatedAt = toNonNegativeInt(source.updatedAt) + if (updatedAt === undefined) return null + return { + updatedAt, + hasAny: toBoolean(source.hasAny, false), + hasVoice: toBoolean(source.hasVoice, false), + hasImage: toBoolean(source.hasImage, false), + hasVideo: toBoolean(source.hasVideo, false), + hasEmoji: toBoolean(source.hasEmoji, false), + mediaReady: toBoolean(source.mediaReady, false) + } +} + +function normalizeScopeStatsEntry(raw: unknown): ExportContentScopeStatsEntry | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + const updatedAt = toNonNegativeInt(source.updatedAt) + if (updatedAt === undefined) return null + + const sessionsRaw = source.sessions + if (!sessionsRaw || typeof sessionsRaw !== 'object') { + return { + updatedAt, + sessions: {} + } + } + + const sessions: Record = {} + for (const [sessionId, entryRaw] of Object.entries(sessionsRaw as Record)) { + const normalized = normalizeSessionStatsEntry(entryRaw) + if (!normalized) continue + sessions[sessionId] = normalized + } + + return { + updatedAt, + sessions + } +} + +function cloneScope(scope: ExportContentScopeStatsEntry): ExportContentScopeStatsEntry { + return { + updatedAt: scope.updatedAt, + sessions: Object.fromEntries( + Object.entries(scope.sessions).map(([sessionId, entry]) => [sessionId, { ...entry }]) + ) + } +} + +export class ExportContentStatsCacheService { + private readonly cacheFilePath: string + private store: ExportContentStatsStore = { + version: CACHE_VERSION, + scopes: {} + } + + constructor(cacheBasePath?: string) { + const basePath = cacheBasePath && cacheBasePath.trim().length > 0 + ? cacheBasePath + : ConfigService.getInstance().getCacheBasePath() + this.cacheFilePath = join(basePath, 'export-content-stats.json') + this.ensureCacheDir() + this.load() + } + + private ensureCacheDir(): void { + const dir = dirname(this.cacheFilePath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + } + + private load(): void { + if (!existsSync(this.cacheFilePath)) return + try { + const raw = readFileSync(this.cacheFilePath, 'utf8') + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object') { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + + const payload = parsed as Record + const scopesRaw = payload.scopes + if (!scopesRaw || typeof scopesRaw !== 'object') { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + + const scopes: Record = {} + for (const [scopeKey, scopeRaw] of Object.entries(scopesRaw as Record)) { + const normalizedScope = normalizeScopeStatsEntry(scopeRaw) + if (!normalizedScope) continue + scopes[scopeKey] = normalizedScope + } + + this.store = { + version: CACHE_VERSION, + scopes + } + } catch (error) { + console.error('ExportContentStatsCacheService: 载入缓存失败', error) + this.store = { version: CACHE_VERSION, scopes: {} } + } + } + + getScope(scopeKey: string): ExportContentScopeStatsEntry | undefined { + if (!scopeKey) return undefined + const rawScope = this.store.scopes[scopeKey] + if (!rawScope) return undefined + const normalizedScope = normalizeScopeStatsEntry(rawScope) + if (!normalizedScope) { + delete this.store.scopes[scopeKey] + this.persist() + return undefined + } + this.store.scopes[scopeKey] = normalizedScope + return cloneScope(normalizedScope) + } + + setScope(scopeKey: string, scope: ExportContentScopeStatsEntry): void { + if (!scopeKey) return + const normalized = normalizeScopeStatsEntry(scope) + if (!normalized) return + this.store.scopes[scopeKey] = normalized + this.trimScope(scopeKey) + this.trimScopes() + this.persist() + } + + deleteSession(scopeKey: string, sessionId: string): void { + if (!scopeKey || !sessionId) return + const scope = this.store.scopes[scopeKey] + if (!scope) return + if (!(sessionId in scope.sessions)) return + delete scope.sessions[sessionId] + if (Object.keys(scope.sessions).length === 0) { + delete this.store.scopes[scopeKey] + } else { + scope.updatedAt = Date.now() + } + this.persist() + } + + clearScope(scopeKey: string): void { + if (!scopeKey) return + if (!this.store.scopes[scopeKey]) return + delete this.store.scopes[scopeKey] + this.persist() + } + + clearAll(): void { + this.store = { version: CACHE_VERSION, scopes: {} } + try { + rmSync(this.cacheFilePath, { force: true }) + } catch (error) { + console.error('ExportContentStatsCacheService: 清理缓存失败', error) + } + } + + private trimScope(scopeKey: string): void { + const scope = this.store.scopes[scopeKey] + if (!scope) return + + const entries = Object.entries(scope.sessions) + if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return + + entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) + scope.sessions = Object.fromEntries(entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) + } + + private trimScopes(): void { + const scopeEntries = Object.entries(this.store.scopes) + if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return + + scopeEntries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) + this.store.scopes = Object.fromEntries(scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) + } + + private persist(): void { + try { + this.ensureCacheDir() + writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8') + } catch (error) { + console.error('ExportContentStatsCacheService: 持久化缓存失败', error) + } + } +} diff --git a/electron/services/exportHtml.css b/electron/services/exportHtml.css index 993e478..c6751fc 100644 --- a/electron/services/exportHtml.css +++ b/electron/services/exportHtml.css @@ -186,6 +186,33 @@ body { word-break: break-word; } +.quoted-message { + border-left: 3px solid rgba(79, 70, 229, 0.35); + background: rgba(79, 70, 229, 0.06); + border-radius: 12px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.message.sent .quoted-message { + background: rgba(37, 99, 235, 0.08); + border-left-color: rgba(37, 99, 235, 0.35); +} + +.quoted-sender { + font-size: 12px; + color: #374151; + font-weight: 600; +} + +.quoted-text { + font-size: 13px; + color: #4b5563; + word-break: break-word; +} + .message-link-card { color: #2563eb; text-decoration: underline; diff --git a/electron/services/exportHtmlStyles.ts b/electron/services/exportHtmlStyles.ts index 935eb49..96f4288 100644 --- a/electron/services/exportHtmlStyles.ts +++ b/electron/services/exportHtmlStyles.ts @@ -186,6 +186,33 @@ body { word-break: break-word; } +.quoted-message { + border-left: 3px solid rgba(79, 70, 229, 0.35); + background: rgba(79, 70, 229, 0.06); + border-radius: 12px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.message.sent .quoted-message { + background: rgba(37, 99, 235, 0.08); + border-left-color: rgba(37, 99, 235, 0.35); +} + +.quoted-sender { + font-size: 12px; + color: #374151; + font-weight: 600; +} + +.quoted-text { + font-size: 13px; + color: #4b5563; + word-break: break-word; +} + .message-link-card { color: #2563eb; text-decoration: underline; diff --git a/electron/services/exportRecordService.ts b/electron/services/exportRecordService.ts new file mode 100644 index 0000000..23c82a9 --- /dev/null +++ b/electron/services/exportRecordService.ts @@ -0,0 +1,95 @@ +import { app } from 'electron' +import fs from 'fs' +import path from 'path' + +export interface ExportRecord { + exportTime: number + format: string + messageCount: number + sourceLatestMessageTimestamp?: number + outputPath?: string +} + +type RecordStore = Record + +class ExportRecordService { + private filePath: string | null = null + private loaded = false + private store: RecordStore = {} + + private resolveFilePath(): string { + if (this.filePath) return this.filePath + const userDataPath = app.getPath('userData') + fs.mkdirSync(userDataPath, { recursive: true }) + this.filePath = path.join(userDataPath, 'weflow-export-records.json') + return this.filePath + } + + private ensureLoaded(): void { + if (this.loaded) return + this.loaded = true + const filePath = this.resolveFilePath() + try { + if (!fs.existsSync(filePath)) return + const raw = fs.readFileSync(filePath, 'utf-8') + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object') { + this.store = parsed as RecordStore + } + } catch { + this.store = {} + } + } + + private persist(): void { + try { + const filePath = this.resolveFilePath() + fs.writeFileSync(filePath, JSON.stringify(this.store), 'utf-8') + } catch { + // ignore persist errors to avoid blocking export flow + } + } + + getLatestRecord(sessionId: string, format: string): ExportRecord | null { + this.ensureLoaded() + const records = this.store[sessionId] + if (!records || records.length === 0) return null + for (let i = records.length - 1; i >= 0; i--) { + const record = records[i] + if (record && record.format === format) return record + } + return null + } + + saveRecord( + sessionId: string, + format: string, + messageCount: number, + extra?: { + sourceLatestMessageTimestamp?: number + outputPath?: string + } + ): void { + this.ensureLoaded() + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + if (!this.store[normalizedSessionId]) { + this.store[normalizedSessionId] = [] + } + const list = this.store[normalizedSessionId] + list.push({ + exportTime: Date.now(), + format, + messageCount, + sourceLatestMessageTimestamp: extra?.sourceLatestMessageTimestamp, + outputPath: extra?.outputPath + }) + // keep the latest 30 records per session + if (list.length > 30) { + this.store[normalizedSessionId] = list.slice(-30) + } + this.persist() + } +} + +export const exportRecordService = new ExportRecordService() diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index bfd50fe..e0f43f3 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -2,6 +2,7 @@ import * as path from 'path' import * as http from 'http' import * as https from 'https' +import crypto from 'crypto' import { fileURLToPath } from 'url' import ExcelJS from 'exceljs' import { getEmojiPath } from 'wechat-emojis' @@ -11,6 +12,7 @@ import { imageDecryptService } from './imageDecryptService' import { chatService } from './chatService' import { videoService } from './videoService' import { voiceTranscribeService } from './voiceTranscribeService' +import { exportRecordService } from './exportRecordService' import { EXPORT_HTML_STYLES } from './exportHtmlStyles' import { LRUCache } from '../utils/LRUCache.js' @@ -44,9 +46,25 @@ interface ChatLabMessage { timestamp: number type: number content: string | null + platformMessageId?: string + replyToMessageId?: string chatRecords?: any[] // 嵌套的聊天记录 } +interface ForwardChatRecordItem { + datatype: number + sourcename: string + sourcetime: string + sourceheadurl?: string + datadesc?: string + datatitle?: string + fileext?: string + datasize?: number + chatRecordTitle?: string + chatRecordDesc?: string + chatRecordList?: ForwardChatRecordItem[] +} + interface ChatLabExport { chatlab: ChatLabHeader meta: ChatLabMeta @@ -69,7 +87,8 @@ const MESSAGE_TYPE_MAP: Record = { } export interface ExportOptions { - format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' + format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' + contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' dateRange?: { start: number; end: number } | null senderUsername?: string fileNameSuffix?: string @@ -83,8 +102,10 @@ export interface ExportOptions { excelCompactColumns?: boolean txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' + sessionNameWithTypePrefix?: boolean displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' exportConcurrency?: number + imageDeepSearchOnMiss?: boolean } const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ @@ -104,14 +125,99 @@ interface MediaExportItem { posterDataUrl?: string } +interface ExportDisplayProfile { + wxid: string + nickname: string + remark: string + alias: string + groupNickname: string + displayName: string +} + +type MessageCollectMode = 'full' | 'text-fast' | 'media-fast' +type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' + export interface ExportProgress { current: number total: number currentSession: string + currentSessionId?: string phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' phaseProgress?: number phaseTotal?: number phaseLabel?: string + collectedMessages?: number + exportedMessages?: number + estimatedTotalMessages?: number + writtenFiles?: number + mediaDoneFiles?: number + mediaCacheHitFiles?: number + mediaCacheMissFiles?: number + mediaCacheFillFiles?: number + mediaDedupReuseFiles?: number + mediaBytesWritten?: number +} + +interface MediaExportTelemetry { + doneFiles: number + cacheHitFiles: number + cacheMissFiles: number + cacheFillFiles: number + dedupReuseFiles: number + bytesWritten: number +} + +interface MediaSourceResolution { + sourcePath: string + cacheHit: boolean + cachePath?: string + fileStat?: { size: number; mtimeMs: number } + dedupeKey?: string +} + +interface ExportTaskControl { + shouldPause?: () => boolean + shouldStop?: () => boolean +} + +interface ExportStatsResult { + totalMessages: number + voiceMessages: number + cachedVoiceCount: number + needTranscribeCount: number + mediaMessages: number + estimatedSeconds: number + sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> +} + +interface ExportStatsSessionSnapshot { + totalCount: number + voiceCount: number + imageCount: number + videoCount: number + emojiCount: number + cachedVoiceCount: number + lastTimestamp?: number +} + +interface ExportStatsCacheEntry { + createdAt: number + result: ExportStatsResult + sessions: Record +} + +interface ExportAggregatedSessionMetric { + totalMessages?: number + voiceMessages?: number + imageMessages?: number + videoMessages?: number + emojiMessages?: number + lastTimestamp?: number +} + +interface ExportAggregatedSessionStatsCacheEntry { + createdAt: number + data: Record } // 并发控制:限制同时执行的 Promise 数量 @@ -144,6 +250,30 @@ class ExportService { private contactCache: LRUCache private inlineEmojiCache: LRUCache private htmlStyleCache: string | null = null + private exportStatsCache = new Map() + private exportAggregatedSessionStatsCache = new Map() + private readonly exportStatsCacheTtlMs = 2 * 60 * 1000 + private readonly exportAggregatedSessionStatsCacheTtlMs = 60 * 1000 + private readonly exportStatsCacheMaxEntries = 16 + private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED' + private mediaFileCachePopulatePending = new Map>() + private mediaFileCacheReadyDirs = new Set() + private mediaExportTelemetry: MediaExportTelemetry | null = null + private mediaRunSourceDedupMap = new Map() + private mediaRunMissingImageKeys = new Set() + private mediaFileCacheCleanupPending: Promise | null = null + private mediaFileCacheLastCleanupAt = 0 + private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000 + private readonly mediaFileCacheMaxBytes = 6 * 1024 * 1024 * 1024 + private readonly mediaFileCacheMaxFiles = 120000 + private readonly mediaFileCacheTtlMs = 45 * 24 * 60 * 60 * 1000 + private emojiCaptionCache = new Map() + private emojiCaptionPending = new Map>() + private emojiMd5ByCdnCache = new Map() + private emojiMd5ByCdnPending = new Map>() + private emoticonDbPathCache: string | null = null + private emoticonDbPathCacheToken = '' + private readonly emojiCaptionLookupConcurrency = 8 constructor() { this.configService = new ConfigService() @@ -152,12 +282,665 @@ class ExportService { this.inlineEmojiCache = new LRUCache(100) // 最多缓存100个表情 } + private createStopError(): Error { + const error = new Error('导出任务已停止') + ;(error as Error & { code?: string }).code = this.STOP_ERROR_CODE + return error + } + + private normalizeSessionIds(sessionIds: string[]): string[] { + return Array.from( + new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)) + ) + } + + private getExportStatsDateRangeToken(dateRange?: { start: number; end: number } | null): string { + if (!dateRange) return 'all' + const start = Number.isFinite(dateRange.start) ? Math.max(0, Math.floor(dateRange.start)) : 0 + const end = Number.isFinite(dateRange.end) ? Math.max(0, Math.floor(dateRange.end)) : 0 + return `${start}-${end}` + } + + private buildExportStatsCacheKey( + sessionIds: string[], + options: Pick, + cleanedWxid?: string + ): string { + const normalizedIds = this.normalizeSessionIds(sessionIds).sort() + const senderToken = String(options.senderUsername || '').trim() + const dateToken = this.getExportStatsDateRangeToken(options.dateRange) + const dbPath = String(this.configService.get('dbPath') || '').trim() + const wxidToken = String(cleanedWxid || this.cleanAccountDirName(String(this.configService.get('myWxid') || '')) || '').trim() + return `${dbPath}::${wxidToken}::${dateToken}::${senderToken}::${normalizedIds.join('\u001f')}` + } + + private cloneExportStatsResult(result: ExportStatsResult): ExportStatsResult { + return { + ...result, + sessions: result.sessions.map((item) => ({ ...item })) + } + } + + private pruneExportStatsCaches(): void { + const now = Date.now() + for (const [key, entry] of this.exportStatsCache.entries()) { + if (now - entry.createdAt > this.exportStatsCacheTtlMs) { + this.exportStatsCache.delete(key) + } + } + for (const [key, entry] of this.exportAggregatedSessionStatsCache.entries()) { + if (now - entry.createdAt > this.exportAggregatedSessionStatsCacheTtlMs) { + this.exportAggregatedSessionStatsCache.delete(key) + } + } + } + + private getExportStatsCacheEntry(key: string): ExportStatsCacheEntry | null { + this.pruneExportStatsCaches() + const entry = this.exportStatsCache.get(key) + if (!entry) return null + if (Date.now() - entry.createdAt > this.exportStatsCacheTtlMs) { + this.exportStatsCache.delete(key) + return null + } + return entry + } + + private setExportStatsCacheEntry(key: string, entry: ExportStatsCacheEntry): void { + this.pruneExportStatsCaches() + this.exportStatsCache.set(key, entry) + if (this.exportStatsCache.size <= this.exportStatsCacheMaxEntries) return + const staleKeys = Array.from(this.exportStatsCache.entries()) + .sort((a, b) => a[1].createdAt - b[1].createdAt) + .slice(0, Math.max(0, this.exportStatsCache.size - this.exportStatsCacheMaxEntries)) + .map(([cacheKey]) => cacheKey) + for (const staleKey of staleKeys) { + this.exportStatsCache.delete(staleKey) + } + } + + private getAggregatedSessionStatsCache(key: string): Record | null { + this.pruneExportStatsCaches() + const entry = this.exportAggregatedSessionStatsCache.get(key) + if (!entry) return null + if (Date.now() - entry.createdAt > this.exportAggregatedSessionStatsCacheTtlMs) { + this.exportAggregatedSessionStatsCache.delete(key) + return null + } + return entry.data + } + + private setAggregatedSessionStatsCache( + key: string, + data: Record + ): void { + this.pruneExportStatsCaches() + this.exportAggregatedSessionStatsCache.set(key, { + createdAt: Date.now(), + data + }) + if (this.exportAggregatedSessionStatsCache.size <= this.exportStatsCacheMaxEntries) return + const staleKeys = Array.from(this.exportAggregatedSessionStatsCache.entries()) + .sort((a, b) => a[1].createdAt - b[1].createdAt) + .slice(0, Math.max(0, this.exportAggregatedSessionStatsCache.size - this.exportStatsCacheMaxEntries)) + .map(([cacheKey]) => cacheKey) + for (const staleKey of staleKeys) { + this.exportAggregatedSessionStatsCache.delete(staleKey) + } + } + + private isStopError(error: unknown): boolean { + if (!error) return false + if (typeof error === 'string') { + return error.includes(this.STOP_ERROR_CODE) || error.includes('导出任务已停止') + } + if (error instanceof Error) { + const code = (error as Error & { code?: string }).code + return code === this.STOP_ERROR_CODE || error.message.includes(this.STOP_ERROR_CODE) || error.message.includes('导出任务已停止') + } + return false + } + + private throwIfStopRequested(control?: ExportTaskControl): void { + if (control?.shouldStop?.()) { + throw this.createStopError() + } + } + private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number { if (typeof value !== 'number' || !Number.isFinite(value)) return fallback const raw = Math.floor(value) return Math.max(1, Math.min(raw, max)) } + private createProgressEmitter(onProgress?: (progress: ExportProgress) => void): { + emit: (progress: ExportProgress, options?: { force?: boolean }) => void + flush: () => void + } { + if (!onProgress) { + return { + emit: () => { /* noop */ }, + flush: () => { /* noop */ } + } + } + + let pending: ExportProgress | null = null + let lastSentAt = 0 + let lastPhase = '' + let lastSessionId = '' + let lastCollected = 0 + let lastExported = 0 + + const commit = (progress: ExportProgress) => { + onProgress(progress) + pending = null + lastSentAt = Date.now() + lastPhase = String(progress.phase || '') + lastSessionId = String(progress.currentSessionId || '') + lastCollected = Number.isFinite(progress.collectedMessages) ? Math.max(0, Math.floor(progress.collectedMessages || 0)) : lastCollected + lastExported = Number.isFinite(progress.exportedMessages) ? Math.max(0, Math.floor(progress.exportedMessages || 0)) : lastExported + } + + const emit = (progress: ExportProgress, options?: { force?: boolean }) => { + pending = progress + const force = options?.force === true + const now = Date.now() + const phase = String(progress.phase || '') + const sessionId = String(progress.currentSessionId || '') + const collected = Number.isFinite(progress.collectedMessages) ? Math.max(0, Math.floor(progress.collectedMessages || 0)) : lastCollected + const exported = Number.isFinite(progress.exportedMessages) ? Math.max(0, Math.floor(progress.exportedMessages || 0)) : lastExported + const collectedDelta = Math.abs(collected - lastCollected) + const exportedDelta = Math.abs(exported - lastExported) + const shouldEmit = force || + phase !== lastPhase || + sessionId !== lastSessionId || + collectedDelta >= 200 || + exportedDelta >= 200 || + (now - lastSentAt >= 120) + + if (shouldEmit && pending) { + commit(pending) + } + } + + const flush = () => { + if (!pending) return + commit(pending) + } + + return { emit, flush } + } + + private async pathExists(filePath: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.F_OK) + return true + } catch { + return false + } + } + + private isCloneUnsupportedError(code: string | undefined): boolean { + return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY' + } + + private async copyFileOptimized(sourcePath: string, destPath: string): Promise<{ success: boolean; code?: string }> { + const cloneFlag = typeof fs.constants.COPYFILE_FICLONE === 'number' ? fs.constants.COPYFILE_FICLONE : 0 + try { + if (cloneFlag) { + await fs.promises.copyFile(sourcePath, destPath, cloneFlag) + } else { + await fs.promises.copyFile(sourcePath, destPath) + } + return { success: true } + } catch (e) { + const code = (e as NodeJS.ErrnoException | undefined)?.code + if (!this.isCloneUnsupportedError(code)) { + return { success: false, code } + } + } + + try { + await fs.promises.copyFile(sourcePath, destPath) + return { success: true } + } catch (e) { + return { success: false, code: (e as NodeJS.ErrnoException | undefined)?.code } + } + } + + private getMediaFileCacheRoot(): string { + return path.join(this.configService.getCacheBasePath(), 'export-media-files') + } + + private createEmptyMediaTelemetry(): MediaExportTelemetry { + return { + doneFiles: 0, + cacheHitFiles: 0, + cacheMissFiles: 0, + cacheFillFiles: 0, + dedupReuseFiles: 0, + bytesWritten: 0 + } + } + + private resetMediaRuntimeState(): void { + this.mediaExportTelemetry = this.createEmptyMediaTelemetry() + this.mediaRunSourceDedupMap.clear() + this.mediaRunMissingImageKeys.clear() + } + + private clearMediaRuntimeState(): void { + this.mediaExportTelemetry = null + this.mediaRunSourceDedupMap.clear() + this.mediaRunMissingImageKeys.clear() + } + + private getMediaTelemetrySnapshot(): Partial { + const stats = this.mediaExportTelemetry + if (!stats) return {} + return { + mediaDoneFiles: stats.doneFiles, + mediaCacheHitFiles: stats.cacheHitFiles, + mediaCacheMissFiles: stats.cacheMissFiles, + mediaCacheFillFiles: stats.cacheFillFiles, + mediaDedupReuseFiles: stats.dedupReuseFiles, + mediaBytesWritten: stats.bytesWritten + } + } + + private noteMediaTelemetry(delta: Partial): void { + if (!this.mediaExportTelemetry) return + if (Number.isFinite(delta.doneFiles)) { + this.mediaExportTelemetry.doneFiles += Math.max(0, Math.floor(Number(delta.doneFiles || 0))) + } + if (Number.isFinite(delta.cacheHitFiles)) { + this.mediaExportTelemetry.cacheHitFiles += Math.max(0, Math.floor(Number(delta.cacheHitFiles || 0))) + } + if (Number.isFinite(delta.cacheMissFiles)) { + this.mediaExportTelemetry.cacheMissFiles += Math.max(0, Math.floor(Number(delta.cacheMissFiles || 0))) + } + if (Number.isFinite(delta.cacheFillFiles)) { + this.mediaExportTelemetry.cacheFillFiles += Math.max(0, Math.floor(Number(delta.cacheFillFiles || 0))) + } + if (Number.isFinite(delta.dedupReuseFiles)) { + this.mediaExportTelemetry.dedupReuseFiles += Math.max(0, Math.floor(Number(delta.dedupReuseFiles || 0))) + } + if (Number.isFinite(delta.bytesWritten)) { + this.mediaExportTelemetry.bytesWritten += Math.max(0, Math.floor(Number(delta.bytesWritten || 0))) + } + } + + private async ensureMediaFileCacheDir(dirPath: string): Promise { + if (this.mediaFileCacheReadyDirs.has(dirPath)) return + await fs.promises.mkdir(dirPath, { recursive: true }) + this.mediaFileCacheReadyDirs.add(dirPath) + } + + private async getMediaFileStat(sourcePath: string): Promise<{ size: number; mtimeMs: number } | null> { + try { + const stat = await fs.promises.stat(sourcePath) + if (!stat.isFile()) return null + return { + size: Number.isFinite(stat.size) ? Math.max(0, Math.floor(stat.size)) : 0, + mtimeMs: Number.isFinite(stat.mtimeMs) ? Math.max(0, Math.floor(stat.mtimeMs)) : 0 + } + } catch { + return null + } + } + + private buildMediaFileCachePath( + kind: 'image' | 'video' | 'emoji', + sourcePath: string, + fileStat: { size: number; mtimeMs: number } + ): string { + const normalizedSource = path.resolve(sourcePath) + const rawKey = `${kind}\u001f${normalizedSource}\u001f${fileStat.size}\u001f${fileStat.mtimeMs}` + const digest = crypto.createHash('sha1').update(rawKey).digest('hex') + const ext = path.extname(normalizedSource) || '' + return path.join(this.getMediaFileCacheRoot(), kind, digest.slice(0, 2), `${digest}${ext}`) + } + + private async resolveMediaFileCachePath( + kind: 'image' | 'video' | 'emoji', + sourcePath: string + ): Promise<{ cachePath: string; fileStat: { size: number; mtimeMs: number } } | null> { + const fileStat = await this.getMediaFileStat(sourcePath) + if (!fileStat) return null + const cachePath = this.buildMediaFileCachePath(kind, sourcePath, fileStat) + return { cachePath, fileStat } + } + + private async populateMediaFileCache( + kind: 'image' | 'video' | 'emoji', + sourcePath: string + ): Promise { + const resolved = await this.resolveMediaFileCachePath(kind, sourcePath) + if (!resolved) return null + const { cachePath } = resolved + if (await this.pathExists(cachePath)) return cachePath + + const pending = this.mediaFileCachePopulatePending.get(cachePath) + if (pending) return pending + + const task = (async () => { + try { + await this.ensureMediaFileCacheDir(path.dirname(cachePath)) + if (await this.pathExists(cachePath)) return cachePath + + const tempPath = `${cachePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` + const copied = await this.copyFileOptimized(sourcePath, tempPath) + if (!copied.success) { + await fs.promises.rm(tempPath, { force: true }).catch(() => { }) + return null + } + await fs.promises.rename(tempPath, cachePath).catch(async (error) => { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code === 'EEXIST') { + await fs.promises.rm(tempPath, { force: true }).catch(() => { }) + return + } + await fs.promises.rm(tempPath, { force: true }).catch(() => { }) + throw error + }) + this.noteMediaTelemetry({ cacheFillFiles: 1 }) + return cachePath + } catch { + return null + } finally { + this.mediaFileCachePopulatePending.delete(cachePath) + } + })() + + this.mediaFileCachePopulatePending.set(cachePath, task) + return task + } + + private async resolvePreferredMediaSource( + kind: 'image' | 'video' | 'emoji', + sourcePath: string + ): Promise { + const resolved = await this.resolveMediaFileCachePath(kind, sourcePath) + if (!resolved) { + return { + sourcePath, + cacheHit: false + } + } + const dedupeKey = `${kind}\u001f${resolved.cachePath}` + if (await this.pathExists(resolved.cachePath)) { + return { + sourcePath: resolved.cachePath, + cacheHit: true, + cachePath: resolved.cachePath, + fileStat: resolved.fileStat, + dedupeKey + } + } + // 未命中缓存时异步回填,不阻塞当前导出路径 + void this.populateMediaFileCache(kind, sourcePath) + return { + sourcePath, + cacheHit: false, + cachePath: resolved.cachePath, + fileStat: resolved.fileStat, + dedupeKey + } + } + + private isHardlinkFallbackError(code: string | undefined): boolean { + return code === 'EXDEV' || code === 'EPERM' || code === 'EACCES' || code === 'EINVAL' || code === 'ENOSYS' || code === 'ENOTSUP' + } + + private async hardlinkOrCopyFile(sourcePath: string, destPath: string): Promise<{ success: boolean; code?: string; linked?: boolean }> { + try { + await fs.promises.link(sourcePath, destPath) + return { success: true, linked: true } + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code === 'EEXIST') { + return { success: true, linked: true } + } + if (!this.isHardlinkFallbackError(code)) { + return { success: false, code } + } + } + + const copied = await this.copyFileOptimized(sourcePath, destPath) + if (!copied.success) return copied + return { success: true, linked: false } + } + + private async copyMediaWithCacheAndDedup( + kind: 'image' | 'video' | 'emoji', + sourcePath: string, + destPath: string + ): Promise<{ success: boolean; code?: string }> { + const resolved = await this.resolvePreferredMediaSource(kind, sourcePath) + if (resolved.cacheHit) { + this.noteMediaTelemetry({ cacheHitFiles: 1 }) + } else { + this.noteMediaTelemetry({ cacheMissFiles: 1 }) + } + + const dedupeKey = resolved.dedupeKey + if (dedupeKey) { + const reusedPath = this.mediaRunSourceDedupMap.get(dedupeKey) + if (reusedPath && reusedPath !== destPath && await this.pathExists(reusedPath)) { + const reused = await this.hardlinkOrCopyFile(reusedPath, destPath) + if (!reused.success) return reused + this.noteMediaTelemetry({ + doneFiles: 1, + dedupReuseFiles: 1, + bytesWritten: resolved.fileStat?.size || 0 + }) + return { success: true } + } + } + + const copied = resolved.cacheHit + ? await this.hardlinkOrCopyFile(resolved.sourcePath, destPath) + : await this.copyFileOptimized(resolved.sourcePath, destPath) + if (!copied.success) return copied + + if (dedupeKey) { + this.mediaRunSourceDedupMap.set(dedupeKey, destPath) + } + this.noteMediaTelemetry({ + doneFiles: 1, + bytesWritten: resolved.fileStat?.size || 0 + }) + return { success: true } + } + + private triggerMediaFileCacheCleanup(force = false): void { + const now = Date.now() + if (!force && now - this.mediaFileCacheLastCleanupAt < this.mediaFileCacheCleanupIntervalMs) return + if (this.mediaFileCacheCleanupPending) return + this.mediaFileCacheLastCleanupAt = now + + this.mediaFileCacheCleanupPending = this.cleanupMediaFileCache().finally(() => { + this.mediaFileCacheCleanupPending = null + }) + } + + private async cleanupMediaFileCache(): Promise { + const root = this.getMediaFileCacheRoot() + if (!await this.pathExists(root)) return + const now = Date.now() + const files: Array<{ filePath: string; size: number; mtimeMs: number }> = [] + const dirs: string[] = [] + + const stack = [root] + while (stack.length > 0) { + const current = stack.pop() as string + dirs.push(current) + let entries: fs.Dirent[] + try { + entries = await fs.promises.readdir(current, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + const entryPath = path.join(current, entry.name) + if (entry.isDirectory()) { + stack.push(entryPath) + continue + } + if (!entry.isFile()) continue + try { + const stat = await fs.promises.stat(entryPath) + if (!stat.isFile()) continue + files.push({ + filePath: entryPath, + size: Number.isFinite(stat.size) ? Math.max(0, Math.floor(stat.size)) : 0, + mtimeMs: Number.isFinite(stat.mtimeMs) ? Math.max(0, Math.floor(stat.mtimeMs)) : 0 + }) + } catch { } + } + } + + if (files.length === 0) return + + let totalBytes = files.reduce((sum, item) => sum + item.size, 0) + let totalFiles = files.length + const ttlThreshold = now - this.mediaFileCacheTtlMs + const removalSet = new Set() + + for (const item of files) { + if (item.mtimeMs > 0 && item.mtimeMs < ttlThreshold) { + removalSet.add(item.filePath) + totalBytes -= item.size + totalFiles -= 1 + } + } + + if (totalBytes > this.mediaFileCacheMaxBytes || totalFiles > this.mediaFileCacheMaxFiles) { + const ordered = files + .filter((item) => !removalSet.has(item.filePath)) + .sort((a, b) => a.mtimeMs - b.mtimeMs) + for (const item of ordered) { + if (totalBytes <= this.mediaFileCacheMaxBytes && totalFiles <= this.mediaFileCacheMaxFiles) break + removalSet.add(item.filePath) + totalBytes -= item.size + totalFiles -= 1 + } + } + + if (removalSet.size === 0) return + + for (const filePath of removalSet) { + await fs.promises.rm(filePath, { force: true }).catch(() => { }) + } + + dirs.sort((a, b) => b.length - a.length) + for (const dirPath of dirs) { + if (dirPath === root) continue + await fs.promises.rmdir(dirPath).catch(() => { }) + } + } + + private isMediaExportEnabled(options: ExportOptions): boolean { + return options.exportMedia === true && + Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + } + + private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean { + if (!dateRange) return true + const start = Number.isFinite(dateRange.start) ? dateRange.start : 0 + const end = Number.isFinite(dateRange.end) ? dateRange.end : 0 + return start <= 0 && end <= 0 + } + + private shouldUseFastTextCollection(options: ExportOptions): boolean { + // 文本批量导出优先走轻量采集:不做媒体字段预提取,减少 CPU 与内存占用 + return !this.isMediaExportEnabled(options) + } + + private getMediaContentType(options: ExportOptions): MediaContentType | null { + const value = options.contentType + if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji') { + return value + } + return null + } + + private isMediaContentBatchExport(options: ExportOptions): boolean { + return this.getMediaContentType(options) !== null + } + + private getTargetMediaLocalTypes(options: ExportOptions): Set { + const mediaContentType = this.getMediaContentType(options) + if (mediaContentType === 'voice') return new Set([34]) + if (mediaContentType === 'image') return new Set([3]) + if (mediaContentType === 'video') return new Set([43]) + if (mediaContentType === 'emoji') return new Set([47]) + + const selected = new Set() + if (options.exportImages) selected.add(3) + if (options.exportVoices) selected.add(34) + if (options.exportVideos) selected.add(43) + if (options.exportEmojis) selected.add(47) + return selected + } + + private resolveCollectMode(options: ExportOptions): MessageCollectMode { + if (this.isMediaContentBatchExport(options)) { + return 'media-fast' + } + return this.shouldUseFastTextCollection(options) ? 'text-fast' : 'full' + } + + private resolveCollectParams(options: ExportOptions): { mode: MessageCollectMode; targetMediaTypes?: Set } { + const mode = this.resolveCollectMode(options) + if (mode === 'media-fast') { + const targetMediaTypes = this.getTargetMediaLocalTypes(options) + if (targetMediaTypes.size > 0) { + return { mode, targetMediaTypes } + } + } + return { mode } + } + + private createCollectProgressReporter( + sessionName: string, + onProgress?: (progress: ExportProgress) => void, + progressCurrent = 5 + ): ((payload: { fetched: number }) => void) | undefined { + if (!onProgress) return undefined + let lastReportAt = 0 + return ({ fetched }) => { + const now = Date.now() + if (now - lastReportAt < 350) return + lastReportAt = now + onProgress({ + current: progressCurrent, + total: 100, + currentSession: sessionName, + phase: 'preparing', + phaseLabel: `收集消息 ${fetched.toLocaleString()} 条`, + collectedMessages: fetched + }) + } + } + + private shouldDecodeMessageContentInFastMode(localType: number): boolean { + // 这些类型在文本导出里只需要占位符,无需解码完整 XML / 压缩内容 + if (localType === 3 || localType === 34 || localType === 42 || localType === 43) { + return false + } + return true + } + + private shouldDecodeMessageContentInMediaMode(localType: number, targetMediaTypes: Set | null): boolean { + if (!targetMediaTypes || !targetMediaTypes.has(localType)) return false + // 语音导出仅需要 localId 读取音频数据,不依赖 XML 内容 + if (localType === 34) return false + // 图片/视频/表情可能需要从 XML 提取 md5/datName/cdnUrl + if (localType === 3 || localType === 43 || localType === 47) return true + return false + } + private cleanAccountDirName(dirName: string): string { const trimmed = dirName.trim() if (!trimmed) return trimmed @@ -172,6 +955,357 @@ class ExportService { return cleaned } + private getIntFromRow(row: Record, keys: string[], fallback = 0): number { + for (const key of keys) { + const raw = row?.[key] + if (raw === undefined || raw === null || raw === '') continue + const parsed = Number.parseInt(String(raw), 10) + if (Number.isFinite(parsed)) return parsed + } + return fallback + } + + private getRowField(row: Record, keys: string[]): any { + for (const key of keys) { + if (row && Object.prototype.hasOwnProperty.call(row, key)) { + const value = row[key] + if (value !== undefined && value !== null && value !== '') { + return value + } + } + } + return undefined + } + + private normalizeUnsignedIntToken(value: unknown): string { + const raw = String(value ?? '').trim() + if (!raw) return '0' + if (/^\d+$/.test(raw)) { + return raw.replace(/^0+(?=\d)/, '') + } + const num = Number(raw) + if (!Number.isFinite(num) || num <= 0) return '0' + return String(Math.floor(num)) + } + + private getStableMessageKey(msg: { localId?: unknown; createTime?: unknown; serverId?: unknown; serverIdRaw?: unknown }): string { + const localId = this.normalizeUnsignedIntToken(msg?.localId) + const createTime = this.normalizeUnsignedIntToken(msg?.createTime) + const serverId = this.normalizeUnsignedIntToken(msg?.serverIdRaw ?? msg?.serverId) + return `${localId}:${createTime}:${serverId}` + } + + private getMediaCacheKey(msg: { localType?: unknown; localId?: unknown; createTime?: unknown; serverId?: unknown; serverIdRaw?: unknown }): string { + const localType = this.normalizeUnsignedIntToken(msg?.localType) + 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(/&/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 { + 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, '&') + ])) + + 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 { + 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 => { + 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 { + 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 => { + 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 { + 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() + + const uniqueMd5s = new Set() + 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 }> { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') @@ -204,6 +1338,41 @@ class ExportService { return info } + private resolveSessionFilePrefix(sessionId: string, contact?: any): string { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return '私聊_' + if (normalizedSessionId.endsWith('@chatroom')) return '群聊_' + if (normalizedSessionId.startsWith('gh_')) return '公众号_' + + const rawLocalType = contact?.local_type ?? contact?.localType ?? contact?.WCDB_CT_local_type + const localType = Number.parseInt(String(rawLocalType ?? ''), 10) + const quanPin = String(contact?.quan_pin ?? contact?.quanPin ?? contact?.WCDB_CT_quan_pin ?? '').trim() + + if (Number.isFinite(localType) && localType === 0 && quanPin) { + return '曾经的好友_' + } + + return '私聊_' + } + + private async getSessionFilePrefix(sessionId: string): Promise { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return '私聊_' + if (normalizedSessionId.endsWith('@chatroom')) return '群聊_' + if (normalizedSessionId.startsWith('gh_')) return '公众号_' + + try { + const contactResult = await wcdbService.getContact(normalizedSessionId) + if (contactResult.success && contactResult.contact) { + return this.resolveSessionFilePrefix(normalizedSessionId, contactResult.contact) + } + } catch { + // ignore and use default private prefix + } + + return '私聊_' + } + private async preloadContacts( usernames: Iterable, cache: Map, @@ -218,24 +1387,65 @@ class ExportService { }) } + private async preloadContactInfos( + usernames: Iterable, + limit = 8 + ): Promise> { + const infoMap = new Map() + const unique = Array.from(new Set(Array.from(usernames).filter(Boolean))) + if (unique.length === 0) return infoMap + + await parallelLimit(unique, limit, async (username) => { + const info = await this.getContactInfo(username) + infoMap.set(username, info) + }) + + return infoMap + } + /** - * 通过 contact.chat_room.ext_buffer 解析群昵称(纯 SQL) + * 获取群成员群昵称。优先使用 DLL,必要时回退到 `contact.chat_room.ext_buffer` 解析。 */ async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise> { - try { - // 使用参数化查询防止SQL注入 - const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1' - const result = await wcdbService.execQuery('contact', null, sql, [chatroomId]) - if (!result.success || !result.rows || result.rows.length === 0) { - return new Map() - } + const nicknameMap = new Map() - const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer) - if (!extBuffer) return new Map() - return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates) + try { + const dllResult = await wcdbService.getGroupNicknames(chatroomId) + if (dllResult.success && dllResult.nicknames) { + this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames)) + } + } catch (e) { + console.error('getGroupNicknamesForRoom dll error:', e) + } + + try { + const result = await wcdbService.getChatRoomExtBuffer(chatroomId) + if (!result.success || !result.extBuffer) { + return nicknameMap + } + const extBuffer = this.decodeExtBuffer(result.extBuffer) + if (!extBuffer) return nicknameMap + this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries()) + return nicknameMap } catch (e) { console.error('getGroupNicknamesForRoom error:', e) - return new Map() + return nicknameMap + } + } + + private mergeGroupNicknameEntries( + target: Map, + entries: Iterable<[string, string]> + ): void { + for (const [memberIdRaw, nicknameRaw] of entries) { + const nickname = this.normalizeGroupNickname(nicknameRaw || '') + if (!nickname) continue + for (const alias of this.buildGroupNicknameIdCandidates([memberIdRaw])) { + if (!alias) continue + if (!target.has(alias)) target.set(alias, nickname) + const lower = alias.toLowerCase() + if (!target.has(lower)) target.set(lower, nickname) + } } } @@ -366,12 +1576,13 @@ class ExportService { * 转换微信消息类型到 ChatLab 类型 */ private convertMessageType(localType: number, content: string): number { - // 检查 XML 中的 type 标签(支持大 localType 的情况) - const xmlTypeMatch = /(\d+)<\/type>/i.exec(content) - const xmlType = xmlTypeMatch ? parseInt(xmlTypeMatch[1]) : null + const normalized = this.normalizeAppMessageContent(content || '') + const xmlTypeRaw = this.extractAppMessageType(normalized) + const xmlType = xmlTypeRaw ? Number.parseInt(xmlTypeRaw, 10) : null + const looksLikeAppMessage = localType === 49 || normalized.includes('') // 特殊处理 type 49 或 XML type - if (localType === 49 || xmlType) { + if (looksLikeAppMessage || xmlType) { const subType = xmlType || 0 switch (subType) { case 6: return 4 // 文件 -> FILE @@ -383,7 +1594,7 @@ class ExportService { case 5: case 49: return 7 // 链接 -> LINK default: - if (xmlType) return 7 // 有 XML type 但未知,默认为链接 + if (xmlType || looksLikeAppMessage) return 7 // 有 appmsg 但未知,默认为链接 } } return MESSAGE_TYPE_MAP[localType] ?? 99 // 未知类型 -> OTHER @@ -526,6 +1737,50 @@ class ExportService { } } + private async resolveExportDisplayProfile( + wxid: string, + preference: ExportOptions['displayNamePreference'], + getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }>, + groupNicknamesMap: Map, + fallbackDisplayName = '', + extraGroupNicknameCandidates: Array = [] + ): Promise { + const resolvedWxid = String(wxid || '').trim() || String(fallbackDisplayName || '').trim() || 'unknown' + const contactResult = resolvedWxid ? await getContact(resolvedWxid) : { success: false as const } + const contact = contactResult.success ? contactResult.contact : null + const nickname = String(contact?.nickName || contact?.nick_name || fallbackDisplayName || resolvedWxid) + const remark = String(contact?.remark || '') + const alias = String(contact?.alias || '') + const groupNickname = this.resolveGroupNicknameByCandidates( + groupNicknamesMap, + [ + resolvedWxid, + contact?.username, + contact?.userName, + contact?.encryptUsername, + contact?.encryptUserName, + alias, + ...extraGroupNicknameCandidates + ] + ) || '' + const displayName = this.getPreferredDisplayName( + resolvedWxid, + nickname, + remark, + groupNickname, + preference || 'remark' + ) + + return { + wxid: resolvedWxid, + nickname, + remark, + alias, + groupNickname, + displayName + } + } + /** * 从转账消息 XML 中提取并解析 "谁转账给谁" 描述 * @param content 原始消息内容 XML @@ -636,13 +1891,16 @@ class ExportService { createTime?: number, myWxid?: string, senderWxid?: string, - isSend?: boolean + isSend?: boolean, + emojiCaption?: string ): string | null { + if (!content && localType === 47) { + return this.formatEmojiSemanticText(emojiCaption) + } if (!content) return null - // 检查 XML 中的 type 标签(支持大 localType 的情况) - const xmlTypeMatch = /(\d+)<\/type>/i.exec(content) - const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null + const normalizedContent = this.normalizeAppMessageContent(content) + const xmlType = this.extractAppMessageType(normalizedContent) switch (localType) { case 1: // 文本 @@ -664,7 +1922,7 @@ class ExportService { } case 42: return '[名片]' case 43: return '[视频]' - case 47: return '[动画表情]' + case 47: return this.formatEmojiSemanticText(emojiCaption) case 48: { const normalized48 = this.normalizeAppMessageContent(content) const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName') @@ -678,24 +1936,32 @@ class ExportService { return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]' } case 49: { - const title = this.extractXmlValue(content, 'title') - const type = this.extractXmlValue(content, 'type') + const title = this.extractXmlValue(normalizedContent, 'title') + const type = this.extractAppMessageType(normalizedContent) + const songName = this.extractXmlValue(normalizedContent, 'songname') // 转账消息特殊处理 if (type === '2000') { - const feedesc = this.extractXmlValue(content, 'feedesc') - const payMemo = this.extractXmlValue(content, 'pay_memo') - const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend) + const feedesc = this.extractXmlValue(normalizedContent, 'feedesc') + const payMemo = this.extractXmlValue(normalizedContent, 'pay_memo') + const transferPrefix = this.getTransferPrefix(normalizedContent, myWxid, senderWxid, isSend) if (feedesc) { return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}` } return transferPrefix } + if (type === '3') return songName ? `[音乐] ${songName}` : (title ? `[音乐] ${title}` : '[音乐]') if (type === '6') return title ? `[文件] ${title}` : '[文件]' - if (type === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]' + if (type === '19') return this.formatForwardChatRecordContent(normalizedContent) if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]' - if (type === '57') return title || '[引用消息]' + if (type === '57') { + const quoteDisplay = this.extractQuotedReplyDisplay(content) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } + return title || '[引用消息]' + } if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]' return title ? `[链接] ${title}` : '[链接]' } @@ -704,6 +1970,10 @@ class ExportService { case 266287972401: return this.cleanSystemMessage(content) // 拍一拍 case 244813135921: { // 引用消息 + const quoteDisplay = this.extractQuotedReplyDisplay(content) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } const title = this.extractXmlValue(content, 'title') return title || '[引用消息]' } @@ -733,10 +2003,17 @@ class ExportService { } // 其他类型 + if (xmlType === '3') return title ? `[音乐] ${title}` : '[音乐]' if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]' - if (xmlType === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]' + if (xmlType === '19') return this.formatForwardChatRecordContent(normalizedContent) if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]' - if (xmlType === '57') return title || '[引用消息]' + if (xmlType === '57') { + const quoteDisplay = this.extractQuotedReplyDisplay(content) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } + return title || '[引用消息]' + } if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' // 有 title 就返回 title @@ -744,7 +2021,7 @@ class ExportService { } // 最后尝试提取文本内容 - return this.stripSenderPrefix(content) || null + return this.stripSenderPrefix(normalizedContent) || null } } @@ -755,7 +2032,8 @@ class ExportService { voiceTranscript?: string, myWxid?: string, senderWxid?: string, - isSend?: boolean + isSend?: boolean, + emojiCaption?: string ): string { const safeContent = content || '' @@ -785,6 +2063,9 @@ class ExportService { const seconds = lengthValue ? this.parseDurationSeconds(lengthValue) : null return seconds ? `[视频]${seconds}s` : '[视频]' } + if (localType === 47) { + return this.formatEmojiSemanticText(emojiCaption) + } if (localType === 48) { const normalized = this.normalizeAppMessageContent(safeContent) const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName') @@ -807,8 +2088,8 @@ class ExportService { const normalized = this.normalizeAppMessageContent(safeContent) const isAppMessage = normalized.includes('') if (localType === 49 || isAppMessage) { - const typeMatch = /(\d+)<\/type>/i.exec(normalized) - const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0 + const subTypeRaw = this.extractAppMessageType(normalized) + const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0 const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname') // 群公告消息(type 87) @@ -854,18 +2135,17 @@ class ExportService { return `[红包]${title || '微信红包'}` } if (subType === 19 || normalized.includes('') + const referMsgEnd = normalized.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) { + return null + } + + const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) + const quoteInfo = this.parseQuoteMessage(normalized) + const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '') + const quotedPreview = this.formatQuotedReferencePreview( + this.extractXmlValue(referMsgXml, 'content'), + this.extractXmlValue(referMsgXml, 'type') + ) + + if (!replyText && !quotedPreview) { + return null + } + + return { + replyText, + quotedSender: quoteInfo.sender || undefined, + quotedPreview: quotedPreview || '[消息]' + } + } catch { + return null + } + } + + private isQuotedReplyMessage(localType: number, content: string): boolean { + if (localType === 244813135921) return true + const normalized = this.normalizeAppMessageContent(content || '') + if (!(localType === 49 || normalized.includes(''))) { + return false + } + const subType = this.extractAppMessageType(normalized) + return subType === '57' || normalized.includes('') + } + + private async resolveQuotedReplyDisplayWithNames(args: { + content: string + isGroup: boolean + displayNamePreference: ExportOptions['displayNamePreference'] + getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }> + groupNicknamesMap: Map + cleanedMyWxid: string + rawMyWxid?: string + myDisplayName?: string + }): Promise<{ + replyText: string + quotedSender?: string + quotedPreview: string + } | null> { + const base = this.extractQuotedReplyDisplay(args.content) + if (!base) return null + if (base.quotedSender) return base + + const normalized = this.normalizeAppMessageContent(args.content || '') + const referMsgStart = normalized.indexOf('') + const referMsgEnd = normalized.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) { + return base + } + + const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) + const quotedSenderUsername = this.resolveQuotedSenderUsername( + this.extractXmlValue(referMsgXml, 'fromusr'), + this.extractXmlValue(referMsgXml, 'chatusr') + ) + if (!quotedSenderUsername) { + return base + } + + const isQuotedSelf = this.isSameWxid(quotedSenderUsername, args.cleanedMyWxid) + const fallbackDisplayName = isQuotedSelf + ? (args.myDisplayName || quotedSenderUsername) + : quotedSenderUsername + + const profile = await this.resolveExportDisplayProfile( + quotedSenderUsername, + args.displayNamePreference, + args.getContact, + args.groupNicknamesMap, + fallbackDisplayName, + isQuotedSelf ? [args.rawMyWxid, args.cleanedMyWxid] : [] + ) + + return { + ...base, + quotedSender: profile.displayName || fallbackDisplayName || base.quotedSender + } + } + private parseDurationSeconds(value: string): number | null { const numeric = Number(value) if (!Number.isFinite(numeric) || numeric <= 0) return null @@ -901,8 +2336,9 @@ class ExportService { if (localType === 43) return 'video' if (localType === 34) return 'voice' if (localType === 48) return 'location' - if (localType === 49) { - const xmlType = this.extractXmlValue(content || '', 'type') + const normalized = this.normalizeAppMessageContent(content || '') + const xmlType = this.extractAppMessageType(normalized) + if (localType === 49 || normalized.includes('')) { if (xmlType === '6') return 'file' return 'text' } @@ -1111,11 +2547,12 @@ class ExportService { private getMessageTypeName(localType: number, content?: string): string { // 检查 XML 中的 type 标签(支持大 localType 的情况) if (content) { - const xmlTypeMatch = /(\d+)<\/type>/i.exec(content) - const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null + const normalized = this.normalizeAppMessageContent(content) + const xmlType = this.extractAppMessageType(normalized) if (xmlType) { switch (xmlType) { + case '3': return '音乐消息' case '87': return '群公告' case '2000': return '转账消息' case '5': return '链接消息' @@ -1233,45 +2670,38 @@ class ExportService { /** * 解析合并转发的聊天记录 (Type 19) */ - private parseChatHistory(content: string): any[] | undefined { + private parseChatHistory(content: string): ForwardChatRecordItem[] | undefined { try { - const type = this.extractXmlValue(content, 'type') - if (type !== '19') return undefined + const normalized = this.normalizeAppMessageContent(content || '') + const appMsgType = this.extractAppMessageType(normalized) + if (appMsgType !== '19' && !normalized.includes('[\s\S]*?[\s\S]*?<\/recorditem>/.exec(content) - if (!match) return undefined + const items: ForwardChatRecordItem[] = [] + const dedupe = new Set() + const recordItemRegex = /([\s\S]*?)<\/recorditem>/gi + let recordItemMatch: RegExpExecArray | null + while ((recordItemMatch = recordItemRegex.exec(normalized)) !== null) { + const parsedItems = this.parseForwardChatRecordContainer(recordItemMatch[1] || '') + for (const item of parsedItems) { + const dedupeKey = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}` + if (!dedupe.has(dedupeKey)) { + dedupe.add(dedupeKey) + items.push(item) + } + } + } - const innerXml = match[1] - const items: any[] = [] - const itemRegex = /([\s\S]*?)<\/dataitem>/g - let itemMatch - - while ((itemMatch = itemRegex.exec(innerXml)) !== null) { - const attrs = itemMatch[1] - const body = itemMatch[2] - - const datatypeMatch = /datatype="(\d+)"/.exec(attrs) - const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0 - - const sourcename = this.extractXmlValue(body, 'sourcename') - const sourcetime = this.extractXmlValue(body, 'sourcetime') - const sourceheadurl = this.extractXmlValue(body, 'sourceheadurl') - const datadesc = this.extractXmlValue(body, 'datadesc') - const datatitle = this.extractXmlValue(body, 'datatitle') - const fileext = this.extractXmlValue(body, 'fileext') - const datasize = parseInt(this.extractXmlValue(body, 'datasize') || '0') - - items.push({ - datatype, - sourcename, - sourcetime, - sourceheadurl, - datadesc: this.decodeHtmlEntities(datadesc), - datatitle: this.decodeHtmlEntities(datatitle), - fileext, - datasize - }) + if (items.length === 0 && normalized.includes(' 0 ? items : undefined @@ -1281,6 +2711,139 @@ class ExportService { } } + private parseForwardChatRecordContainer(containerXml: string): ForwardChatRecordItem[] { + const source = containerXml || '' + if (!source) return [] + + const segments: string[] = [source] + const decodedContainer = this.decodeHtmlEntities(source) + if (decodedContainer !== source) { + segments.push(decodedContainer) + } + + const cdataRegex = //g + let cdataMatch: RegExpExecArray | null + while ((cdataMatch = cdataRegex.exec(source)) !== null) { + const cdataInner = cdataMatch[1] || '' + if (cdataInner) { + segments.push(cdataInner) + const decodedInner = this.decodeHtmlEntities(cdataInner) + if (decodedInner !== cdataInner) { + segments.push(decodedInner) + } + } + } + + const items: ForwardChatRecordItem[] = [] + const seen = new Set() + for (const segment of segments) { + if (!segment) continue + const dataItemRegex = /]*)>([\s\S]*?)<\/dataitem>/gi + let dataItemMatch: RegExpExecArray | null + while ((dataItemMatch = dataItemRegex.exec(segment)) !== null) { + const parsed = this.parseForwardChatRecordDataItem(dataItemMatch[2] || '', dataItemMatch[1] || '') + if (!parsed) continue + const key = `${parsed.datatype}|${parsed.sourcename}|${parsed.sourcetime}|${parsed.datadesc || ''}|${parsed.datatitle || ''}` + if (!seen.has(key)) { + seen.add(key) + items.push(parsed) + } + } + } + + if (items.length > 0) return items + const fallback = this.parseForwardChatRecordDataItem(source, '') + return fallback ? [fallback] : [] + } + + private parseForwardChatRecordDataItem(body: string, attrs: string): ForwardChatRecordItem | null { + const datatypeByAttr = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '') + const datatypeRaw = datatypeByAttr?.[1] || this.extractXmlValue(body, 'datatype') || '0' + const datatype = Number.parseInt(datatypeRaw, 10) + const sourcename = this.decodeHtmlEntities(this.extractXmlValue(body, 'sourcename')) + const sourcetime = this.extractXmlValue(body, 'sourcetime') + const sourceheadurl = this.extractXmlValue(body, 'sourceheadurl') + const datadesc = this.decodeHtmlEntities(this.extractXmlValue(body, 'datadesc') || this.extractXmlValue(body, 'content')) + const datatitle = this.decodeHtmlEntities(this.extractXmlValue(body, 'datatitle')) + const fileext = this.extractXmlValue(body, 'fileext') + const datasizeRaw = this.extractXmlValue(body, 'datasize') + const datasize = datasizeRaw ? Number.parseInt(datasizeRaw, 10) : 0 + const nestedRecordXml = this.extractXmlValue(body, 'recordxml') || '' + const nestedRecordList = + datatype === 17 && nestedRecordXml + ? this.parseForwardChatRecordContainer(nestedRecordXml) + : undefined + const chatRecordTitle = this.decodeHtmlEntities( + (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'title')) || datatitle || '' + ) + const chatRecordDesc = this.decodeHtmlEntities( + (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'desc')) || datadesc || '' + ) + + if (!sourcename && !datadesc && !datatitle) return null + + return { + datatype: Number.isFinite(datatype) ? datatype : 0, + sourcename: sourcename || '', + sourcetime: sourcetime || '', + sourceheadurl: sourceheadurl || undefined, + datadesc: datadesc || undefined, + datatitle: datatitle || undefined, + fileext: fileext || undefined, + datasize: Number.isFinite(datasize) && datasize > 0 ? datasize : undefined, + chatRecordTitle: chatRecordTitle || undefined, + chatRecordDesc: chatRecordDesc || undefined, + chatRecordList: nestedRecordList && nestedRecordList.length > 0 ? nestedRecordList : undefined + } + } + + private formatForwardChatRecordItemText(item: ForwardChatRecordItem): string { + const desc = (item.datadesc || '').trim() + const title = (item.datatitle || '').trim() + if (desc) return desc + if (title) return title + switch (item.datatype) { + case 3: return '[图片]' + case 34: return '[语音消息]' + case 43: return '[视频]' + case 47: return '[表情包]' + case 49: + case 8: return title ? `[文件] ${title}` : '[文件]' + case 17: return item.chatRecordDesc || title || '[聊天记录]' + default: return '[消息]' + } + } + + private buildForwardChatRecordLines(record: ForwardChatRecordItem, depth = 0): string[] { + const indent = depth > 0 ? `${' '.repeat(Math.min(depth, 8))}` : '' + const senderPrefix = record.sourcename ? `${record.sourcename}: ` : '' + if (record.chatRecordList && record.chatRecordList.length > 0) { + const nestedTitle = record.chatRecordTitle || record.datatitle || record.chatRecordDesc || '聊天记录' + const header = `${indent}${senderPrefix}[转发的聊天记录]${nestedTitle}` + const nestedLines = record.chatRecordList.flatMap((item) => this.buildForwardChatRecordLines(item, depth + 1)) + return [header, ...nestedLines] + } + const text = this.formatForwardChatRecordItemText(record) + return [`${indent}${senderPrefix}${text}`] + } + + private formatForwardChatRecordContent(content: string): string { + const normalized = this.normalizeAppMessageContent(content || '') + const forwardName = + this.extractXmlValue(normalized, 'nickname') || + this.extractXmlValue(normalized, 'title') || + this.extractXmlValue(normalized, 'des') || + this.extractXmlValue(normalized, 'displayname') || + '聊天记录' + const records = this.parseChatHistory(normalized) + if (!records || records.length === 0) { + return forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]' + } + + const lines = records.flatMap((record) => this.buildForwardChatRecordLines(record)) + return `${forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]'}\n${lines.join('\n')}` + } + /** * 解码 HTML 实体 */ @@ -1308,6 +2871,358 @@ class ExportService { return content } + private extractFinderFeedDesc(content: string): string { + if (!content) return '' + const match = /([\s\S]*?)<\/desc>/i.exec(content) + if (!match) return '' + return match[1].replace(//g, '').trim() + } + + private extractAppMessageType(content: string): string { + if (!content) return '' + const normalized = this.normalizeAppMessageContent(content) + const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(normalized) + if (appmsgMatch) { + const appmsgInner = appmsgMatch[1] + .replace(//gi, '') + .replace(//gi, '') + const typeMatch = /([\s\S]*?)<\/type>/i.exec(appmsgInner) + if (typeMatch) return typeMatch[1].trim() + } + if (!normalized.includes('')) { + return '' + } + const fallbackTypeMatch = /(\d+)<\/type>/i.exec(normalized) + return fallbackTypeMatch ? fallbackTypeMatch[1] : '' + } + + private looksLikeWxid(text: string): boolean { + if (!text) return false + const trimmed = text.trim().toLowerCase() + if (trimmed.startsWith('wxid_')) return true + return /^wx[a-z0-9_-]{4,}$/.test(trimmed) + } + + private sanitizeQuotedContent(content: string): string { + if (!content) return '' + let result = content + result = result.replace(/wxid_[A-Za-z0-9_-]{3,}/g, '') + result = result.replace(/^[\s::\-]+/, '') + result = result.replace(/[::]{2,}/g, ':') + result = result.replace(/^[\s::\-]+/, '') + result = result.replace(/\s+/g, ' ').trim() + return result + } + + private parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } { + try { + const normalized = this.normalizeAppMessageContent(content || '') + const referMsgStart = normalized.indexOf('') + const referMsgEnd = normalized.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) { + return {} + } + + const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) + let sender = this.extractXmlValue(referMsgXml, 'displayname') + if (sender && this.looksLikeWxid(sender)) { + sender = '' + } + + const referContent = this.extractXmlValue(referMsgXml, 'content') + const referType = this.extractXmlValue(referMsgXml, 'type') + let displayContent = referContent + + switch (referType) { + case '1': + displayContent = this.sanitizeQuotedContent(referContent) + break + case '3': + displayContent = '[图片]' + break + case '34': + displayContent = '[语音]' + break + case '43': + displayContent = '[视频]' + break + case '47': + displayContent = '[表情包]' + break + case '49': + displayContent = '[链接]' + break + case '42': + displayContent = '[名片]' + break + case '48': + displayContent = '[位置]' + break + default: + if (!referContent || referContent.includes('wxid_')) { + displayContent = '[消息]' + } else { + displayContent = this.sanitizeQuotedContent(referContent) + } + } + + return { + content: displayContent || undefined, + sender: sender || undefined, + type: referType || undefined + } + } catch { + return {} + } + } + + private extractChatLabReplyToMessageId(content: string): string | undefined { + try { + const normalized = this.normalizeAppMessageContent(content || '') + const referMsgStart = normalized.indexOf('') + const referMsgEnd = normalized.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) { + return undefined + } + + const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) + const replyToMessageIdRaw = this.normalizeUnsignedIntToken(this.extractXmlValue(referMsgXml, 'svrid')) + return replyToMessageIdRaw !== '0' ? replyToMessageIdRaw : undefined + } catch { + return undefined + } + } + + private getExportPlatformMessageId(msg: { serverIdRaw?: unknown; serverId?: unknown }): string | undefined { + const value = this.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId) + return value !== '0' ? value : undefined + } + + private getExportReplyToMessageId(content: string): string | undefined { + return this.extractChatLabReplyToMessageId(content) + } + + private extractArkmeAppMessageMeta(content: string, localType: number): Record | null { + if (!content) return null + + const normalized = this.normalizeAppMessageContent(content) + const looksLikeAppMsg = + localType === 49 || + localType === 244813135921 || + normalized.includes('') + const hasReferMsg = normalized.includes('') + const xmlType = this.extractAppMessageType(normalized) + const isFinder = + xmlType === '51' || + normalized.includes('') || + normalized.includes('') + + if (!looksLikeAppMsg && !isFinder && !hasReferMsg) return null + + let appMsgKind: string | undefined + if (isFinder) { + appMsgKind = 'finder' + } else if (xmlType === '2001') { + appMsgKind = 'red-packet' + } else if (isMusic) { + appMsgKind = 'music' + } else if (xmlType === '33' || xmlType === '36') { + appMsgKind = 'miniapp' + } else if (xmlType === '6') { + appMsgKind = 'file' + } else if (xmlType === '19') { + appMsgKind = 'chat-record' + } else if (xmlType === '2000') { + appMsgKind = 'transfer' + } else if (xmlType === '87') { + appMsgKind = 'announcement' + } else if (xmlType === '57' || hasReferMsg || localType === 244813135921) { + appMsgKind = 'quote' + } else if (xmlType === '5' || xmlType === '49') { + appMsgKind = 'link' + } else if (looksLikeAppMsg) { + appMsgKind = 'card' + } + + const meta: Record = {} + if (xmlType) meta.appMsgType = xmlType + else if (appMsgKind === 'quote') meta.appMsgType = '57' + if (appMsgKind) meta.appMsgKind = appMsgKind + + const appMsgDesc = this.extractXmlValue(normalized, 'des') || this.extractXmlValue(normalized, 'desc') + const appMsgAppName = this.extractXmlValue(normalized, 'appname') + const appMsgSourceName = + this.extractXmlValue(normalized, 'sourcename') || + this.extractXmlValue(normalized, 'sourcedisplayname') + const appMsgSourceUsername = this.extractXmlValue(normalized, 'sourceusername') + const appMsgThumbUrl = + this.extractXmlValue(normalized, 'thumburl') || + this.extractXmlValue(normalized, 'cdnthumburl') || + this.extractXmlValue(normalized, 'cover') || + this.extractXmlValue(normalized, 'coverurl') || + this.extractXmlValue(normalized, 'thumbUrl') || + this.extractXmlValue(normalized, 'coverUrl') + + if (appMsgDesc) meta.appMsgDesc = appMsgDesc + if (appMsgAppName) meta.appMsgAppName = appMsgAppName + if (appMsgSourceName) meta.appMsgSourceName = appMsgSourceName + if (appMsgSourceUsername) meta.appMsgSourceUsername = appMsgSourceUsername + if (appMsgThumbUrl) meta.appMsgThumbUrl = appMsgThumbUrl + + if (appMsgKind === 'quote') { + const quoteInfo = this.parseQuoteMessage(normalized) + if (quoteInfo.content) meta.quotedContent = quoteInfo.content + if (quoteInfo.sender) meta.quotedSender = quoteInfo.sender + if (quoteInfo.type) meta.quotedType = quoteInfo.type + } + + if (appMsgKind === 'link') { + const linkCard = this.extractHtmlLinkCard(normalized, localType) + const linkUrl = linkCard?.url || this.normalizeHtmlLinkUrl( + this.extractXmlValue(normalized, 'shareurl') || + this.extractXmlValue(normalized, 'shorturl') || + this.extractXmlValue(normalized, 'dataurl') + ) + if (linkCard?.title) meta.linkTitle = linkCard.title + if (linkUrl) meta.linkUrl = linkUrl + if (appMsgThumbUrl) meta.linkThumb = appMsgThumbUrl + } + + if (isMusic) { + const musicTitle = + this.extractXmlValue(normalized, 'songname') || + this.extractXmlValue(normalized, 'title') + const musicUrl = + this.extractXmlValue(normalized, 'musicurl') || + this.extractXmlValue(normalized, 'playurl') || + this.extractXmlValue(normalized, 'songalbumurl') + const musicDataUrl = + this.extractXmlValue(normalized, 'dataurl') || + this.extractXmlValue(normalized, 'lowurl') + const musicAlbumUrl = this.extractXmlValue(normalized, 'songalbumurl') + const musicCoverUrl = + this.extractXmlValue(normalized, 'thumburl') || + this.extractXmlValue(normalized, 'cdnthumburl') || + this.extractXmlValue(normalized, 'coverurl') || + this.extractXmlValue(normalized, 'cover') + const musicSinger = + this.extractXmlValue(normalized, 'singername') || + this.extractXmlValue(normalized, 'artist') || + this.extractXmlValue(normalized, 'albumartist') + const musicAppName = this.extractXmlValue(normalized, 'appname') + const musicSourceName = this.extractXmlValue(normalized, 'sourcename') + const durationRaw = + this.extractXmlValue(normalized, 'playlength') || + this.extractXmlValue(normalized, 'play_length') || + this.extractXmlValue(normalized, 'duration') + const musicDuration = durationRaw ? this.parseDurationSeconds(durationRaw) : null + + if (musicTitle) meta.musicTitle = musicTitle + if (musicUrl) meta.musicUrl = musicUrl + if (musicDataUrl) meta.musicDataUrl = musicDataUrl + if (musicAlbumUrl) meta.musicAlbumUrl = musicAlbumUrl + if (musicCoverUrl) meta.musicCoverUrl = musicCoverUrl + if (musicSinger) meta.musicSinger = musicSinger + if (musicAppName) meta.musicAppName = musicAppName + if (musicSourceName) meta.musicSourceName = musicSourceName + if (musicDuration != null) meta.musicDuration = musicDuration + } + + if (!isFinder) { + return Object.keys(meta).length > 0 ? meta : null + } + + const rawTitle = this.extractXmlValue(normalized, 'title') + const finderFeedDesc = this.extractFinderFeedDesc(normalized) + const finderTitle = (!rawTitle || rawTitle.includes('不支持')) ? finderFeedDesc : rawTitle + const finderDesc = this.extractXmlValue(normalized, 'des') || this.extractXmlValue(normalized, 'desc') + const finderUsername = + this.extractXmlValue(normalized, 'finderusername') || + this.extractXmlValue(normalized, 'finder_username') || + this.extractXmlValue(normalized, 'finderuser') + const finderNickname = + this.extractXmlValue(normalized, 'findernickname') || + this.extractXmlValue(normalized, 'finder_nickname') + const finderCoverUrl = + this.extractXmlValue(normalized, 'thumbUrl') || + this.extractXmlValue(normalized, 'coverUrl') || + this.extractXmlValue(normalized, 'thumburl') || + this.extractXmlValue(normalized, 'coverurl') + const finderAvatar = this.extractXmlValue(normalized, 'avatar') + const durationRaw = this.extractXmlValue(normalized, 'videoPlayDuration') || this.extractXmlValue(normalized, 'duration') + const finderDuration = durationRaw ? this.parseDurationSeconds(durationRaw) : null + const finderObjectId = + this.extractXmlValue(normalized, 'finderobjectid') || + this.extractXmlValue(normalized, 'finder_objectid') || + this.extractXmlValue(normalized, 'objectid') || + this.extractXmlValue(normalized, 'object_id') + const finderUrl = + this.extractXmlValue(normalized, 'url') || + this.extractXmlValue(normalized, 'shareurl') + + if (finderTitle) meta.finderTitle = finderTitle + if (finderDesc) meta.finderDesc = finderDesc + if (finderUsername) meta.finderUsername = finderUsername + if (finderNickname) meta.finderNickname = finderNickname + if (finderCoverUrl) meta.finderCoverUrl = finderCoverUrl + if (finderAvatar) meta.finderAvatar = finderAvatar + if (finderDuration != null) meta.finderDuration = finderDuration + if (finderObjectId) meta.finderObjectId = finderObjectId + if (finderUrl) meta.finderUrl = finderUrl + + return Object.keys(meta).length > 0 ? meta : null + } + + private extractArkmeContactCardMeta(content: string, localType: number): Record | null { + if (!content || localType !== 42) return null + + const normalized = this.normalizeAppMessageContent(content) + const readAttr = (attrName: string): string => + this.extractXmlAttribute(normalized, 'msg', attrName) || this.extractXmlValue(normalized, attrName) + + const contactCardWxid = + readAttr('username') || + readAttr('encryptusername') || + readAttr('encrypt_user_name') + const contactCardNickname = readAttr('nickname') + const contactCardAlias = readAttr('alias') + const contactCardRemark = readAttr('remark') + const contactCardProvince = readAttr('province') + const contactCardCity = readAttr('city') + const contactCardSignature = readAttr('sign') || readAttr('signature') + const contactCardAvatar = + readAttr('smallheadimgurl') || + readAttr('bigheadimgurl') || + readAttr('headimgurl') || + readAttr('avatar') + const sexRaw = readAttr('sex') + const contactCardGender = sexRaw ? parseInt(sexRaw, 10) : NaN + + const meta: Record = { + cardKind: 'contact-card' + } + if (contactCardWxid) meta.contactCardWxid = contactCardWxid + if (contactCardNickname) meta.contactCardNickname = contactCardNickname + if (contactCardAlias) meta.contactCardAlias = contactCardAlias + if (contactCardRemark) meta.contactCardRemark = contactCardRemark + if (contactCardProvince) meta.contactCardProvince = contactCardProvince + if (contactCardCity) meta.contactCardCity = contactCardCity + if (contactCardSignature) meta.contactCardSignature = contactCardSignature + if (contactCardAvatar) meta.contactCardAvatar = contactCardAvatar + if (Number.isFinite(contactCardGender) && contactCardGender >= 0) { + meta.contactCardGender = contactCardGender + } + + return Object.keys(meta).length > 0 ? meta : null + } + private getInlineEmojiDataUrl(name: string): string | null { if (!name) return null const cached = this.inlineEmojiCache.get(name) @@ -1345,7 +3260,17 @@ class ExportService { 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 (localType === 1) { @@ -1353,10 +3278,10 @@ class ExportService { } 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 { @@ -1366,7 +3291,7 @@ class ExportService { const isAppMessage = localType === 49 || normalized.includes('') if (!isAppMessage) return null - const subType = this.extractXmlValue(normalized, 'type') + const subType = this.extractAppMessageType(normalized) if (subType && subType !== '5' && subType !== '49') return null const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url')) @@ -1422,14 +3347,24 @@ class ExportService { exportVideos?: boolean exportEmojis?: boolean exportVoiceAsText?: boolean + includeVideoPoster?: boolean includeVoiceWithTranscript?: boolean + imageDeepSearchOnMiss?: boolean + dirCache?: Set } ): Promise { const localType = msg.localType // 图片消息 if (localType === 3 && options.exportImages) { - const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix) + const result = await this.exportImage( + msg, + sessionId, + mediaRootDir, + mediaRelativePrefix, + options.dirCache, + options.imageDeepSearchOnMiss !== false + ) if (result) { } return result @@ -1438,7 +3373,7 @@ class ExportService { // 语音消息 if (localType === 34) { if (options.exportVoices) { - return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix) + return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) } if (options.exportVoiceAsText) { return null @@ -1447,14 +3382,21 @@ class ExportService { // 动画表情 if (localType === 47 && options.exportEmojis) { - const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix) + const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) if (result) { } return result } if (localType === 43 && options.exportVideos) { - return this.exportVideo(msg, sessionId, mediaRootDir, mediaRelativePrefix) + return this.exportVideo( + msg, + sessionId, + mediaRootDir, + mediaRelativePrefix, + options.dirCache, + options.includeVideoPoster === true + ) } return null @@ -1467,12 +3409,15 @@ class ExportService { msg: any, sessionId: string, mediaRootDir: string, - mediaRelativePrefix: string + mediaRelativePrefix: string, + dirCache?: Set, + imageDeepSearchOnMiss = true ): Promise { try { const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images') - if (!fs.existsSync(imagesDir)) { - fs.mkdirSync(imagesDir, { recursive: true }) + if (!dirCache?.has(imagesDir)) { + await fs.promises.mkdir(imagesDir, { recursive: true }) + dirCache?.add(imagesDir) } // 使用消息对象中已提取的字段 @@ -1483,27 +3428,60 @@ class ExportService { return null } + const missingRunCacheKey = this.getImageMissingRunCacheKey( + sessionId, + imageMd5, + imageDatName, + imageDeepSearchOnMiss + ) + if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) { + return null + } + const result = await imageDecryptService.decryptImage({ sessionId, imageMd5, imageDatName, - force: false // 先尝试缩略图 + force: true, // 导出优先高清,失败再回退缩略图 + preferFilePath: true, + hardlinkOnly: !imageDeepSearchOnMiss }) if (!result.success || !result.localPath) { 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({ sessionId, imageMd5, - imageDatName + imageDatName, + preferFilePath: true }) - if (!thumbResult.success || !thumbResult.localPath) { - console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'} → 将显示 [图片] 占位符`) - return null + if (thumbResult.success && thumbResult.localPath) { + console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) + result.localPath = thumbResult.localPath + } else { + console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'}`) + // 最后尝试:直接从 imageStore 获取缓存的缩略图 data URL + const { imageStore } = await import('../main') + const cachedThumb = imageStore?.getCachedImage(sessionId, imageMd5, imageDatName) + if (cachedThumb) { + console.log(`[Export] 从 imageStore 获取到缓存缩略图 (localId=${msg.localId})`) + result.localPath = cachedThumb + } else { + console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`) + if (missingRunCacheKey) { + this.mediaRunMissingImageKeys.add(missingRunCacheKey) + } + return null + } } - console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) - result.localPath = thumbResult.localPath } // 为每条消息生成稳定且唯一的文件名前缀,避免跨日期/消息发生同名覆盖 @@ -1519,7 +3497,13 @@ class ExportService { const fileName = `${messageId}_${imageKey}${ext}` const destPath = path.join(imagesDir, fileName) - fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64')) + const buffer = Buffer.from(base64Data, 'base64') + await fs.promises.writeFile(destPath, buffer) + this.noteMediaTelemetry({ + doneFiles: 1, + cacheMissFiles: 1, + bytesWritten: buffer.length + }) return { relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), @@ -1530,16 +3514,17 @@ class ExportService { } // 复制文件 - if (!fs.existsSync(sourcePath)) { - console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`) - return null - } const ext = path.extname(sourcePath) || '.jpg' const fileName = `${messageId}_${imageKey}${ext}` const destPath = path.join(imagesDir, fileName) - - if (!fs.existsSync(destPath)) { - fs.copyFileSync(sourcePath, destPath) + const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath) + if (!copied.success) { + if (copied.code === 'ENOENT') { + console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`) + } else { + console.log(`[Export] 复制图片失败 (localId=${msg.localId}): ${sourcePath}, code=${copied.code || 'UNKNOWN'} → 将显示 [图片] 占位符`) + } + return null } return { @@ -1552,6 +3537,105 @@ class ExportService { } } + private async preloadMediaLookupCaches( + _sessionId: string, + messages: any[], + options: { exportImages?: boolean; exportVideos?: boolean }, + control?: ExportTaskControl + ): Promise { + if (!Array.isArray(messages) || messages.length === 0) return + + const md5Pattern = /^[a-f0-9]{32}$/i + const imageMd5Set = new Set() + const videoMd5Set = new Set() + + let scanIndex = 0 + for (const msg of messages) { + if ((scanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } + + if (options.exportImages && msg?.localType === 3) { + const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase() + if (imageMd5) { + imageMd5Set.add(imageMd5) + } else { + const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase() + if (md5Pattern.test(imageDatName)) { + imageMd5Set.add(imageDatName) + } + } + } + + if (options.exportVideos && msg?.localType === 43) { + const videoMd5 = String(msg?.videoMd5 || '').trim().toLowerCase() + if (videoMd5) videoMd5Set.add(videoMd5) + } + } + + const preloadTasks: Array> = [] + if (imageMd5Set.size > 0) { + preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))) + } + if (videoMd5Set.size > 0) { + preloadTasks.push(videoService.preloadVideoHardlinkMd5s(Array.from(videoMd5Set))) + } + if (preloadTasks.length === 0) return + + await Promise.all(preloadTasks.map((task) => task.catch(() => { }))) + this.throwIfStopRequested(control) + } + + /** + * 导出语音文件 + */ + private async preloadVoiceWavCache( + sessionId: string, + messages: any[], + control?: ExportTaskControl + ): Promise { + if (!Array.isArray(messages) || messages.length === 0) return + + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + + const normalized: Array<{ + localId: number + createTime: number + serverId?: string | number + senderWxid?: string | null + }> = [] + const seen = new Set() + + for (const msg of messages) { + const localIdRaw = Number(msg?.localId) + const createTimeRaw = Number(msg?.createTime) + const localId = Number.isFinite(localIdRaw) ? Math.max(0, Math.floor(localIdRaw)) : 0 + const createTime = Number.isFinite(createTimeRaw) ? Math.max(0, Math.floor(createTimeRaw)) : 0 + if (!localId || !createTime) continue + const dedupeKey = this.getStableMessageKey(msg) + if (seen.has(dedupeKey)) continue + seen.add(dedupeKey) + normalized.push({ + localId, + createTime, + serverId: msg?.serverId, + senderWxid: msg?.senderUsername || null + }) + } + if (normalized.length === 0) return + + const chunkSize = 120 + for (let i = 0; i < normalized.length; i += chunkSize) { + this.throwIfStopRequested(control) + const chunk = normalized.slice(i, i + chunkSize) + await chatService.preloadVoiceDataBatch(normalizedSessionId, chunk, { + chunkSize: 48, + decodeConcurrency: 3 + }) + } + } + /** * 导出语音文件 */ @@ -1559,20 +3643,26 @@ class ExportService { msg: any, sessionId: string, mediaRootDir: string, - mediaRelativePrefix: string + mediaRelativePrefix: string, + dirCache?: Set ): Promise { try { const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices') - if (!fs.existsSync(voicesDir)) { - fs.mkdirSync(voicesDir, { recursive: true }) + if (!dirCache?.has(voicesDir)) { + await fs.promises.mkdir(voicesDir, { recursive: true }) + dirCache?.add(voicesDir) } const msgId = String(msg.localId) - const fileName = `voice_${msgId}.wav` + const safeSession = this.cleanAccountDirName(sessionId) + .replace(/[^a-zA-Z0-9_-]/g, '_') + .slice(0, 48) || 'session' + const stableKey = this.getStableMessageKey(msg).replace(/:/g, '_') + const fileName = `voice_${safeSession}_${stableKey || msgId}.wav` const destPath = path.join(voicesDir, fileName) // 如果已存在则跳过 - if (fs.existsSync(destPath)) { + if (await this.pathExists(destPath)) { return { relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), kind: 'voice' @@ -1580,14 +3670,24 @@ class ExportService { } // 调用 chatService 获取语音数据 - const voiceResult = await chatService.getVoiceData(sessionId, msgId) + const voiceResult = await chatService.getVoiceData( + sessionId, + msgId, + Number.isFinite(Number(msg?.createTime)) ? Number(msg.createTime) : undefined, + msg?.serverId, + msg?.senderUsername || undefined + ) if (!voiceResult.success || !voiceResult.data) { return null } // voiceResult.data 是 base64 编码的 wav 数据 const wavBuffer = Buffer.from(voiceResult.data, 'base64') - fs.writeFileSync(destPath, wavBuffer) + await fs.promises.writeFile(destPath, wavBuffer) + this.noteMediaTelemetry({ + doneFiles: 1, + bytesWritten: wavBuffer.length + }) return { relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), @@ -1620,18 +3720,20 @@ class ExportService { msg: any, sessionId: string, mediaRootDir: string, - mediaRelativePrefix: string + mediaRelativePrefix: string, + dirCache?: Set ): Promise { try { const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis') - if (!fs.existsSync(emojisDir)) { - fs.mkdirSync(emojisDir, { recursive: true }) + if (!dirCache?.has(emojisDir)) { + await fs.promises.mkdir(emojisDir, { recursive: true }) + dirCache?.add(emojisDir) } // 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑) const localPath = await chatService.downloadEmojiFile(msg) - if (!localPath || !fs.existsSync(localPath)) { + if (!localPath) { return null } @@ -1640,11 +3742,8 @@ class ExportService { const key = msg.emojiMd5 || String(msg.localId) const fileName = `${key}${ext}` const destPath = path.join(emojisDir, fileName) - - // 复制文件到导出目录 (如果不存在) - if (!fs.existsSync(destPath)) { - fs.copyFileSync(localPath, destPath) - } + const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath) + if (!copied.success) return null return { relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), @@ -1663,18 +3762,21 @@ class ExportService { msg: any, sessionId: string, mediaRootDir: string, - mediaRelativePrefix: string + mediaRelativePrefix: string, + dirCache?: Set, + includePoster = false ): Promise { try { const videoMd5 = msg.videoMd5 if (!videoMd5) return null const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos') - if (!fs.existsSync(videosDir)) { - fs.mkdirSync(videosDir, { recursive: true }) + if (!dirCache?.has(videosDir)) { + await fs.promises.mkdir(videosDir, { recursive: true }) + dirCache?.add(videosDir) } - const videoInfo = await videoService.getVideoInfo(videoMd5) + const videoInfo = await videoService.getVideoInfo(videoMd5, { includePoster }) if (!videoInfo.exists || !videoInfo.videoUrl) { return null } @@ -1683,14 +3785,13 @@ class ExportService { const fileName = path.basename(sourcePath) const destPath = path.join(videosDir, fileName) - if (!fs.existsSync(destPath)) { - fs.copyFileSync(sourcePath, destPath) - } + const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath) + if (!copied.success) return null return { relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName), kind: 'video', - posterDataUrl: videoInfo.coverUrl || videoInfo.thumbUrl + posterDataUrl: includePoster ? (videoInfo.coverUrl || videoInfo.thumbUrl) : undefined } } catch (e) { return null @@ -1751,8 +3852,11 @@ class ExportService { */ private extractEmojiMd5(content: string): string | undefined { if (!content) return undefined - const match = /md5="([^"]+)"/i.exec(content) || /([^<]+)<\/md5>/i.exec(content) - return match?.[1] + const match = + /md5\s*=\s*['"]([a-fA-F0-9]{32})['"]/i.exec(content) || + /md5\s*=\s*([a-fA-F0-9]{32})/i.exec(content) || + /([a-fA-F0-9]{32})<\/md5>/i.exec(content) + return this.normalizeEmojiMd5(match?.[1]) || this.extractLooseHexMd5(content) } private extractVideoMd5(content: string): string | undefined { @@ -1765,6 +3869,46 @@ class ExportService { return tagMatch?.[1]?.toLowerCase() } + private extractLocationMeta(content: string, localType: number): { + locationLat?: number + locationLng?: number + locationPoiname?: string + locationLabel?: string + } | null { + if (!content || localType !== 48) return null + + const normalized = this.normalizeAppMessageContent(content) + const rawLat = this.extractXmlAttribute(normalized, 'location', 'x') || this.extractXmlAttribute(normalized, 'location', 'latitude') + const rawLng = this.extractXmlAttribute(normalized, 'location', 'y') || this.extractXmlAttribute(normalized, 'location', 'longitude') + const locationPoiname = + this.extractXmlAttribute(normalized, 'location', 'poiname') || + this.extractXmlValue(normalized, 'poiname') || + this.extractXmlValue(normalized, 'poiName') + const locationLabel = + this.extractXmlAttribute(normalized, 'location', 'label') || + this.extractXmlValue(normalized, 'label') + + const meta: { + locationLat?: number + locationLng?: number + locationPoiname?: string + locationLabel?: string + } = {} + + if (rawLat) { + const parsed = parseFloat(rawLat) + if (Number.isFinite(parsed)) meta.locationLat = parsed + } + if (rawLng) { + const parsed = parseFloat(rawLng) + if (Number.isFinite(parsed)) meta.locationLng = parsed + } + if (locationPoiname) meta.locationPoiname = locationPoiname + if (locationLabel) meta.locationLabel = locationLabel + + return Object.keys(meta).length > 0 ? meta : null + } + /** * 从 data URL 获取扩展名 */ @@ -1783,6 +3927,18 @@ class ExportService { const exportMediaEnabled = options.exportMedia === true && Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) const outputDir = path.dirname(outputPath) + const rawWriteLayout = this.configService.get('exportWriteLayout') + const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' + ? rawWriteLayout + : 'A' + // A: type-first layout, text exports are placed under `texts/`, media is placed at sibling type directories. + if (writeLayout === 'A' && path.basename(outputDir) === 'texts') { + return { + exportMediaEnabled, + mediaRootDir: outputDir, + mediaRelativePrefix: '..' + } + } const outputBaseName = path.basename(outputPath, path.extname(outputPath)) const useSharedMediaLayout = options.sessionLayout === 'shared' const mediaRelativePrefix = useSharedMediaLayout @@ -1842,27 +3998,42 @@ class ExportService { sessionId: string, cleanedMyWxid: string, dateRange?: { start: number; end: number } | null, - senderUsernameFilter?: string + senderUsernameFilter?: string, + collectMode: MessageCollectMode = 'full', + targetMediaTypes?: Set, + control?: ExportTaskControl, + onCollectProgress?: (payload: { fetched: number }) => void ): Promise<{ rows: any[]; memberSet: Map; firstTime: number | null; lastTime: number | null }> { const rows: any[] = [] const memberSet = new Map() const senderSet = new Set() let firstTime: number | null = null let lastTime: number | null = null + const mediaTypeFilter = collectMode === 'media-fast' && targetMediaTypes && targetMediaTypes.size > 0 + ? targetMediaTypes + : null // 修复时间范围:0 表示不限制,而不是时间戳 0 const beginTime = dateRange?.start || 0 const endTime = dateRange?.end && dateRange.end > 0 ? dateRange.end : 0 - console.log(`[Export] 收集消息: sessionId=${sessionId}, 时间范围: ${beginTime} ~ ${endTime || '无限制'}`) - - const cursor = await wcdbService.openMessageCursor( - sessionId, - 500, - true, - beginTime, - endTime - ) + const batchSize = (collectMode === 'text-fast' || collectMode === 'media-fast') ? 2000 : 500 + this.throwIfStopRequested(control) + const cursor = collectMode === 'media-fast' + ? await wcdbService.openMessageCursorLite( + sessionId, + batchSize, + true, + beginTime, + endTime + ) + : await wcdbService.openMessageCursor( + sessionId, + batchSize, + true, + beginTime, + endTime + ) if (!cursor.success || !cursor.cursor) { console.error(`[Export] 打开游标失败: ${cursor.error || '未知错误'}`) return { rows, memberSet, firstTime, lastTime } @@ -1872,6 +4043,7 @@ class ExportService { let hasMore = true let batchCount = 0 while (hasMore) { + this.throwIfStopRequested(control) const batch = await wcdbService.fetchMessageBatch(cursor.cursor) batchCount++ @@ -1880,25 +4052,57 @@ class ExportService { break } - if (!batch.rows) { - console.warn(`[Export] 批次 ${batchCount} 无数据`) - break - } - - console.log(`[Export] 批次 ${batchCount}: 收到 ${batch.rows.length} 条消息`) + if (!batch.rows) break + let rowIndex = 0 for (const row of batch.rows) { - const createTime = parseInt(row.create_time || '0', 10) + if ((rowIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } + const createTime = this.getIntFromRow(row, [ + 'create_time', 'createTime', 'createtime', + 'msg_create_time', 'msgCreateTime', + 'msg_time', 'msgTime', 'time', + 'WCDB_CT_create_time' + ], 0) if (dateRange) { if (createTime < dateRange.start || createTime > dateRange.end) continue } - const content = this.decodeMessageContent(row.message_content, row.compress_content) - const localType = parseInt(row.local_type || row.type || '1', 10) + const localType = this.getIntFromRow(row, [ + 'local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type' + ], 1) + if (mediaTypeFilter && !mediaTypeFilter.has(localType)) { + continue + } + const shouldDecodeContent = collectMode === 'full' + || (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType)) + || (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter)) + const content = shouldDecodeContent + ? this.decodeMessageContent(row.message_content, row.compress_content) + : '' const senderUsername = row.sender_username || '' const isSendRaw = row.computed_is_send ?? row.is_send ?? '0' const isSend = parseInt(isSendRaw, 10) === 1 - const localId = parseInt(row.local_id || row.localId || '0', 10) + const localId = this.getIntFromRow(row, [ + 'local_id', 'localId', 'LocalId', + 'msg_local_id', 'msgLocalId', 'MsgLocalId', + 'msg_id', 'msgId', 'MsgId', 'id', + 'WCDB_CT_local_id' + ], 0) + const rawServerIdValue = this.getRowField(row, [ + 'server_id', 'serverId', 'ServerId', + 'msg_server_id', 'msgServerId', 'MsgServerId', + 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId', + 'WCDB_CT_server_id' + ]) + const serverIdRaw = this.normalizeUnsignedIntToken(rawServerIdValue) + const serverId = this.getIntFromRow(row, [ + 'server_id', 'serverId', 'ServerId', + 'msg_server_id', 'msgServerId', 'MsgServerId', + 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId', + 'WCDB_CT_server_id' + ], 0) // 确定实际发送者 let actualSender: string @@ -1930,35 +4134,70 @@ class ExportService { } senderSet.add(actualSender) - // 提取媒体相关字段 + // 提取媒体相关字段(轻量模式下跳过) let imageMd5: string | undefined let imageDatName: string | undefined let emojiCdnUrl: string | undefined let emojiMd5: string | undefined let videoMd5: string | undefined + let locationLat: number | undefined + let locationLng: number | undefined + let locationPoiname: string | undefined + let locationLabel: string | undefined let chatRecordList: any[] | undefined + let emojiCaption: string | undefined - if (localType === 3 && content) { - // 图片消息 - imageMd5 = this.extractImageMd5(content) - imageDatName = this.extractImageDatName(content) - } else if (localType === 47 && content) { - // 动画表情 - emojiCdnUrl = this.extractEmojiUrl(content) - emojiMd5 = this.extractEmojiMd5(content) - } else if (localType === 43 && content) { - // 视频消息 - videoMd5 = this.extractVideoMd5(content) - } else if (localType === 49 && content) { - // 检查是否是聊天记录消息(type=19) - const xmlType = this.extractXmlValue(content, 'type') - if (xmlType === '19') { - chatRecordList = this.parseChatHistory(content) + if (localType === 48 && content) { + const locationMeta = this.extractLocationMeta(content, localType) + if (locationMeta) { + locationLat = locationMeta.locationLat + locationLng = locationMeta.locationLng + locationPoiname = locationMeta.locationPoiname + locationLabel = locationMeta.locationLabel + } + } + + 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') { + // 优先复用游标返回的字段,缺失时再回退到 XML 解析。 + imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined + imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined + videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined + + if (localType === 3 && content) { + // 图片消息 + imageMd5 = imageMd5 || this.extractImageMd5(content) + imageDatName = imageDatName || this.extractImageDatName(content) + } else if (localType === 43 && content) { + // 视频消息 + videoMd5 = videoMd5 || this.extractVideoMd5(content) + } else if (collectMode === 'full' && content && (localType === 49 || content.includes(' lastTime) lastTime = createTime } + onCollectProgress?.({ fetched: rows.length }) hasMore = batch.hasMore === true } - console.log(`[Export] 收集完成: 共 ${rows.length} 条消息, ${batchCount} 个批次`) } catch (err) { + if (this.isStopError(err)) throw err console.error(`[Export] 收集消息异常:`, err) } finally { try { await wcdbService.closeMessageCursor(cursor.cursor) - console.log(`[Export] 游标已关闭`) } catch (err) { console.error(`[Export] 关闭游标失败:`, err) } } + this.throwIfStopRequested(control) + if (collectMode === 'media-fast' && mediaTypeFilter && rows.length > 0) { + await this.backfillMediaFieldsFromMessageDetail(sessionId, rows, mediaTypeFilter, control) + } + + this.throwIfStopRequested(control) if (senderSet.size > 0) { const usernames = Array.from(senderSet) const [nameResult, avatarResult] = await Promise.all([ @@ -2017,6 +4267,76 @@ class ExportService { return { rows, memberSet, firstTime, lastTime } } + private async backfillMediaFieldsFromMessageDetail( + sessionId: string, + rows: any[], + targetMediaTypes: Set, + control?: ExportTaskControl + ): Promise { + const needsBackfill = rows.filter((msg) => { + if (!targetMediaTypes.has(msg.localType)) return false + if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName + if (msg.localType === 47) return !msg.emojiMd5 + if (msg.localType === 43) return !msg.videoMd5 + return false + }) + if (needsBackfill.length === 0) return + + const DETAIL_CONCURRENCY = 6 + await parallelLimit(needsBackfill, DETAIL_CONCURRENCY, async (msg) => { + this.throwIfStopRequested(control) + const localId = Number(msg.localId || 0) + if (!Number.isFinite(localId) || localId <= 0) return + + try { + const detail = await wcdbService.getMessageById(sessionId, localId) + if (!detail.success || !detail.message) return + + const row = detail.message as any + const rawMessageContent = this.getRowField(row, [ + '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 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) { + const imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content) + const imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || this.extractImageDatName(content) + if (imageMd5) msg.imageMd5 = imageMd5 + if (imageDatName) msg.imageDatName = imageDatName + return + } + + if (msg.localType === 47) { + const emojiMd5 = + 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 (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl + return + } + + if (msg.localType === 43) { + const videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || this.extractVideoMd5(content) + if (videoMd5) msg.videoMd5 = videoMd5 + } + } catch (error) { + // 详情补取失败时保持降级导出(占位符),避免中断整批任务。 + } + }) + } + // 补齐群成员,避免只导出发言者导致头像缺失 private async mergeGroupMembers( chatroomId: string, @@ -2092,6 +4412,89 @@ class ExportService { } } + private extractGroupMemberUsername(member: any): string { + if (!member) return '' + if (typeof member === 'string') return member.trim() + return String( + member.username || + member.userName || + member.user_name || + member.encryptUsername || + member.encryptUserName || + member.encrypt_username || + member.originalName || + '' + ).trim() + } + + private extractGroupSenderCountMap(groupStats: any, sessionId: string): Map { + const senderCountMap = new Map() + if (!groupStats || typeof groupStats !== 'object') return senderCountMap + + const sessions = (groupStats as any).sessions + const sessionStats = sessions && typeof sessions === 'object' + ? (sessions[sessionId] || sessions[String(sessionId)] || null) + : null + const senderRaw = (sessionStats && typeof sessionStats === 'object' && (sessionStats as any).senders && typeof (sessionStats as any).senders === 'object') + ? (sessionStats as any).senders + : ((groupStats as any).senders && typeof (groupStats as any).senders === 'object' ? (groupStats as any).senders : {}) + const idMap = (groupStats as any).idMap && typeof (groupStats as any).idMap === 'object' + ? (groupStats as any).idMap + : ((sessionStats && typeof sessionStats === 'object' && (sessionStats as any).idMap && typeof (sessionStats as any).idMap === 'object') + ? (sessionStats as any).idMap + : {}) + + for (const [senderKey, rawCount] of Object.entries(senderRaw)) { + const countNumber = Number(rawCount) + if (!Number.isFinite(countNumber) || countNumber <= 0) continue + const count = Math.max(0, Math.floor(countNumber)) + const mapped = typeof (idMap as any)[senderKey] === 'string' ? String((idMap as any)[senderKey]).trim() : '' + const wxid = (mapped || String(senderKey || '').trim()) + if (!wxid) continue + senderCountMap.set(wxid, (senderCountMap.get(wxid) || 0) + count) + } + + return senderCountMap + } + + private sumSenderCountsByIdentity(senderCountMap: Map, wxid: string): number { + const target = String(wxid || '').trim() + if (!target) return 0 + let total = 0 + for (const [senderWxid, count] of senderCountMap.entries()) { + if (!Number.isFinite(count) || count <= 0) continue + if (this.isSameWxid(senderWxid, target)) { + total += count + } + } + return total + } + + private async queryFriendFlagMap(usernames: string[]): Promise> { + const result = new Map() + const unique = Array.from( + new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean)) + ) + if (unique.length === 0) return result + + const query = await wcdbService.getContactFriendFlags(unique) + if (query.success && query.map) { + for (const [username, isFriend] of Object.entries(query.map)) { + const normalized = String(username || '').trim() + if (!normalized) continue + result.set(normalized, Boolean(isFriend)) + } + } + + for (const username of unique) { + if (!result.has(username)) { + result.set(username, false) + } + } + + return result + } + private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null { if (!avatarUrl) return null if (avatarUrl.startsWith('data:')) { @@ -2347,16 +4750,29 @@ class ExportService { sessionId: string, outputPath: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void + onProgress?: (progress: ExportProgress) => void, + control?: ExportTaskControl ): Promise<{ success: boolean; error?: string }> { try { + this.throwIfStopRequested(control) const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) + const myInfo = await this.getContactInfo(cleanedMyWxid) + const contactCache = new Map() + const getContactCached = async (username: string) => { + if (contactCache.has(username)) { + return contactCache.get(username)! + } + const result = await wcdbService.getContact(username) + contactCache.set(username, result) + return result + } onProgress?.({ current: 0, @@ -2365,14 +4781,28 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes, + control, + collectProgressReporter + ) const allMessages = collected.rows + const totalMessages = allMessages.length // 如果没有消息,不创建文件 - if (allMessages.length === 0) { + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } + await this.hydrateEmojiCaptionsForMessages(sessionId, allMessages, control) + const voiceMessages = options.exportVoiceAsText ? allMessages.filter(msg => msg.localType === 34) : [] @@ -2381,7 +4811,20 @@ class ExportService { await this.ensureVoiceModel(onProgress) } + const senderUsernames = new Set() + let senderScanIndex = 0 + for (const msg of allMessages) { + if ((senderScanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } + if (msg.senderUsername) senderUsernames.add(msg.senderUsername) + } + senderUsernames.add(sessionId) + senderUsernames.add(cleanedMyWxid) + await this.preloadContacts(senderUsernames, contactCache) + if (isGroup) { + this.throwIfStopRequested(control) await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) } @@ -2424,8 +4867,18 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 20, total: 100, @@ -2433,21 +4886,27 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), + estimatedTotalMessages: totalMessages }) // 并行导出媒体,并发数跟随导出设置 const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { - const mediaKey = `${msg.localType}_${msg.localId}` + this.throwIfStopRequested(control) + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', + imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -2460,16 +4919,19 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) } // ========== 阶段2:并行语音转文字 ========== - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 40, total: 100, @@ -2477,15 +4939,17 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) // 并行转写语音,限制 4 个并发(转写比较耗资源) const VOICE_CONCURRENCY = 4 let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 40, @@ -2504,11 +4968,19 @@ class ExportService { current: 60, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) const chatLabMessages: ChatLabMessage[] = [] + const senderProfileMap = new Map() + let messageIndex = 0 for (const msg of allMessages) { + if ((messageIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { platformId: msg.senderUsername, accountName: msg.senderUsername, @@ -2519,12 +4991,36 @@ class ExportService { const groupNickname = memberInfo.groupNickname || (isGroup ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [msg.senderUsername]) : '') || '' + const senderProfile = isGroup + ? await this.resolveExportDisplayProfile( + msg.senderUsername || cleanedMyWxid, + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (memberInfo.accountName || msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + : { + wxid: msg.senderUsername || cleanedMyWxid, + nickname: memberInfo.accountName || msg.senderUsername || '', + remark: '', + alias: '', + groupNickname, + displayName: memberInfo.accountName || msg.senderUsername || '' + } + if (senderProfile.wxid && !senderProfileMap.has(senderProfile.wxid)) { + senderProfileMap.set(senderProfile.wxid, senderProfile) + } // 确定消息内容 let content: string | null + const mediaKey = this.getMediaCacheKey(msg) + const mediaItem = mediaCache.get(mediaKey) if (msg.localType === 34 && options.exportVoiceAsText) { // 使用预先转写的文字 - content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' + } else if (mediaItem && msg.localType === 3) { + content = mediaItem.relativePath } else { content = this.parseMessageContent( msg.content, @@ -2533,7 +5029,8 @@ class ExportService { msg.createTime, cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption ) } @@ -2555,13 +5052,23 @@ class ExportService { const message: ChatLabMessage = { sender: msg.senderUsername, - accountName: memberInfo.accountName, - groupNickname: groupNickname || undefined, + accountName: senderProfile.displayName || memberInfo.accountName, + groupNickname: (senderProfile.groupNickname || groupNickname) || undefined, timestamp: msg.createTime, type: this.convertMessageType(msg.localType, msg.content), content: content } + const platformMessageId = this.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId) + if (platformMessageId !== '0') { + message.platformMessageId = platformMessageId + } + + const replyToMessageId = this.extractChatLabReplyToMessageId(msg.content) + if (replyToMessageId) { + message.replyToMessageId = replyToMessageId + } + // 如果有聊天记录,添加为嵌套字段 if (msg.chatRecordList && msg.chatRecordList.length > 0) { const chatRecords: any[] = [] @@ -2615,7 +5122,7 @@ class ExportService { break case 47: recordType = 5 // EMOJI - recordContent = '[动画表情]' + recordContent = '[表情包]' break default: recordType = 0 @@ -2657,6 +5164,18 @@ class ExportService { } chatLabMessages.push(message) + if ((chatLabMessages.length % 200) === 0 || chatLabMessages.length === totalMessages) { + const exportProgress = 60 + Math.floor((chatLabMessages.length / totalMessages) * 20) + onProgress?.({ + current: exportProgress, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: chatLabMessages.length + }) + } } const avatarMap = options.exportAvatars @@ -2672,10 +5191,27 @@ class ExportService { : new Map() const sessionAvatar = avatarMap.get(sessionId) - const members = Array.from(collected.memberSet.values()).map((info) => { + const members = await Promise.all(Array.from(collected.memberSet.values()).map(async (info) => { + const profile = isGroup + ? (senderProfileMap.get(info.member.platformId) || await this.resolveExportDisplayProfile( + info.member.platformId, + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + info.member.accountName || info.member.platformId, + this.isSameWxid(info.member.platformId, cleanedMyWxid) ? [rawMyWxid, cleanedMyWxid] : [] + )) + : null + const member = profile + ? { + ...info.member, + accountName: profile.displayName || info.member.accountName, + groupNickname: profile.groupNickname || info.member.groupNickname + } + : info.member const avatar = avatarMap.get(info.member.platformId) - return avatar ? { ...info.member, avatar } : info.member - }) + return avatar ? { ...member, avatar } : member + })) const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup, sessionAvatar) @@ -2690,7 +5226,10 @@ class ExportService { current: 80, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) if (options.format === 'chatlab-jsonl') { @@ -2701,25 +5240,36 @@ class ExportService { meta: chatLabExport.meta })) for (const member of chatLabExport.members) { + this.throwIfStopRequested(control) lines.push(JSON.stringify({ _type: 'member', ...member })) } for (const message of chatLabExport.messages) { + this.throwIfStopRequested(control) lines.push(JSON.stringify({ _type: 'message', ...message })) } - fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8') + this.throwIfStopRequested(control) + await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8') } else { - fs.writeFileSync(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8') + this.throwIfStopRequested(control) + await fs.promises.writeFile(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8') } onProgress?.({ current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } } catch (e) { + if (this.isStopError(e)) { + return { success: false, error: '导出任务已停止' } + } return { success: false, error: String(e) } } } @@ -2731,14 +5281,17 @@ class ExportService { sessionId: string, outputPath: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void + onProgress?: (progress: ExportProgress) => void, + control?: ExportTaskControl ): Promise<{ success: boolean; error?: string }> { try { + this.throwIfStopRequested(control) const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -2760,13 +5313,27 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes, + control, + collectProgressReporter + ) + const totalMessages = collected.rows.length // 如果没有消息,不创建文件 - if (collected.rows.length === 0) { + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } + await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) + const voiceMessages = options.exportVoiceAsText ? collected.rows.filter(msg => msg.localType === 34) : [] @@ -2776,11 +5343,19 @@ class ExportService { } const senderUsernames = new Set() + let senderScanIndex = 0 for (const msg of collected.rows) { + if ((senderScanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } if (msg.senderUsername) senderUsernames.add(msg.senderUsername) } senderUsernames.add(sessionId) await this.preloadContacts(senderUsernames, contactCache) + const senderInfoMap = await this.preloadContactInfos([ + ...Array.from(senderUsernames.values()), + cleanedMyWxid + ]) const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) @@ -2796,8 +5371,18 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 15, total: 100, @@ -2805,20 +5390,26 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), + estimatedTotalMessages: totalMessages }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { - const mediaKey = `${msg.localType}_${msg.localId}` + this.throwIfStopRequested(control) + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', + imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -2831,16 +5422,19 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) } // ========== 阶段2:并行语音转文字 ========== - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 35, total: 100, @@ -2848,14 +5442,16 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 35, @@ -2886,22 +5482,38 @@ class ExportService { current: 55, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) const allMessages: any[] = [] + const senderProfileMap = new Map() + const transferCandidates: Array<{ xml: string; messageRef: any }> = [] + let needSort = false + let lastCreateTime = Number.NEGATIVE_INFINITY + let messageIndex = 0 for (const msg of collected.rows) { - const senderInfo = await this.getContactInfo(msg.senderUsername) + if ((messageIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } + const senderInfo = senderInfoMap.get(msg.senderUsername) || { displayName: msg.senderUsername || '' } const sourceMatch = /[\s\S]*?<\/msgsource>/i.exec(msg.content || '') const source = sourceMatch ? sourceMatch[0] : '' let content: string | null - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) if (msg.localType === 34 && options.exportVoiceAsText) { - content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' - } else if (mediaItem) { + content = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' + } else if (mediaItem && msg.localType !== 47) { content = mediaItem.relativePath } else { content = this.parseMessageContent( @@ -2911,32 +5523,30 @@ class ExportService { undefined, cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption ) } - // 转账消息:追加 "谁转账给谁" 信息 - if (content && this.isTransferExportContent(content) && msg.content) { - const transferDesc = await this.resolveTransferDesc( - msg.content, - cleanedMyWxid, - groupNicknamesMap, - async (username) => { - const c = await getContactCached(username) - if (c.success && c.contact) { - return c.contact.remark || c.contact.nickName || c.contact.alias || username - } - return username - } - ) - if (transferDesc) { - content = this.appendTransferDesc(content, transferDesc) - } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + content = this.buildQuotedReplyText(quotedReplyDisplay) } // 获取发送者信息用于名称显示 const senderWxid = msg.senderUsername - const contact = await getContactCached(senderWxid) + const contact = senderWxid + ? (contactCache.get(senderWxid) ?? { success: false as const }) + : { success: false as const } const senderNickname = contact.success && contact.contact?.nickName ? contact.contact.nickName : (senderInfo.displayName || senderWxid) @@ -2951,6 +5561,15 @@ class ExportService { senderGroupNickname, options.displayNamePreference || 'remark' ) + const existingSenderProfile = senderProfileMap.get(senderWxid) + if (!existingSenderProfile) { + senderProfileMap.set(senderWxid, { + displayName: senderDisplayName, + nickname: senderNickname, + remark: senderRemark, + groupNickname: senderGroupNickname + }) + } const msgObj: any = { localId: allMessages.length + 1, @@ -2966,6 +5585,43 @@ class ExportService { 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) + if (platformMessageId) msgObj.platformMessageId = platformMessageId + + const replyToMessageId = this.getExportReplyToMessageId(msg.content) + if (replyToMessageId) msgObj.replyToMessageId = replyToMessageId + + const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType) + if (appMsgMeta) { + if ( + options.format === 'arkme-json' || + (options.format === 'json' && (appMsgMeta.appMsgKind === 'quote' || appMsgMeta.appMsgKind === 'link')) + ) { + Object.assign(msgObj, appMsgMeta) + } + } + if (quotedReplyDisplay) { + if (quotedReplyDisplay.quotedSender) msgObj.quotedSender = quotedReplyDisplay.quotedSender + if (quotedReplyDisplay.quotedPreview) msgObj.quotedContent = quotedReplyDisplay.quotedPreview + } + + if (options.format === 'arkme-json') { + const contactCardMeta = this.extractArkmeContactCardMeta(msg.content, msg.localType) + if (contactCardMeta) { + Object.assign(msgObj, contactCardMeta) + } + } + + if (content && this.isTransferExportContent(content) && msg.content) { + transferCandidates.push({ xml: msg.content, messageRef: msgObj }) + } + // 位置消息:附加结构化位置字段 if (msg.localType === 48) { if (msg.locationLat != null) msgObj.locationLat = msg.locationLat @@ -2975,21 +5631,76 @@ class ExportService { } allMessages.push(msgObj) + if (msg.createTime < lastCreateTime) needSort = true + lastCreateTime = msg.createTime + if ((allMessages.length % 200) === 0 || allMessages.length === totalMessages) { + const exportProgress = 55 + Math.floor((allMessages.length / totalMessages) * 15) + onProgress?.({ + current: exportProgress, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: allMessages.length + }) + } } - allMessages.sort((a, b) => a.createTime - b.createTime) + if (transferCandidates.length > 0) { + const transferNameCache = new Map() + const transferNamePromiseCache = new Map>() + const resolveDisplayNameByUsername = async (username: string): Promise => { + if (!username) return username + const cachedName = transferNameCache.get(username) + if (cachedName) return cachedName + const pending = transferNamePromiseCache.get(username) + if (pending) return pending + const task = (async () => { + const contactResult = contactCache.get(username) ?? await getContactCached(username) + if (contactResult.success && contactResult.contact) { + return contactResult.contact.remark || contactResult.contact.nickName || contactResult.contact.alias || username + } + return username + })() + transferNamePromiseCache.set(username, task) + const resolved = await task + transferNamePromiseCache.delete(username) + transferNameCache.set(username, resolved) + return resolved + } + + const transferConcurrency = this.getClampedConcurrency(options.exportConcurrency, 4, 8) + await parallelLimit(transferCandidates, transferConcurrency, async (item) => { + this.throwIfStopRequested(control) + const transferDesc = await this.resolveTransferDesc( + item.xml, + cleanedMyWxid, + groupNicknamesMap, + resolveDisplayNameByUsername + ) + if (transferDesc && typeof item.messageRef.content === 'string') { + item.messageRef.content = this.appendTransferDesc(item.messageRef.content, transferDesc) + } + }) + } + + if (needSort) { + allMessages.sort((a, b) => a.createTime - b.createTime) + } onProgress?.({ current: 70, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) - const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup) - // 获取会话的昵称和备注信息 - const sessionContact = await getContactCached(sessionId) + const sessionContact = contactCache.get(sessionId) ?? await getContactCached(sessionId) const sessionNickname = sessionContact.success && sessionContact.contact?.nickName ? sessionContact.contact.nickName : sessionInfo.displayName @@ -3010,55 +5721,277 @@ class ExportService { ) const weflow = this.getWeflowHeader() - const detailedExport: any = { - weflow, - session: { - wxid: sessionId, - nickname: sessionNickname, - remark: sessionRemark, - displayName: sessionDisplayName, - type: isGroup ? '群聊' : '私聊', - lastTimestamp: collected.lastTime, - messageCount: allMessages.length, - avatar: undefined as string | undefined - }, - messages: allMessages + if (options.format === 'arkme-json' && isGroup) { + this.throwIfStopRequested(control) + await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) } - if (options.exportAvatars) { - const avatarMap = await this.exportAvatars( + const avatarMap = options.exportAvatars + ? await this.exportAvatars( [ ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ username, avatarUrl: info.avatarUrl })), - { username: sessionId, avatarUrl: sessionInfo.avatarUrl } + { username: sessionId, avatarUrl: sessionInfo.avatarUrl }, + { username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl } ] ) - const avatars: Record = {} - for (const [username, relPath] of avatarMap.entries()) { - avatars[username] = relPath - } - if (Object.keys(avatars).length > 0) { - detailedExport.session = { - ...detailedExport.session, - avatar: avatars[sessionId] - } - ; (detailedExport as any).avatars = avatars - } + : new Map() + + const sessionPayload: any = { + wxid: sessionId, + nickname: sessionNickname, + remark: sessionRemark, + displayName: sessionDisplayName, + type: isGroup ? '群聊' : '私聊', + lastTimestamp: collected.lastTime, + messageCount: allMessages.length, + avatar: avatarMap.get(sessionId) } - fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8') + if (options.format === 'arkme-json') { + const senderIdMap = new Map() + const senders: Array<{ + senderID: number + wxid: string + displayName: string + nickname: string + remark?: string + groupNickname?: string + avatar?: string + }> = [] + const ensureSenderId = (senderWxidRaw: string): number => { + const senderWxid = String(senderWxidRaw || '').trim() || 'unknown' + const existed = senderIdMap.get(senderWxid) + if (existed) return existed + + const senderID = senders.length + 1 + senderIdMap.set(senderWxid, senderID) + + const profile = senderProfileMap.get(senderWxid) + const senderItem: { + senderID: number + wxid: string + displayName: string + nickname: string + remark?: string + groupNickname?: string + avatar?: string + } = { + senderID, + wxid: senderWxid, + displayName: profile?.displayName || senderWxid, + nickname: profile?.nickname || profile?.displayName || senderWxid + } + if (profile?.remark) senderItem.remark = profile.remark + if (profile?.groupNickname) senderItem.groupNickname = profile.groupNickname + const avatar = avatarMap.get(senderWxid) + if (avatar) senderItem.avatar = avatar + + senders.push(senderItem) + return senderID + } + + const compactMessages = allMessages.map((message) => { + this.throwIfStopRequested(control) + const senderID = ensureSenderId(String(message.senderUsername || '')) + const compactMessage: any = { + localId: message.localId, + createTime: message.createTime, + formattedTime: message.formattedTime, + type: message.type, + localType: message.localType, + content: message.content, + isSend: message.isSend, + senderID, + source: message.source + } + if (message.platformMessageId) compactMessage.platformMessageId = message.platformMessageId + if (message.replyToMessageId) compactMessage.replyToMessageId = message.replyToMessageId + if (message.locationLat != null) compactMessage.locationLat = message.locationLat + if (message.locationLng != null) compactMessage.locationLng = message.locationLng + if (message.locationPoiname) compactMessage.locationPoiname = message.locationPoiname + if (message.locationLabel) compactMessage.locationLabel = message.locationLabel + if (message.appMsgType) compactMessage.appMsgType = message.appMsgType + if (message.appMsgKind) compactMessage.appMsgKind = message.appMsgKind + if (message.appMsgDesc) compactMessage.appMsgDesc = message.appMsgDesc + if (message.appMsgAppName) compactMessage.appMsgAppName = message.appMsgAppName + if (message.appMsgSourceName) compactMessage.appMsgSourceName = message.appMsgSourceName + if (message.appMsgSourceUsername) compactMessage.appMsgSourceUsername = message.appMsgSourceUsername + if (message.appMsgThumbUrl) compactMessage.appMsgThumbUrl = message.appMsgThumbUrl + if (message.quotedContent) compactMessage.quotedContent = message.quotedContent + if (message.quotedSender) compactMessage.quotedSender = message.quotedSender + if (message.quotedType) compactMessage.quotedType = message.quotedType + if (message.linkTitle) compactMessage.linkTitle = message.linkTitle + if (message.linkUrl) compactMessage.linkUrl = message.linkUrl + 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.finderDesc) compactMessage.finderDesc = message.finderDesc + if (message.finderUsername) compactMessage.finderUsername = message.finderUsername + if (message.finderNickname) compactMessage.finderNickname = message.finderNickname + if (message.finderCoverUrl) compactMessage.finderCoverUrl = message.finderCoverUrl + if (message.finderAvatar) compactMessage.finderAvatar = message.finderAvatar + if (message.finderDuration != null) compactMessage.finderDuration = message.finderDuration + if (message.finderObjectId) compactMessage.finderObjectId = message.finderObjectId + if (message.finderUrl) compactMessage.finderUrl = message.finderUrl + if (message.musicTitle) compactMessage.musicTitle = message.musicTitle + if (message.musicUrl) compactMessage.musicUrl = message.musicUrl + if (message.musicDataUrl) compactMessage.musicDataUrl = message.musicDataUrl + if (message.musicAlbumUrl) compactMessage.musicAlbumUrl = message.musicAlbumUrl + if (message.musicCoverUrl) compactMessage.musicCoverUrl = message.musicCoverUrl + if (message.musicSinger) compactMessage.musicSinger = message.musicSinger + if (message.musicAppName) compactMessage.musicAppName = message.musicAppName + if (message.musicSourceName) compactMessage.musicSourceName = message.musicSourceName + if (message.musicDuration != null) compactMessage.musicDuration = message.musicDuration + if (message.cardKind) compactMessage.cardKind = message.cardKind + if (message.contactCardWxid) compactMessage.contactCardWxid = message.contactCardWxid + if (message.contactCardNickname) compactMessage.contactCardNickname = message.contactCardNickname + if (message.contactCardAlias) compactMessage.contactCardAlias = message.contactCardAlias + if (message.contactCardRemark) compactMessage.contactCardRemark = message.contactCardRemark + if (message.contactCardGender != null) compactMessage.contactCardGender = message.contactCardGender + if (message.contactCardProvince) compactMessage.contactCardProvince = message.contactCardProvince + if (message.contactCardCity) compactMessage.contactCardCity = message.contactCardCity + if (message.contactCardSignature) compactMessage.contactCardSignature = message.contactCardSignature + if (message.contactCardAvatar) compactMessage.contactCardAvatar = message.contactCardAvatar + return compactMessage + }) + + const arkmeSession: any = { + ...sessionPayload + } + let groupMembers: Array<{ + wxid: string + displayName: string + nickname: string + remark: string + alias: string + groupNickname?: string + isFriend: boolean + messageCount: number + avatar?: string + }> | undefined + + if (isGroup) { + const memberUsernames = Array.from(collected.memberSet.keys()).filter(Boolean) + await this.preloadContacts(memberUsernames, contactCache) + const friendLookupUsernames = this.buildGroupNicknameIdCandidates(memberUsernames) + const friendFlagMap = await this.queryFriendFlagMap(friendLookupUsernames) + const groupStatsResult = await wcdbService.getGroupStats(sessionId, 0, 0) + const groupSenderCountMap = groupStatsResult.success && groupStatsResult.data + ? this.extractGroupSenderCountMap(groupStatsResult.data, sessionId) + : new Map() + + groupMembers = [] + for (const memberWxid of memberUsernames) { + this.throwIfStopRequested(control) + const member = collected.memberSet.get(memberWxid)?.member + const contactResult = await getContactCached(memberWxid) + const contact = contactResult.success ? contactResult.contact : null + const nickname = String(contact?.nickName || contact?.nick_name || member?.accountName || memberWxid) + const remark = String(contact?.remark || '') + const alias = String(contact?.alias || '') + const groupNickname = member?.groupNickname || this.resolveGroupNicknameByCandidates( + groupNicknamesMap, + [memberWxid, contact?.username, contact?.userName, contact?.encryptUsername, contact?.encryptUserName, alias] + ) || '' + const displayName = this.getPreferredDisplayName( + memberWxid, + nickname, + remark, + groupNickname, + options.displayNamePreference || 'remark' + ) + + const groupMember: { + wxid: string + displayName: string + nickname: string + remark: string + alias: string + groupNickname?: string + isFriend: boolean + messageCount: number + avatar?: string + } = { + wxid: memberWxid, + displayName, + nickname, + remark, + alias, + isFriend: this.buildGroupNicknameIdCandidates([memberWxid]).some((candidate) => friendFlagMap.get(candidate) === true), + messageCount: this.sumSenderCountsByIdentity(groupSenderCountMap, memberWxid) + } + if (groupNickname) groupMember.groupNickname = groupNickname + const avatar = avatarMap.get(memberWxid) + if (avatar) groupMember.avatar = avatar + groupMembers.push(groupMember) + } + groupMembers.sort((a, b) => { + if (b.messageCount !== a.messageCount) return b.messageCount - a.messageCount + return String(a.displayName || a.wxid).localeCompare(String(b.displayName || b.wxid), 'zh-CN') + }) + } + + const arkmeExport: any = { + weflow: { + ...weflow, + format: 'arkme-json' + }, + session: arkmeSession, + senders, + messages: compactMessages + } + if (groupMembers) { + arkmeExport.groupMembers = groupMembers + } + + this.throwIfStopRequested(control) + await fs.promises.writeFile(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8') + } else { + const detailedExport: any = { + weflow, + session: sessionPayload, + messages: allMessages + } + + if (options.exportAvatars) { + const avatars: Record = {} + for (const [username, relPath] of avatarMap.entries()) { + avatars[username] = relPath + } + if (Object.keys(avatars).length > 0) { + detailedExport.session = { + ...detailedExport.session, + avatar: avatars[sessionId] + } + ; (detailedExport as any).avatars = avatars + } + } + + this.throwIfStopRequested(control) + await fs.promises.writeFile(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8') + } onProgress?.({ current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } } catch (e) { + if (this.isStopError(e)) { + return { success: false, error: '导出任务已停止' } + } return { success: false, error: String(e) } } } @@ -3070,14 +6003,17 @@ class ExportService { sessionId: string, outputPath: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void + onProgress?: (progress: ExportProgress) => void, + control?: ExportTaskControl ): Promise<{ success: boolean; error?: string }> { try { + this.throwIfStopRequested(control) const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -3104,13 +6040,27 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes, + control, + collectProgressReporter + ) + const totalMessages = collected.rows.length // 如果没有消息,不创建文件 - if (collected.rows.length === 0) { + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } + await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) + const voiceMessages = options.exportVoiceAsText ? collected.rows.filter(msg => msg.localType === 34) : [] @@ -3120,7 +6070,11 @@ class ExportService { } const senderUsernames = new Set() + let senderScanIndex = 0 for (const msg of collected.rows) { + if ((senderScanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } if (msg.senderUsername) senderUsernames.add(msg.senderUsername) } senderUsernames.add(sessionId) @@ -3130,7 +6084,10 @@ class ExportService { current: 30, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) // 创建 Excel 工作簿 @@ -3237,13 +6194,14 @@ class ExportService { } // 预加载群昵称 (仅群聊且完整列模式) - const groupNicknameCandidates = (isGroup && !useCompactColumns) + const groupNicknameCandidates = isGroup ? this.buildGroupNicknameIdCandidates([ ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid + cleanedMyWxid, + rawMyWxid ]) : [] - const groupNicknamesMap = (isGroup && !useCompactColumns) + const groupNicknamesMap = isGroup ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) : new Map() @@ -3266,8 +6224,18 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 35, total: 100, @@ -3275,20 +6243,26 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), + estimatedTotalMessages: totalMessages }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { - const mediaKey = `${msg.localType}_${msg.localId}` + this.throwIfStopRequested(control) + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', + imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -3301,16 +6275,19 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) } // ========== 并行预处理:语音转文字 ========== - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 50, total: 100, @@ -3318,14 +6295,16 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 50, @@ -3339,15 +6318,44 @@ class ExportService { }) } + const shouldUseStreamingWriter = totalMessages > 20000 + if (shouldUseStreamingWriter) { + return this.exportSessionToExcelStreaming({ + outputPath, + options, + sessionId, + sessionInfo, + myInfo, + cleanedMyWxid, + rawMyWxid, + isGroup, + sortedMessages, + mediaCache, + voiceTranscriptMap, + getContactCached, + groupNicknamesMap, + onProgress, + control, + totalMessages + }) + } + onProgress?.({ current: 65, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) // ========== 写入 Excel 行 ========== - for (let i = 0; i < sortedMessages.length; i++) { + const senderProfileCache = new Map() + for (let i = 0; i < totalMessages; i++) { + if ((i & 0x7f) === 0) { + this.throwIfStopRequested(control) + } const msg = sortedMessages[i] // 确定发送者信息 @@ -3357,30 +6365,31 @@ class ExportService { let senderRemark: string = '' let senderGroupNickname: string = '' // 群昵称 - - if (msg.isSend) { + if (isGroup) { + const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}` + let senderProfile = senderProfileCache.get(senderProfileKey) + if (!senderProfile) { + senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderProfileKey, senderProfile) + } + senderWxid = senderProfile.wxid + senderNickname = senderProfile.nickname + senderRemark = senderProfile.remark + senderGroupNickname = senderProfile.groupNickname + senderRole = senderProfile.displayName + } else if (msg.isSend) { // 我发送的消息 senderRole = '我' senderWxid = cleanedMyWxid senderNickname = myInfo.displayName || cleanedMyWxid senderRemark = '' - } else if (isGroup && msg.senderUsername) { - // 群消息 - senderWxid = msg.senderUsername - - // 用 getContact 获取联系人详情,分别取昵称和备注 - const contactDetail = await getContactCached(msg.senderUsername) - if (contactDetail.success && contactDetail.contact) { - // nickName 才是真正的昵称 - senderNickname = contactDetail.contact.nickName || msg.senderUsername - senderRemark = contactDetail.contact.remark || '' - // 身份:有备注显示备注,没有显示昵称 - senderRole = senderRemark || senderNickname - } else { - senderNickname = msg.senderUsername - senderRemark = '' - senderRole = msg.senderUsername - } } else { // 单聊对方消息 - 用 getContact 获取联系人详情 senderWxid = sessionId @@ -3396,16 +6405,10 @@ class ExportService { } } - // 获取群昵称 (仅群聊且完整列模式) - if (isGroup && !useCompactColumns && senderWxid) { - senderGroupNickname = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid]) - } - - const row = worksheet.getRow(currentRow) row.height = 24 - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText const contentValue = shouldUseTranscript @@ -3413,20 +6416,22 @@ class ExportService { msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId), + voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption ) - : (mediaItem?.relativePath + : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined) || this.formatPlainExportContent( msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId), + voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption )) // 转账消息:追加 "谁转账给谁" 信息 @@ -3449,6 +6454,20 @@ class ExportService { } } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) + } + // 调试日志 if (msg.localType === 3 || msg.localType === 47) { } @@ -3469,14 +6488,6 @@ class ExportService { worksheet.getCell(currentRow, 9).value = enrichedContentValue } - // 设置每个单元格的样式 - const maxColumns = useCompactColumns ? 5 : 9 - for (let col = 1; col <= maxColumns; col++) { - const cell = worksheet.getCell(currentRow, col) - cell.font = { name: 'Calibri', size: 11 } - cell.alignment = { vertical: 'middle', wrapText: false } - } - currentRow++ // 每处理 100 条消息报告一次进度 @@ -3486,7 +6497,10 @@ class ExportService { current: progress, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 }) } } @@ -3495,21 +6509,32 @@ class ExportService { current: 90, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) // 写入文件 + this.throwIfStopRequested(control) await workbook.xlsx.writeFile(outputPath) onProgress?.({ current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } } catch (e) { + if (this.isStopError(e)) { + return { success: false, error: '导出任务已停止' } + } // 处理文件被占用的错误 if (e instanceof Error) { if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) { @@ -3521,6 +6546,252 @@ class ExportService { } } + private async exportSessionToExcelStreaming(params: { + outputPath: string + options: ExportOptions + sessionId: string + sessionInfo: { displayName: string } + myInfo: { displayName: string } + cleanedMyWxid: string + rawMyWxid: string + isGroup: boolean + sortedMessages: any[] + mediaCache: Map + voiceTranscriptMap: Map + getContactCached: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }> + groupNicknamesMap: Map + onProgress?: (progress: ExportProgress) => void + control?: ExportTaskControl + totalMessages: number + }): Promise<{ success: boolean; error?: string }> { + const { + outputPath, + options, + sessionId, + sessionInfo, + myInfo, + cleanedMyWxid, + rawMyWxid, + isGroup, + sortedMessages, + mediaCache, + voiceTranscriptMap, + getContactCached, + groupNicknamesMap, + onProgress, + control, + totalMessages + } = params + + try { + const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ + filename: outputPath, + useStyles: true, + useSharedStrings: false + }) + const worksheet = workbook.addWorksheet('聊天记录') + const useCompactColumns = options.excelCompactColumns === true + const senderProfileCache = new Map() + + worksheet.columns = useCompactColumns + ? [ + { width: 8 }, + { width: 20 }, + { width: 18 }, + { width: 12 }, + { width: 50 } + ] + : [ + { width: 8 }, + { width: 20 }, + { width: 18 }, + { width: 25 }, + { width: 18 }, + { width: 18 }, + { width: 15 }, + { width: 12 }, + { width: 50 } + ] + + const appendRow = (values: any[]) => { + const row = worksheet.addRow(values) + row.commit() + } + + appendRow(['会话信息']) + appendRow(['微信ID', sessionId, '昵称', sessionInfo.displayName || sessionId]) + appendRow(['导出工具', 'WeFlow', '导出时间', this.formatTimestamp(Math.floor(Date.now() / 1000))]) + appendRow([]) + appendRow(useCompactColumns + ? ['序号', '时间', '发送者身份', '消息类型', '内容'] + : ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容']) + + for (let i = 0; i < totalMessages; i++) { + if ((i & 0x7f) === 0) this.throwIfStopRequested(control) + const msg = sortedMessages[i] + + let senderRole: string + let senderWxid: string + let senderNickname: string + let senderRemark = '' + let senderGroupNickname = '' + + if (isGroup) { + const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}` + let senderProfile = senderProfileCache.get(senderProfileKey) + if (!senderProfile) { + senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderProfileKey, senderProfile) + } + senderWxid = senderProfile.wxid + senderNickname = senderProfile.nickname + senderRemark = senderProfile.remark + senderGroupNickname = senderProfile.groupNickname + senderRole = senderProfile.displayName + } else if (msg.isSend) { + senderRole = '我' + senderWxid = cleanedMyWxid + senderNickname = myInfo.displayName || cleanedMyWxid + } else { + senderWxid = sessionId + const contactDetail = await getContactCached(sessionId) + if (contactDetail.success && contactDetail.contact) { + senderNickname = contactDetail.contact.nickName || sessionId + senderRemark = contactDetail.contact.remark || '' + senderRole = senderRemark || senderNickname + } else { + senderNickname = sessionInfo.displayName || sessionId + senderRole = senderNickname + } + } + + const mediaKey = this.getMediaCacheKey(msg) + const mediaItem = mediaCache.get(mediaKey) + const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText + const contentValue = shouldUseTranscript + ? this.formatPlainExportContent( + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(this.getStableMessageKey(msg)), + cleanedMyWxid, + msg.senderUsername, + msg.isSend, + msg.emojiCaption + ) + : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined) + || this.formatPlainExportContent( + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(this.getStableMessageKey(msg)), + cleanedMyWxid, + msg.senderUsername, + msg.isSend, + msg.emojiCaption + )) + + let enrichedContentValue = contentValue + if (this.isTransferExportContent(contentValue) && msg.content) { + const transferDesc = await this.resolveTransferDesc( + msg.content, + cleanedMyWxid, + groupNicknamesMap, + async (username) => { + const c = await getContactCached(username) + if (c.success && c.contact) { + return c.contact.remark || c.contact.nickName || c.contact.alias || username + } + return username + } + ) + if (transferDesc) { + enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc) + } + } + + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) + } + + appendRow(useCompactColumns + ? [ + i + 1, + this.formatTimestamp(msg.createTime), + senderRole, + this.getMessageTypeName(msg.localType), + enrichedContentValue + ] + : [ + i + 1, + this.formatTimestamp(msg.createTime), + senderNickname, + senderWxid, + senderRemark, + senderGroupNickname, + senderRole, + this.getMessageTypeName(msg.localType), + enrichedContentValue + ]) + + if ((i + 1) % 200 === 0) { + onProgress?.({ + current: 65 + Math.floor((i + 1) / totalMessages * 25), + total: 100, + currentSession: sessionInfo.displayName, + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 + }) + } + } + + worksheet.commit() + await workbook.commit() + + onProgress?.({ + current: 100, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 + }) + + return { success: true } + } catch (e) { + if (this.isStopError(e)) { + return { success: false, error: '导出任务已停止' } + } + if (e instanceof Error) { + if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) { + return { success: false, error: '文件已经打开,请关闭后再导出' } + } + } + return { success: false, error: String(e) } + } + } + /** * 确保语音转写模型已下载 */ @@ -3563,14 +6834,17 @@ class ExportService { sessionId: string, outputPath: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void + onProgress?: (progress: ExportProgress) => void, + control?: ExportTaskControl ): Promise<{ success: boolean; error?: string }> { try { + this.throwIfStopRequested(control) const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -3591,13 +6865,27 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes, + control, + collectProgressReporter + ) + const totalMessages = collected.rows.length // 如果没有消息,不创建文件 - if (collected.rows.length === 0) { + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } + await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) + const voiceMessages = options.exportVoiceAsText ? collected.rows.filter(msg => msg.localType === 34) : [] @@ -3607,7 +6895,11 @@ class ExportService { } const senderUsernames = new Set() + let senderScanIndex = 0 for (const msg of collected.rows) { + if ((senderScanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } if (msg.senderUsername) senderUsernames.add(msg.senderUsername) } senderUsernames.add(sessionId) @@ -3618,7 +6910,8 @@ class ExportService { ? this.buildGroupNicknameIdCandidates([ ...Array.from(senderUsernames.values()), ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid + cleanedMyWxid, + rawMyWxid ]) : [] const groupNicknamesMap = isGroup @@ -3639,8 +6932,18 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 25, total: 100, @@ -3648,20 +6951,26 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), + estimatedTotalMessages: totalMessages }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { - const mediaKey = `${msg.localType}_${msg.localId}` + this.throwIfStopRequested(control) + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', + imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -3674,15 +6983,18 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) } - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 45, total: 100, @@ -3690,14 +7002,16 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 45, @@ -3715,14 +7029,21 @@ class ExportService { current: 60, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) const lines: string[] = [] + const senderProfileCache = new Map() - for (let i = 0; i < sortedMessages.length; i++) { + for (let i = 0; i < totalMessages; i++) { + if ((i & 0x7f) === 0) { + this.throwIfStopRequested(control) + } const msg = sortedMessages[i] - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText const contentValue = shouldUseTranscript @@ -3730,20 +7051,22 @@ class ExportService { msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId), + voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption ) - : (mediaItem?.relativePath + : ((msg.localType !== 47 ? mediaItem?.relativePath : undefined) || this.formatPlainExportContent( msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId), + voiceTranscriptMap.get(this.getStableMessageKey(msg)), cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption )) // 转账消息:追加 "谁转账给谁" 信息 @@ -3766,26 +7089,47 @@ class ExportService { } } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) + } + let senderRole: string let senderWxid: string let senderNickname: string let senderRemark = '' - if (msg.isSend) { + if (isGroup) { + const senderProfileKey = `${msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${msg.isSend ? '1' : '0'}` + let senderProfile = senderProfileCache.get(senderProfileKey) + if (!senderProfile) { + senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderProfileKey, senderProfile) + } + senderWxid = senderProfile.wxid + senderNickname = senderProfile.nickname + senderRemark = senderProfile.remark + senderRole = senderProfile.displayName + } else if (msg.isSend) { senderRole = '我' senderWxid = cleanedMyWxid senderNickname = myInfo.displayName || cleanedMyWxid - } else if (isGroup && msg.senderUsername) { - senderWxid = msg.senderUsername - const contactDetail = await getContactCached(msg.senderUsername) - if (contactDetail.success && contactDetail.contact) { - senderNickname = contactDetail.contact.nickName || msg.senderUsername - senderRemark = contactDetail.contact.remark || '' - senderRole = senderRemark || senderNickname - } else { - senderNickname = msg.senderUsername - senderRole = msg.senderUsername - } } else { senderWxid = sessionId const contactDetail = await getContactCached(sessionId) @@ -3809,7 +7153,10 @@ class ExportService { current: progress, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 }) } } @@ -3818,20 +7165,31 @@ class ExportService { current: 92, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) - fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8') + this.throwIfStopRequested(control) + await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8') onProgress?.({ current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } } catch (e) { + if (this.isStopError(e)) { + return { success: false, error: '导出任务已停止' } + } return { success: false, error: String(e) } } } @@ -3843,14 +7201,17 @@ class ExportService { sessionId: string, outputPath: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void + onProgress?: (progress: ExportProgress) => void, + control?: ExportTaskControl ): Promise<{ success: boolean; error?: string }> { try { + this.throwIfStopRequested(control) const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -3871,13 +7232,31 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) - if (collected.rows.length === 0) { + const collectParams = this.resolveCollectParams(options) + const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes, + control, + collectProgressReporter + ) + let totalMessages = collected.rows.length + if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } + await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) + const senderUsernames = new Set() + let senderScanIndex = 0 for (const msg of collected.rows) { + if ((senderScanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } if (msg.senderUsername) senderUsernames.add(msg.senderUsername) } senderUsernames.add(sessionId) @@ -3887,14 +7266,21 @@ class ExportService { ? this.buildGroupNicknameIdCandidates([ ...Array.from(senderUsernames.values()), ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid + cleanedMyWxid, + rawMyWxid ]) : [] const groupNicknamesMap = isGroup ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) : new Map() - const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) + const sortedMessages = collected.rows + .sort((a, b) => a.createTime - b.createTime) + .filter((msg) => !this.isQuotedReplyMessage(msg.localType, msg.content || '')) + totalMessages = sortedMessages.length + if (totalMessages === 0) { + return { success: false, error: '该会话在指定时间范围内没有可导出的消息' } + } const voiceMessages = options.exportVoiceAsText ? sortedMessages.filter(msg => msg.localType === 34) @@ -3916,8 +7302,18 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 25, total: 100, @@ -3925,20 +7321,26 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), + estimatedTotalMessages: totalMessages }) const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) let mediaExported = 0 await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { - const mediaKey = `${msg.localType}_${msg.localId}` + this.throwIfStopRequested(control) + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText + exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', + imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -3951,15 +7353,18 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) } - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 45, total: 100, @@ -3967,14 +7372,16 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 45, @@ -3992,15 +7399,22 @@ class ExportService { current: 60, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) const lines: string[] = [] lines.push('id,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime') + const senderProfileCache = new Map() - for (let i = 0; i < sortedMessages.length; i++) { + for (let i = 0; i < totalMessages; i++) { + if ((i & 0x7f) === 0) { + this.throwIfStopRequested(control) + } const msg = sortedMessages[i] - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) || null const typeName = this.getWeCloneTypeName(msg.localType, msg.content || '') @@ -4012,7 +7426,22 @@ class ExportService { } let talker = myInfo.displayName || '我' - if (!msg.isSend) { + if (isGroup) { + const senderProfileKey = `${msg.isSend ? cleanedMyWxid : senderWxid}::${msg.isSend ? '1' : '0'}` + let senderProfile = senderProfileCache.get(senderProfileKey) + if (!senderProfile) { + senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : senderWxid, + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : senderWxid, + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderProfileKey, senderProfile) + } + talker = senderProfile.displayName + } else if (!msg.isSend) { const contactDetail = await getContactCached(senderWxid) const senderNickname = contactDetail.success && contactDetail.contact ? (contactDetail.contact.nickName || senderWxid) @@ -4033,7 +7462,7 @@ class ExportService { } const msgText = msg.localType === 34 && options.exportVoiceAsText - ? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]') + ? (voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]') : (this.parseMessageContent( msg.content, msg.localType, @@ -4041,13 +7470,15 @@ class ExportService { msg.createTime, cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption ) || '') const src = this.getWeCloneSource(msg, typeName, mediaItem) + const platformMessageId = this.getExportPlatformMessageId(msg) || '' const row = [ i + 1, - i + 1, + platformMessageId, typeName, msg.isSend ? 1 : 0, talker, @@ -4064,7 +7495,10 @@ class ExportService { current: progress, total: 100, currentSession: sessionInfo.displayName, - phase: 'exporting' + phase: 'exporting', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 }) } } @@ -4073,20 +7507,31 @@ class ExportService { current: 92, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages }) - fs.writeFileSync(outputPath, `\uFEFF${lines.join('\r\n')}`, 'utf-8') + this.throwIfStopRequested(control) + await fs.promises.writeFile(outputPath, `\uFEFF${lines.join('\r\n')}`, 'utf-8') onProgress?.({ current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) return { success: true } } catch (e) { + if (this.isStopError(e)) { + return { success: false, error: '导出任务已停止' } + } return { success: false, error: String(e) } } } @@ -4182,14 +7627,17 @@ class ExportService { sessionId: string, outputPath: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void + onProgress?: (progress: ExportProgress) => void, + control?: ExportTaskControl ): Promise<{ success: boolean; error?: string }> { try { + this.throwIfStopRequested(control) const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) const contactCache = new Map() @@ -4213,15 +7661,33 @@ class ExportService { await this.ensureVoiceModel(onProgress) } - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) + const collectParams = this.resolveCollectParams(options) + const collectProgressReporter = this.createCollectProgressReporter(sessionInfo.displayName, onProgress, 5) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + collectParams.mode, + collectParams.targetMediaTypes, + control, + collectProgressReporter + ) // 如果没有消息,不创建文件 if (collected.rows.length === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } + const totalMessages = collected.rows.length + + await this.hydrateEmojiCaptionsForMessages(sessionId, collected.rows, control) const senderUsernames = new Set() + let senderScanIndex = 0 for (const msg of collected.rows) { + if ((senderScanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } if (msg.senderUsername) senderUsernames.add(msg.senderUsername) } senderUsernames.add(sessionId) @@ -4231,7 +7697,8 @@ class ExportService { ? this.buildGroupNicknameIdCandidates([ ...Array.from(senderUsernames.values()), ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid + cleanedMyWxid, + rawMyWxid ]) : [] const groupNicknamesMap = isGroup @@ -4239,6 +7706,7 @@ class ExportService { : new Map() if (isGroup) { + this.throwIfStopRequested(control) await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) } const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) @@ -4255,8 +7723,18 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) + const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) + if (voiceMediaMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) + } + onProgress?.({ current: 20, total: 100, @@ -4264,21 +7742,27 @@ class ExportService { phase: 'exporting-media', phaseProgress: 0, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 0/${mediaMessages.length}` + phaseLabel: `导出媒体 0/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot(), + estimatedTotalMessages: totalMessages }) const MEDIA_CONCURRENCY = 6 let mediaExported = 0 await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { - const mediaKey = `${msg.localType}_${msg.localId}` + this.throwIfStopRequested(control) + const mediaKey = this.getMediaCacheKey(msg) if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportEmojis: options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, + includeVideoPoster: options.format === 'html', includeVoiceWithTranscript: true, - exportVideos: options.exportVideos + exportVideos: options.exportVideos, + imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, + dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) } @@ -4291,7 +7775,8 @@ class ExportService { phase: 'exporting-media', phaseProgress: mediaExported, phaseTotal: mediaMessages.length, - phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}` + phaseLabel: `导出媒体 ${mediaExported}/${mediaMessages.length}`, + ...this.getMediaTelemetrySnapshot() }) } }) @@ -4301,9 +7786,11 @@ class ExportService { const voiceMessages = useVoiceTranscript ? sortedMessages.filter(msg => msg.localType === 34) : [] - const voiceTranscriptMap = new Map() + const voiceTranscriptMap = new Map() if (voiceMessages.length > 0) { + await this.preloadVoiceWavCache(sessionId, voiceMessages, control) + onProgress?.({ current: 40, total: 100, @@ -4311,14 +7798,16 @@ class ExportService { phase: 'exporting-voice', phaseProgress: 0, phaseTotal: voiceMessages.length, - phaseLabel: `语音转文字 0/${voiceMessages.length}` + phaseLabel: `语音转文字 0/${voiceMessages.length}`, + estimatedTotalMessages: totalMessages }) const VOICE_CONCURRENCY = 4 let voiceTranscribed = 0 await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + this.throwIfStopRequested(control) const transcript = await this.transcribeVoice(sessionId, String(msg.localId), msg.createTime, msg.senderUsername) - voiceTranscriptMap.set(msg.localId, transcript) + voiceTranscriptMap.set(this.getStableMessageKey(msg), transcript) voiceTranscribed++ onProgress?.({ current: 40, @@ -4350,7 +7839,10 @@ class ExportService { current: 60, total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: 0 }) // ================= BEGIN STREAM WRITING ================= @@ -4360,6 +7852,7 @@ class ExportService { const writePromise = (str: string) => { return new Promise((resolve, reject) => { + this.throwIfStopRequested(control) if (!stream.write(str)) { stream.once('drain', resolve) } else { @@ -4410,6 +7903,7 @@ class ExportService { // Pre-build avatar HTML lookup to avoid per-message rebuilds const avatarHtmlCache = new Map() + const senderProfileCache = new Map() const getAvatarHtml = (username: string, name: string): string => { const cached = avatarHtmlCache.get(username) if (cached !== undefined) return cached @@ -4425,35 +7919,67 @@ class ExportService { const WRITE_BATCH = 100 let writeBuf: string[] = [] - for (let i = 0; i < sortedMessages.length; i++) { + for (let i = 0; i < totalMessages; i++) { + if ((i & 0x7f) === 0) { + this.throwIfStopRequested(control) + } const msg = sortedMessages[i] - const mediaKey = `${msg.localType}_${msg.localId}` + const mediaKey = this.getMediaCacheKey(msg) const mediaItem = mediaCache.get(mediaKey) || null const isSenderMe = msg.isSend const senderInfo = collected.memberSet.get(msg.senderUsername)?.member - const senderName = isSenderMe - ? (myInfo.displayName || '我') - : (isGroup - ? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername) - : (sessionInfo.displayName || sessionId)) + const senderName = isGroup + ? (() => { + const senderKey = `${isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${isSenderMe ? '1' : '0'}` + const cached = senderProfileCache.get(senderKey) + if (cached) return cached.displayName + return '' + })() + : (isSenderMe ? (myInfo.displayName || '我') : (sessionInfo.displayName || sessionId)) + const resolvedSenderName = isGroup && !senderName + ? (await (async () => { + const senderKey = `${isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid)}::${isSenderMe ? '1' : '0'}` + const profile = await this.resolveExportDisplayProfile( + isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + isSenderMe ? (myInfo.displayName || cleanedMyWxid) : (senderInfo?.accountName || msg.senderUsername || ''), + isSenderMe ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderProfileCache.set(senderKey, profile) + return profile.displayName + })()) + : senderName - const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, senderName) + const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, resolvedSenderName) const timeText = this.formatTimestamp(msg.createTime) const typeName = this.getMessageTypeName(msg.localType) + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) - let textContent = this.formatHtmlMessageText( + let textContent = quotedReplyDisplay?.replyText || this.formatHtmlMessageText( msg.content, msg.localType, cleanedMyWxid, msg.senderUsername, - msg.isSend + msg.isSend, + msg.emojiCaption ) if (msg.localType === 34 && useVoiceTranscript) { - textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + textContent = voiceTranscriptMap.get(this.getStableMessageKey(msg)) || '[语音消息 - 转文字失败]' } - if (mediaItem && (msg.localType === 3 || msg.localType === 47)) { + if (mediaItem && msg.localType === 3) { textContent = '' } if (this.isTransferExportContent(textContent) && msg.content) { @@ -4474,7 +8000,7 @@ class ExportService { } } - const linkCard = this.extractHtmlLinkCard(msg.content, msg.localType) + const linkCard = quotedReplyDisplay ? null : this.extractHtmlLinkCard(msg.content, msg.localType) let mediaHtml = '' if (mediaItem?.kind === 'image') { @@ -4490,25 +8016,40 @@ class ExportService { mediaHtml = `` } - const textHtml = linkCard - ? `` - : (textContent - ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` - : '') + const textHtml = quotedReplyDisplay + ? (() => { + const quotedSenderHtml = quotedReplyDisplay.quotedSender + ? `
${this.escapeHtml(quotedReplyDisplay.quotedSender)}
` + : '' + const quotedPreviewHtml = `
${this.renderTextWithEmoji(quotedReplyDisplay.quotedPreview).replace(/\r?\n/g, '
')}
` + const replyTextHtml = textContent + ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` + : '' + return `
${quotedSenderHtml}${quotedPreviewHtml}
${replyTextHtml}` + })() + : (linkCard + ? `` + : (textContent + ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` + : '')) const senderNameHtml = isGroup - ? `
${this.escapeHtml(senderName)}
` + ? `
${this.escapeHtml(resolvedSenderName)}
` : '' const timeHtml = `
${this.escapeHtml(timeText)}
` const messageBody = `${timeHtml}${senderNameHtml}
${mediaHtml}${textHtml}
` + const platformMessageId = this.getExportPlatformMessageId(msg) + const replyToMessageId = this.getExportReplyToMessageId(msg.content) // Compact JSON object - const itemObj = { + const itemObj: Record = { i: i + 1, // index t: msg.createTime, // timestamp s: isSenderMe ? 1 : 0, // isSend a: avatarHtml, // avatar HTML b: messageBody // body HTML } + if (platformMessageId) itemObj.p = platformMessageId + if (replyToMessageId) itemObj.r = replyToMessageId writeBuf.push(JSON.stringify(itemObj)) @@ -4526,7 +8067,10 @@ class ExportService { current: 60 + Math.floor((i + 1) / sortedMessages.length * 30), total: 100, currentSession: sessionInfo.displayName, - phase: 'writing' + phase: 'writing', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: i + 1 }) } } @@ -4553,8 +8097,10 @@ class ExportService { // Render Item Function const renderItem = (item, index) => { const isSenderMe = item.s === 1; + const platformIdAttr = item.p ? \` data-platform-message-id="\${item.p}"\` : ''; + const replyToAttr = item.r ? \` data-reply-to-message-id="\${item.r}"\` : ''; return \` -
+
\${item.a}
@@ -4651,7 +8197,11 @@ class ExportService { current: 100, total: 100, currentSession: sessionInfo.displayName, - phase: 'complete' + phase: 'complete', + estimatedTotalMessages: totalMessages, + collectedMessages: totalMessages, + exportedMessages: totalMessages, + writtenFiles: 1 }) resolve({ success: true }) }) @@ -4659,6 +8209,9 @@ class ExportService { }) } catch (e) { + if (this.isStopError(e)) { + return { success: false, error: '导出任务已停止' } + } return { success: false, error: String(e) } } } @@ -4669,61 +8222,220 @@ class ExportService { async getExportStats( sessionIds: string[], options: ExportOptions - ): Promise<{ - totalMessages: number - voiceMessages: number - cachedVoiceCount: number - needTranscribeCount: number - mediaMessages: number - estimatedSeconds: number - sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> - }> { + ): Promise { const conn = await this.ensureConnected() if (!conn.success || !conn.cleanedWxid) { return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] } } + const normalizedSessionIds = this.normalizeSessionIds(sessionIds) + if (normalizedSessionIds.length === 0) { + return { totalMessages: 0, voiceMessages: 0, cachedVoiceCount: 0, needTranscribeCount: 0, mediaMessages: 0, estimatedSeconds: 0, sessions: [] } + } + const cacheKey = this.buildExportStatsCacheKey(normalizedSessionIds, options, conn.cleanedWxid) + const cachedStats = this.getExportStatsCacheEntry(cacheKey) + if (cachedStats) { + const cachedResult = this.cloneExportStatsResult(cachedStats.result) + const orderedSessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = [] + const sessionMap = new Map(cachedResult.sessions.map((item) => [item.sessionId, item] as const)) + for (const sessionId of normalizedSessionIds) { + const cachedSession = sessionMap.get(sessionId) + if (cachedSession) orderedSessions.push(cachedSession) + } + if (orderedSessions.length === cachedResult.sessions.length) { + cachedResult.sessions = orderedSessions + } + return cachedResult + } + const cleanedMyWxid = conn.cleanedWxid const sessionsStats: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> = [] + const sessionSnapshotMap: Record = {} let totalMessages = 0 let voiceMessages = 0 let cachedVoiceCount = 0 let mediaMessages = 0 - for (const sessionId of sessionIds) { - const sessionInfo = await this.getContactInfo(sessionId) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) - const msgs = collected.rows - const voiceMsgs = msgs.filter(m => m.localType === 34) - const mediaMsgs = msgs.filter(m => { - const t = m.localType - return (t === 3) || (t === 47) || (t === 43) || (t === 34) - }) + const hasSenderFilter = Boolean(String(options.senderUsername || '').trim()) + const canUseAggregatedStats = this.isUnboundedDateRange(options.dateRange) && !hasSenderFilter - // 检查已缓存的转写数量 - let cached = 0 - for (const msg of voiceMsgs) { - if (chatService.hasTranscriptCache(sessionId, String(msg.localId), msg.createTime)) { - cached++ + // 快速路径:直接复用 ChatService 聚合统计,避免逐会话 collectMessages 扫全量消息。 + if (canUseAggregatedStats) { + try { + let aggregatedData = this.getAggregatedSessionStatsCache(cacheKey) + if (!aggregatedData) { + const statsResult = await chatService.getExportSessionStats(normalizedSessionIds, { + includeRelations: false, + allowStaleCache: true + }) + if (statsResult.success && statsResult.data) { + aggregatedData = statsResult.data as Record + this.setAggregatedSessionStatsCache(cacheKey, aggregatedData) + } } + if (aggregatedData) { + const cachedVoiceCountMap = chatService.getCachedVoiceTranscriptCountMap(normalizedSessionIds) + const fastRows = await parallelLimit( + normalizedSessionIds, + 8, + async (sessionId): Promise<{ + sessionId: string + displayName: string + totalCount: number + voiceCount: number + cachedVoiceCount: number + mediaCount: number + }> => { + let displayName = sessionId + try { + const sessionInfo = await this.getContactInfo(sessionId) + displayName = sessionInfo.displayName || sessionId + } catch { + // 预估阶段显示名获取失败不阻塞统计 + } + + const metric = aggregatedData?.[sessionId] + const totalCount = Number.isFinite(metric?.totalMessages) + ? Math.max(0, Math.floor(metric!.totalMessages)) + : 0 + const voiceCount = Number.isFinite(metric?.voiceMessages) + ? Math.max(0, Math.floor(metric!.voiceMessages)) + : 0 + const imageCount = Number.isFinite(metric?.imageMessages) + ? Math.max(0, Math.floor(metric!.imageMessages)) + : 0 + const videoCount = Number.isFinite(metric?.videoMessages) + ? Math.max(0, Math.floor(metric!.videoMessages)) + : 0 + const emojiCount = Number.isFinite(metric?.emojiMessages) + ? Math.max(0, Math.floor(metric!.emojiMessages)) + : 0 + const lastTimestamp = Number.isFinite(metric?.lastTimestamp) + ? Math.max(0, Math.floor(metric!.lastTimestamp)) + : undefined + const cachedCountRaw = Number(cachedVoiceCountMap[sessionId] || 0) + const sessionCachedVoiceCount = Math.min( + voiceCount, + Number.isFinite(cachedCountRaw) ? Math.max(0, Math.floor(cachedCountRaw)) : 0 + ) + + sessionSnapshotMap[sessionId] = { + totalCount, + voiceCount, + imageCount, + videoCount, + emojiCount, + cachedVoiceCount: sessionCachedVoiceCount, + lastTimestamp + } + + return { + sessionId, + displayName, + totalCount, + voiceCount, + cachedVoiceCount: sessionCachedVoiceCount, + mediaCount: voiceCount + imageCount + videoCount + emojiCount + } + } + ) + + for (const row of fastRows) { + totalMessages += row.totalCount + voiceMessages += row.voiceCount + cachedVoiceCount += row.cachedVoiceCount + mediaMessages += row.mediaCount + sessionsStats.push({ + sessionId: row.sessionId, + displayName: row.displayName, + totalCount: row.totalCount, + voiceCount: row.voiceCount + }) + } + + const needTranscribeCount = Math.max(0, voiceMessages - cachedVoiceCount) + const estimatedSeconds = needTranscribeCount * 2 + const result: ExportStatsResult = { + totalMessages, + voiceMessages, + cachedVoiceCount, + needTranscribeCount, + mediaMessages, + estimatedSeconds, + sessions: sessionsStats + } + this.setExportStatsCacheEntry(cacheKey, { + createdAt: Date.now(), + result: this.cloneExportStatsResult(result), + sessions: { ...sessionSnapshotMap } + }) + return result + } + } catch (error) { + // 聚合统计失败时自动回退到慢路径,保证功能正确。 } + } + + // 回退路径:保留旧逻辑,支持有时间范围/发送者过滤等需要精确筛选的场景。 + for (const sessionId of normalizedSessionIds) { + const sessionInfo = await this.getContactInfo(sessionId) + const collected = await this.collectMessages( + sessionId, + cleanedMyWxid, + options.dateRange, + options.senderUsername, + 'text-fast' + ) + const msgs = collected.rows + let voiceCount = 0 + let imageCount = 0 + let videoCount = 0 + let emojiCount = 0 + let latestTimestamp = 0 + let cached = 0 + for (const msg of msgs) { + if (msg.createTime > latestTimestamp) { + latestTimestamp = msg.createTime + } + const localType = msg.localType + if (localType === 34) { + voiceCount++ + if (chatService.hasTranscriptCache(sessionId, String(msg.localId), msg.createTime)) { + cached++ + } + continue + } + if (localType === 3) imageCount++ + if (localType === 43) videoCount++ + if (localType === 47) emojiCount++ + } + const mediaCount = voiceCount + imageCount + videoCount + emojiCount totalMessages += msgs.length - voiceMessages += voiceMsgs.length + voiceMessages += voiceCount cachedVoiceCount += cached - mediaMessages += mediaMsgs.length + mediaMessages += mediaCount + sessionSnapshotMap[sessionId] = { + totalCount: msgs.length, + voiceCount, + imageCount, + videoCount, + emojiCount, + cachedVoiceCount: cached, + lastTimestamp: latestTimestamp > 0 ? latestTimestamp : undefined + } sessionsStats.push({ sessionId, displayName: sessionInfo.displayName, totalCount: msgs.length, - voiceCount: voiceMsgs.length + voiceCount }) } - const needTranscribeCount = voiceMessages - cachedVoiceCount + const needTranscribeCount = Math.max(0, voiceMessages - cachedVoiceCount) // 预估:每条语音转文字约 2 秒 const estimatedSeconds = needTranscribeCount * 2 - return { + const result: ExportStatsResult = { totalMessages, voiceMessages, cachedVoiceCount, @@ -4732,6 +8444,12 @@ class ExportService { estimatedSeconds, sessions: sessionsStats } + this.setExportStatsCacheEntry(cacheKey, { + createdAt: Date.now(), + result: this.cloneExportStatsResult(result), + sessions: { ...sessionSnapshotMap } + }) + return result } /** @@ -4741,10 +8459,31 @@ class ExportService { sessionIds: string[], outputDir: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void - ): Promise<{ success: boolean; successCount: number; failCount: number; error?: string }> { + onProgress?: (progress: ExportProgress) => void, + control?: ExportTaskControl + ): Promise<{ + success: boolean + successCount: number + failCount: number + paused?: boolean + stopped?: boolean + pendingSessionIds?: string[] + successSessionIds?: string[] + failedSessionIds?: string[] + error?: string + }> { let successCount = 0 let failCount = 0 + const successSessionIds: string[] = [] + const failedSessionIds: string[] = [] + const progressEmitter = this.createProgressEmitter(onProgress) + let attachMediaTelemetry = false + const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => { + const payload = attachMediaTelemetry + ? { ...progress, ...this.getMediaTelemetrySnapshot() } + : progress + progressEmitter.emit(payload, options) + } try { const conn = await this.ensureConnected() @@ -4752,106 +8491,472 @@ class ExportService { return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error } } - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }) - } + this.resetMediaRuntimeState() + const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(options) + ? { ...options, exportVoiceAsText: false } + : options - const exportMediaEnabled = options.exportMedia === true && - Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + const exportMediaEnabled = effectiveOptions.exportMedia === true && + Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis) + attachMediaTelemetry = exportMediaEnabled + if (exportMediaEnabled) { + this.triggerMediaFileCacheCleanup() + } + const rawWriteLayout = this.configService.get('exportWriteLayout') + const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' + ? rawWriteLayout + : 'A' + const exportBaseDir = writeLayout === 'A' + ? path.join(outputDir, 'texts') + : outputDir + const createdTaskDirs = new Set() + const ensureTaskDir = async (dirPath: string) => { + if (createdTaskDirs.has(dirPath)) return + await fs.promises.mkdir(dirPath, { recursive: true }) + createdTaskDirs.add(dirPath) + } + await ensureTaskDir(exportBaseDir) const sessionLayout = exportMediaEnabled - ? (options.sessionLayout ?? 'per-session') + ? (effectiveOptions.sessionLayout ?? 'per-session') : 'shared' let completedCount = 0 - const rawConcurrency = typeof options.exportConcurrency === 'number' - ? Math.floor(options.exportConcurrency) - : 2 - const clampedConcurrency = Math.max(1, Math.min(rawConcurrency, 6)) - const sessionConcurrency = (exportMediaEnabled && sessionLayout === 'shared') - ? 1 - : clampedConcurrency - - await parallelLimit(sessionIds, sessionConcurrency, async (sessionId) => { - const sessionInfo = await this.getContactInfo(sessionId) - - // 创建包装后的进度回调,自动附加会话级信息 - const sessionProgress = (progress: ExportProgress) => { - onProgress?.({ - ...progress, - current: completedCount, + const activeSessionRatios = new Map() + const computeAggregateCurrent = () => { + let activeRatioSum = 0 + for (const ratio of activeSessionRatios.values()) { + activeRatioSum += Math.max(0, Math.min(1, ratio)) + } + return Math.min(sessionIds.length, completedCount + activeRatioSum) + } + const isTextContentBatchExport = effectiveOptions.contentType === 'text' && !exportMediaEnabled + const defaultConcurrency = exportMediaEnabled ? 2 : (isTextContentBatchExport ? 1 : 4) + const rawConcurrency = typeof effectiveOptions.exportConcurrency === 'number' + ? Math.floor(effectiveOptions.exportConcurrency) + : defaultConcurrency + const maxSessionConcurrency = isTextContentBatchExport ? 1 : 6 + const clampedConcurrency = Math.max(1, Math.min(rawConcurrency, maxSessionConcurrency)) + const sessionConcurrency = clampedConcurrency + const queue = [...sessionIds] + let pauseRequested = false + let stopRequested = false + const emptySessionIds = new Set() + const sessionMessageCountHints = new Map() + const sessionLatestTimestampHints = new Map() + const exportStatsCacheKey = this.buildExportStatsCacheKey(sessionIds, effectiveOptions, conn.cleanedWxid) + const cachedStatsEntry = this.getExportStatsCacheEntry(exportStatsCacheKey) + if (cachedStatsEntry?.sessions) { + for (const sessionId of sessionIds) { + const snapshot = cachedStatsEntry.sessions[sessionId] + if (!snapshot) continue + sessionMessageCountHints.set(sessionId, Math.max(0, Math.floor(snapshot.totalCount || 0))) + if (Number.isFinite(snapshot.lastTimestamp) && Number(snapshot.lastTimestamp) > 0) { + sessionLatestTimestampHints.set(sessionId, Math.floor(Number(snapshot.lastTimestamp))) + } + if (snapshot.totalCount <= 0) { + emptySessionIds.add(sessionId) + } + } + } + const canUseSessionSnapshotHints = isTextContentBatchExport && + this.isUnboundedDateRange(effectiveOptions.dateRange) && + !String(effectiveOptions.senderUsername || '').trim() + const canFastSkipEmptySessions = !isTextContentBatchExport && + this.isUnboundedDateRange(effectiveOptions.dateRange) && + !String(effectiveOptions.senderUsername || '').trim() + const canTrySkipUnchangedTextSessions = canUseSessionSnapshotHints + const precheckSessionIds = canFastSkipEmptySessions + ? sessionIds.filter((sessionId) => !sessionMessageCountHints.has(sessionId)) + : [] + if (canFastSkipEmptySessions && precheckSessionIds.length > 0) { + const EMPTY_SESSION_PRECHECK_LIMIT = 1200 + if (precheckSessionIds.length <= EMPTY_SESSION_PRECHECK_LIMIT) { + let checkedCount = 0 + emitProgress({ + current: computeAggregateCurrent(), total: sessionIds.length, - currentSession: sessionInfo.displayName + currentSession: '', + currentSessionId: '', + phase: 'preparing', + phaseProgress: 0, + phaseTotal: precheckSessionIds.length, + phaseLabel: `预检查空会话 0/${precheckSessionIds.length}` + }) + + const PRECHECK_BATCH_SIZE = 160 + for (let i = 0; i < precheckSessionIds.length; i += PRECHECK_BATCH_SIZE) { + if (control?.shouldStop?.()) { + stopRequested = true + break + } + if (control?.shouldPause?.()) { + pauseRequested = true + break + } + + const batchSessionIds = precheckSessionIds.slice(i, i + PRECHECK_BATCH_SIZE) + const countsResult = await wcdbService.getMessageCounts(batchSessionIds) + if (countsResult.success && countsResult.counts) { + for (const batchSessionId of batchSessionIds) { + const count = countsResult.counts[batchSessionId] + if (typeof count === 'number' && Number.isFinite(count) && count >= 0) { + sessionMessageCountHints.set(batchSessionId, Math.max(0, Math.floor(count))) + } + if (typeof count === 'number' && Number.isFinite(count) && count <= 0) { + emptySessionIds.add(batchSessionId) + } + } + } + + checkedCount = Math.min(precheckSessionIds.length, checkedCount + batchSessionIds.length) + emitProgress({ + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: '', + currentSessionId: '', + phase: 'preparing', + phaseProgress: checkedCount, + phaseTotal: precheckSessionIds.length, + phaseLabel: `预检查空会话 ${checkedCount}/${precheckSessionIds.length}` + }) + } + } else { + emitProgress({ + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: '', + currentSessionId: '', + phase: 'preparing', + phaseLabel: `会话较多,已跳过空会话预检查(${precheckSessionIds.length} 个)` }) } + } - sessionProgress({ - current: completedCount, - total: sessionIds.length, - currentSession: sessionInfo.displayName, - phase: 'exporting' + if (canUseSessionSnapshotHints && sessionIds.length > 0) { + const missingHintSessionIds = sessionIds.filter((sessionId) => ( + !sessionMessageCountHints.has(sessionId) || !sessionLatestTimestampHints.has(sessionId) + )) + if (missingHintSessionIds.length > 0) { + const sessionSet = new Set(missingHintSessionIds) + const sessionsResult = await chatService.getSessions() + if (sessionsResult.success && Array.isArray(sessionsResult.sessions)) { + for (const item of sessionsResult.sessions) { + const username = String(item?.username || '').trim() + if (!username) continue + if (!sessionSet.has(username)) continue + const messageCountHint = Number(item?.messageCountHint) + if ( + !sessionMessageCountHints.has(username) && + Number.isFinite(messageCountHint) && + messageCountHint >= 0 + ) { + sessionMessageCountHints.set(username, Math.floor(messageCountHint)) + } + const lastTimestamp = Number(item?.lastTimestamp) + if ( + !sessionLatestTimestampHints.has(username) && + Number.isFinite(lastTimestamp) && + lastTimestamp > 0 + ) { + sessionLatestTimestampHints.set(username, Math.floor(lastTimestamp)) + } + } + } + } + } + + if (stopRequested) { + return { + success: true, + successCount, + failCount, + stopped: true, + pendingSessionIds: [...queue], + successSessionIds, + failedSessionIds + } + } + if (pauseRequested) { + return { + success: true, + successCount, + failCount, + paused: true, + pendingSessionIds: [...queue], + successSessionIds, + failedSessionIds + } + } + + const runOne = async (sessionId: string): Promise<'done' | 'stopped'> => { + try { + this.throwIfStopRequested(control) + const sessionInfo = await this.getContactInfo(sessionId) + const messageCountHint = sessionMessageCountHints.get(sessionId) + const latestTimestampHint = sessionLatestTimestampHints.get(sessionId) + + if ( + isTextContentBatchExport && + typeof messageCountHint === 'number' && + messageCountHint <= 0 + ) { + successCount++ + successSessionIds.push(sessionId) + activeSessionRatios.delete(sessionId) + completedCount++ + emitProgress({ + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: sessionInfo.displayName, + currentSessionId: sessionId, + phase: 'complete', + phaseLabel: '该会话没有消息,已跳过', + estimatedTotalMessages: 0, + exportedMessages: 0 + }, { force: true }) + return 'done' + } + + if (emptySessionIds.has(sessionId)) { + successCount++ + successSessionIds.push(sessionId) + activeSessionRatios.delete(sessionId) + completedCount++ + emitProgress({ + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: sessionInfo.displayName, + currentSessionId: sessionId, + phase: 'complete', + phaseLabel: '该会话没有消息,已跳过', + estimatedTotalMessages: 0, + exportedMessages: 0 + }, { force: true }) + return 'done' + } + + const sessionProgress = (progress: ExportProgress) => { + const phaseTotal = Number.isFinite(progress.total) && progress.total > 0 ? progress.total : 100 + const phaseCurrent = Number.isFinite(progress.current) ? progress.current : 0 + const ratio = progress.phase === 'complete' + ? 1 + : Math.max(0, Math.min(1, phaseCurrent / phaseTotal)) + activeSessionRatios.set(sessionId, ratio) + emitProgress({ + ...progress, + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: sessionInfo.displayName, + currentSessionId: sessionId + }, { force: progress.phase === 'complete' }) + } + + sessionProgress({ + current: 0, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'preparing', + phaseLabel: '准备导出' + }) + + const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim() + const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session' + const suffix = sanitizeName(effectiveOptions.fileNameSuffix || '') + const safeName = suffix ? `${baseName}_${suffix}` : baseName + const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false + const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : '' + const fileNameWithPrefix = `${sessionTypePrefix}${safeName}` + const useSessionFolder = sessionLayout === 'per-session' + const sessionDirName = sessionNameWithTypePrefix ? `${sessionTypePrefix}${safeName}` : safeName + const sessionDir = useSessionFolder ? path.join(exportBaseDir, sessionDirName) : exportBaseDir + + if (useSessionFolder) { + await ensureTaskDir(sessionDir) + } + + let ext = '.json' + if (effectiveOptions.format === 'chatlab-jsonl') ext = '.jsonl' + else if (effectiveOptions.format === 'excel') ext = '.xlsx' + else if (effectiveOptions.format === 'txt') ext = '.txt' + else if (effectiveOptions.format === 'weclone') ext = '.csv' + else if (effectiveOptions.format === 'html') ext = '.html' + const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`) + const canTrySkipUnchanged = canTrySkipUnchangedTextSessions && + typeof messageCountHint === 'number' && + messageCountHint >= 0 && + typeof latestTimestampHint === 'number' && + latestTimestampHint > 0 && + await this.pathExists(outputPath) + if (canTrySkipUnchanged) { + const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format) + const hasNoDataChange = Boolean( + latestRecord && + latestRecord.messageCount === messageCountHint && + Number(latestRecord.sourceLatestMessageTimestamp || 0) >= latestTimestampHint + ) + if (hasNoDataChange) { + successCount++ + successSessionIds.push(sessionId) + activeSessionRatios.delete(sessionId) + completedCount++ + emitProgress({ + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: sessionInfo.displayName, + currentSessionId: sessionId, + phase: 'complete', + phaseLabel: '无变化,已跳过', + estimatedTotalMessages: Math.max(0, Math.floor(messageCountHint || 0)), + exportedMessages: Math.max(0, Math.floor(messageCountHint || 0)) + }, { force: true }) + return 'done' + } + } + + let result: { success: boolean; error?: string } + if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') { + result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control) + } else if (effectiveOptions.format === 'chatlab' || effectiveOptions.format === 'chatlab-jsonl') { + result = await this.exportSessionToChatLab(sessionId, outputPath, effectiveOptions, sessionProgress, control) + } else if (effectiveOptions.format === 'excel') { + result = await this.exportSessionToExcel(sessionId, outputPath, effectiveOptions, sessionProgress, control) + } else if (effectiveOptions.format === 'txt') { + result = await this.exportSessionToTxt(sessionId, outputPath, effectiveOptions, sessionProgress, control) + } else if (effectiveOptions.format === 'weclone') { + result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, effectiveOptions, sessionProgress, control) + } else if (effectiveOptions.format === 'html') { + result = await this.exportSessionToHtml(sessionId, outputPath, effectiveOptions, sessionProgress, control) + } else { + result = { success: false, error: `不支持的格式: ${effectiveOptions.format}` } + } + + if (!result.success && this.isStopError(result.error)) { + activeSessionRatios.delete(sessionId) + return 'stopped' + } + + if (result.success) { + successCount++ + successSessionIds.push(sessionId) + if (typeof messageCountHint === 'number' && messageCountHint >= 0) { + exportRecordService.saveRecord(sessionId, effectiveOptions.format, messageCountHint, { + sourceLatestMessageTimestamp: typeof latestTimestampHint === 'number' && latestTimestampHint > 0 + ? latestTimestampHint + : undefined, + outputPath + }) + } + } else { + failCount++ + failedSessionIds.push(sessionId) + console.error(`导出 ${sessionId} 失败:`, result.error) + } + + activeSessionRatios.delete(sessionId) + completedCount++ + emitProgress({ + current: computeAggregateCurrent(), + total: sessionIds.length, + currentSession: sessionInfo.displayName, + currentSessionId: sessionId, + phase: 'complete', + phaseLabel: result.success ? '完成' : '导出失败' + }, { force: true }) + return 'done' + } catch (error) { + if (this.isStopError(error)) { + activeSessionRatios.delete(sessionId) + return 'stopped' + } + throw error + } + } + + if (isTextContentBatchExport) { + // 文本内容批量导出使用串行调度,降低数据库与文件系统抢占,行为更贴近 wxdaochu。 + while (queue.length > 0) { + if (control?.shouldStop?.()) { + stopRequested = true + break + } + if (control?.shouldPause?.()) { + pauseRequested = true + break + } + + const sessionId = queue.shift() + if (!sessionId) break + const runState = await runOne(sessionId) + await new Promise(resolve => setImmediate(resolve)) + if (runState === 'stopped') { + stopRequested = true + queue.unshift(sessionId) + break + } + } + } else { + const workers = Array.from({ length: Math.min(sessionConcurrency, queue.length) }, async () => { + while (queue.length > 0) { + if (control?.shouldStop?.()) { + stopRequested = true + break + } + if (control?.shouldPause?.()) { + pauseRequested = true + break + } + + const sessionId = queue.shift() + if (!sessionId) break + const runState = await runOne(sessionId) + if (runState === 'stopped') { + stopRequested = true + queue.unshift(sessionId) + break + } + } }) + await Promise.all(workers) + } - const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim() - const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session' - const suffix = sanitizeName(options.fileNameSuffix || '') - const safeName = suffix ? `${baseName}_${suffix}` : baseName - const useSessionFolder = sessionLayout === 'per-session' - const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir - - if (useSessionFolder && !fs.existsSync(sessionDir)) { - fs.mkdirSync(sessionDir, { recursive: true }) + const pendingSessionIds = [...queue] + if (stopRequested && pendingSessionIds.length > 0) { + return { + success: true, + successCount, + failCount, + stopped: true, + pendingSessionIds, + successSessionIds, + failedSessionIds } - - let ext = '.json' - if (options.format === 'chatlab-jsonl') ext = '.jsonl' - else if (options.format === 'excel') ext = '.xlsx' - else if (options.format === 'txt') ext = '.txt' - else if (options.format === 'weclone') ext = '.csv' - else if (options.format === 'html') ext = '.html' - const outputPath = path.join(sessionDir, `${safeName}${ext}`) - - let result: { success: boolean; error?: string } - if (options.format === 'json') { - result = await this.exportSessionToDetailedJson(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') { - result = await this.exportSessionToChatLab(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'excel') { - result = await this.exportSessionToExcel(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'txt') { - result = await this.exportSessionToTxt(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'weclone') { - result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, options, sessionProgress) - } else if (options.format === 'html') { - result = await this.exportSessionToHtml(sessionId, outputPath, options, sessionProgress) - } else { - result = { success: false, error: `不支持的格式: ${options.format}` } + } + if (pauseRequested && pendingSessionIds.length > 0) { + return { + success: true, + successCount, + failCount, + paused: true, + pendingSessionIds, + successSessionIds, + failedSessionIds } + } - if (result.success) { - successCount++ - } else { - failCount++ - console.error(`导出 ${sessionId} 失败:`, result.error) - } - - completedCount++ - onProgress?.({ - current: completedCount, - total: sessionIds.length, - currentSession: sessionInfo.displayName, - phase: 'exporting' - }) - }) - - onProgress?.({ + emitProgress({ current: sessionIds.length, total: sessionIds.length, currentSession: '', + currentSessionId: '', phase: 'complete' - }) + }, { force: true }) + progressEmitter.flush() - return { success: true, successCount, failCount } + return { success: true, successCount, failCount, successSessionIds, failedSessionIds } } catch (e) { + progressEmitter.flush() return { success: false, successCount, failCount, error: String(e) } + } finally { + this.clearMediaRuntimeState() } } } diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 22abcb9..8d66ce9 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -21,6 +21,12 @@ export interface GroupMember { alias?: string remark?: string groupNickname?: string + isOwner?: boolean +} + +export interface GroupMembersPanelEntry extends GroupMember { + isFriend: boolean + messageCount: number } export interface GroupMessageRank { @@ -43,8 +49,34 @@ export interface GroupMediaStats { total: number } +export interface GroupMemberMessagesPage { + messages: Message[] + hasMore: boolean + nextCursor: number +} + +interface GroupMemberContactInfo { + remark: string + nickName: string + alias: string + username: string + userName: string + encryptUsername: string + encryptUserName: string + localType: number +} + class GroupAnalyticsService { private configService: ConfigService + private readonly groupMembersPanelCacheTtlMs = 10 * 60 * 1000 + private readonly groupMembersPanelMembersTimeoutMs = 12 * 1000 + private readonly groupMembersPanelFullTimeoutMs = 25 * 1000 + private readonly groupMembersPanelCache = new Map() + private readonly groupMembersPanelInFlight = new Map< + string, + Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> + >() + private readonly friendExcludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) constructor() { this.configService = new ConfigService() @@ -89,6 +121,127 @@ class GroupAnalyticsService { return cleaned } + private resolveMemberUsername( + candidate: unknown, + memberLookup: Map + ): string | null { + if (typeof candidate !== 'string') return null + const raw = candidate.trim() + if (!raw) return null + if (memberLookup.has(raw)) return memberLookup.get(raw) || null + const cleaned = this.cleanAccountDirName(raw) + if (memberLookup.has(cleaned)) return memberLookup.get(cleaned) || null + + const parts = raw.split(/[,\s;|]+/).filter(Boolean) + for (const part of parts) { + if (memberLookup.has(part)) return memberLookup.get(part) || null + const normalizedPart = this.cleanAccountDirName(part) + if (memberLookup.has(normalizedPart)) return memberLookup.get(normalizedPart) || null + } + + if ((raw.startsWith('{') || raw.startsWith('[')) && raw.length < 4096) { + try { + const parsed = JSON.parse(raw) + return this.extractOwnerUsername(parsed, memberLookup, 0) + } catch { + return null + } + } + + return null + } + + private extractOwnerUsername( + value: unknown, + memberLookup: Map, + depth: number + ): string | null { + if (depth > 4 || value == null) return null + if (Buffer.isBuffer(value) || value instanceof Uint8Array) return null + + if (typeof value === 'string') { + return this.resolveMemberUsername(value, memberLookup) + } + + if (Array.isArray(value)) { + for (const item of value) { + const owner = this.extractOwnerUsername(item, memberLookup, depth + 1) + if (owner) return owner + } + return null + } + + if (typeof value !== 'object') return null + const row = value as Record + + for (const [key, entry] of Object.entries(row)) { + const keyLower = key.toLowerCase() + if (!keyLower.includes('owner') && !keyLower.includes('host') && !keyLower.includes('creator')) { + continue + } + + if (typeof entry === 'boolean') { + if (entry && typeof row.username === 'string') { + const owner = this.resolveMemberUsername(row.username, memberLookup) + if (owner) return owner + } + continue + } + + const owner = this.extractOwnerUsername(entry, memberLookup, depth + 1) + if (owner) return owner + } + + return null + } + + private async detectGroupOwnerUsername( + chatroomId: string, + members: Array<{ username: string; [key: string]: unknown }> + ): Promise { + const memberLookup = new Map() + for (const member of members) { + const username = String(member.username || '').trim() + if (!username) continue + const cleaned = this.cleanAccountDirName(username) + memberLookup.set(username, username) + memberLookup.set(cleaned, username) + } + if (memberLookup.size === 0) return undefined + + const tryResolve = (candidate: unknown): string | undefined => { + const owner = this.extractOwnerUsername(candidate, memberLookup, 0) + return owner || undefined + } + + for (const member of members) { + const owner = tryResolve(member) + if (owner) return owner + } + + try { + const groupContact = await wcdbService.getContact(chatroomId) + if (groupContact.success && groupContact.contact) { + const owner = tryResolve(groupContact.contact) + if (owner) return owner + } + } catch { + // ignore + } + + try { + const roomExt = await wcdbService.getChatRoomExtBuffer(chatroomId) + if (roomExt.success && roomExt.extBuffer) { + const owner = tryResolve({ ext_buffer: roomExt.extBuffer }) + if (owner) return owner + } + } catch { + // ignore + } + + return undefined + } + private async ensureConnected(): Promise<{ success: boolean; error?: string }> { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') @@ -107,20 +260,46 @@ class GroupAnalyticsService { * 从 DLL 获取群成员的群昵称 */ private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise> { + const nicknameMap = new Map() + try { - const escapedChatroomId = chatroomId.replace(/'/g, "''") - const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1` - const result = await wcdbService.execQuery('contact', null, sql) - if (!result.success || !result.rows || result.rows.length === 0) { - return new Map() + const dllResult = await wcdbService.getGroupNicknames(chatroomId) + if (dllResult.success && dllResult.nicknames) { + this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames)) + } + } catch (e) { + console.error('getGroupNicknamesForRoom dll error:', e) + } + + try { + const result = await wcdbService.getChatRoomExtBuffer(chatroomId) + if (!result.success || !result.extBuffer) { + return nicknameMap } - const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer) - if (!extBuffer) return new Map() - return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates) + const extBuffer = this.decodeExtBuffer(result.extBuffer) + if (!extBuffer) return nicknameMap + this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries()) + return nicknameMap } catch (e) { console.error('getGroupNicknamesForRoom error:', e) - return new Map() + return nicknameMap + } + } + + private mergeGroupNicknameEntries( + target: Map, + entries: Iterable<[string, string]> + ): void { + for (const [memberIdRaw, nicknameRaw] of entries) { + const nickname = this.normalizeGroupNickname(nicknameRaw || '') + if (!nickname) continue + for (const alias of this.buildIdCandidates([memberIdRaw])) { + if (!alias) continue + if (!target.has(alias)) target.set(alias, nickname) + const lower = alias.toLowerCase() + if (!target.has(lower)) target.set(lower, nickname) + } } } @@ -296,6 +475,193 @@ class GroupAnalyticsService { return Array.from(set) } + private toNonNegativeInteger(value: unknown): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return 0 + return Math.max(0, Math.floor(parsed)) + } + + private pickStringField(row: Record, keys: string[]): string { + for (const key of keys) { + const value = row[key] + if (value == null) continue + const text = String(value).trim() + if (text) return text + } + return '' + } + + private pickIntegerField(row: Record, keys: string[], fallback: number = 0): number { + for (const key of keys) { + const value = row[key] + if (value == null || value === '') continue + const parsed = Number(value) + if (Number.isFinite(parsed)) return Math.floor(parsed) + } + return fallback + } + + private buildGroupMembersPanelCacheKey(chatroomId: string, includeMessageCounts: boolean): string { + const dbPath = String(this.configService.get('dbPath') || '').trim() + const wxid = this.cleanAccountDirName(String(this.configService.get('myWxid') || '').trim()) + const mode = includeMessageCounts ? 'full' : 'members' + return `${dbPath}::${wxid}::${chatroomId}::${mode}` + } + + private pruneGroupMembersPanelCache(maxEntries: number = 80): void { + if (this.groupMembersPanelCache.size <= maxEntries) return + const entries = Array.from(this.groupMembersPanelCache.entries()) + .sort((a, b) => a[1].updatedAt - b[1].updatedAt) + const removeCount = this.groupMembersPanelCache.size - maxEntries + for (let i = 0; i < removeCount; i += 1) { + this.groupMembersPanelCache.delete(entries[i][0]) + } + } + + private async withPromiseTimeout( + promise: Promise, + timeoutMs: number, + timeoutResult: T + ): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return promise + } + + let timeoutTimer: ReturnType | null = null + const timeoutPromise = new Promise((resolve) => { + timeoutTimer = setTimeout(() => { + resolve(timeoutResult) + }, timeoutMs) + }) + + try { + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timeoutTimer) { + clearTimeout(timeoutTimer) + } + } + } + + private async buildGroupMemberContactLookup(usernames: string[]): Promise> { + const lookup = new Map() + const candidates = this.buildIdCandidates(usernames) + if (candidates.length === 0) return lookup + + const appendContactsToLookup = (rows: Record[]) => { + for (const row of rows) { + const contact: GroupMemberContactInfo = { + remark: this.pickStringField(row, ['remark', 'WCDB_CT_remark']), + nickName: this.pickStringField(row, ['nick_name', 'nickName', 'WCDB_CT_nick_name']), + alias: this.pickStringField(row, ['alias', 'WCDB_CT_alias']), + username: this.pickStringField(row, ['username', 'WCDB_CT_username']), + userName: this.pickStringField(row, ['user_name', 'userName', 'WCDB_CT_user_name']), + encryptUsername: this.pickStringField(row, ['encrypt_username', 'encryptUsername', 'WCDB_CT_encrypt_username']), + encryptUserName: this.pickStringField(row, ['encrypt_user_name', 'encryptUserName', 'WCDB_CT_encrypt_user_name']), + localType: this.pickIntegerField(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) + } + const lookupKeys = this.buildIdCandidates([ + contact.username, + contact.userName, + contact.encryptUsername, + contact.encryptUserName, + contact.alias + ]) + for (const key of lookupKeys) { + const normalized = key.toLowerCase() + if (!lookup.has(normalized)) { + lookup.set(normalized, contact) + } + } + } + } + + const batchSize = 200 + for (let i = 0; i < candidates.length; i += batchSize) { + const batch = candidates.slice(i, i + batchSize) + if (batch.length === 0) continue + + const result = await wcdbService.getContactsCompact(batch) + if (!result.success || !result.contacts) continue + appendContactsToLookup(result.contacts as Record[]) + } + return lookup + } + + private resolveContactByCandidates( + lookup: Map, + candidates: Array + ): GroupMemberContactInfo | undefined { + const ids = this.buildIdCandidates(candidates) + for (const id of ids) { + const hit = lookup.get(id.toLowerCase()) + if (hit) return hit + } + return undefined + } + + private async buildGroupMessageCountLookup(chatroomId: string): Promise> { + const lookup = new Map() + const result = await wcdbService.getGroupStats(chatroomId, 0, 0) + if (!result.success || !result.data) return lookup + + const sessionData = result.data?.sessions?.[chatroomId] + if (!sessionData || !sessionData.senders) return lookup + + const idMap = result.data.idMap || {} + for (const [senderId, rawCount] of Object.entries(sessionData.senders as Record)) { + const username = String(idMap[senderId] || senderId || '').trim() + if (!username) continue + const count = this.toNonNegativeInteger(rawCount) + const keys = this.buildIdCandidates([username]) + for (const key of keys) { + const normalized = key.toLowerCase() + const prev = lookup.get(normalized) || 0 + if (count > prev) { + lookup.set(normalized, count) + } + } + } + return lookup + } + + private resolveMessageCountByCandidates( + lookup: Map, + candidates: Array + ): number { + let maxCount = 0 + const ids = this.buildIdCandidates(candidates) + for (const id of ids) { + const count = lookup.get(id.toLowerCase()) + if (typeof count === 'number' && count > maxCount) { + maxCount = count + } + } + return maxCount + } + + private isFriendMember(wxid: string, contact?: GroupMemberContactInfo): boolean { + const normalizedWxid = String(wxid || '').trim().toLowerCase() + if (!normalizedWxid) return false + if (normalizedWxid.includes('@chatroom') || normalizedWxid.startsWith('gh_')) return false + if (this.friendExcludeNames.has(normalizedWxid)) return false + if (!contact) return false + return contact.localType === 1 + } + + private sortGroupMembersPanelEntries(members: GroupMembersPanelEntry[]): GroupMembersPanelEntry[] { + return members.sort((a, b) => { + const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner)) + if (ownerDiff !== 0) return ownerDiff + + const friendDiff = Number(Boolean(b.isFriend)) - Number(Boolean(a.isFriend)) + if (friendDiff !== 0) return friendDiff + + if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount + return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN') + }) + } + private resolveGroupNicknameByCandidates(groupNicknames: Map, candidates: string[]): string { const idCandidates = this.buildIdCandidates(candidates) if (idCandidates.length === 0) return '' @@ -396,36 +762,246 @@ class GroupAnalyticsService { return '' } + private normalizeCursorTimestamp(value: number): number { + if (!Number.isFinite(value) || value <= 0) return 0 + const normalized = Math.floor(value) + return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized + } + + private extractRowSenderUsername(row: Record): string { + const candidates = [ + row.sender_username, + row.senderUsername, + row.sender, + row.WCDB_CT_sender_username + ] + for (const candidate of candidates) { + const value = String(candidate || '').trim() + if (value) return value + } + for (const [key, value] of Object.entries(row)) { + const normalizedKey = key.toLowerCase() + if ( + normalizedKey === 'sender_username' || + normalizedKey === 'senderusername' || + normalizedKey === 'sender' || + normalizedKey === 'wcdb_ct_sender_username' + ) { + const normalizedValue = String(value || '').trim() + if (normalizedValue) return normalizedValue + } + } + return '' + } + + private parseSingleMessageRow(row: Record): Message | null { + try { + const mapped = chatService.mapRowsToMessagesForApi([row]) + return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null + } catch { + return null + } + } + + private async openMemberMessageCursor( + chatroomId: string, + batchSize: number, + ascending: boolean, + startTime: number, + endTime: number + ): Promise<{ success: boolean; cursor?: number; error?: string }> { + const beginTimestamp = this.normalizeCursorTimestamp(startTime) + const endTimestamp = this.normalizeCursorTimestamp(endTime) + const liteResult = await wcdbService.openMessageCursorLite(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp) + if (liteResult.success && liteResult.cursor) return liteResult + return wcdbService.openMessageCursor(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp) + } + private async collectMessagesByMember( chatroomId: string, memberUsername: string, startTime: number, endTime: number ): Promise<{ success: boolean; data?: Message[]; error?: string }> { - const batchSize = 500 + const batchSize = 800 const matchedMessages: Message[] = [] - let offset = 0 + const senderMatchCache = new Map() + const matchesTargetSender = (sender: string | null | undefined): boolean => { + const key = String(sender || '').trim().toLowerCase() + if (!key) return false + const cached = senderMatchCache.get(key) + if (typeof cached === 'boolean') return cached + const matched = this.isSameAccountIdentity(memberUsername, sender) + senderMatchCache.set(key, matched) + return matched + } - while (true) { - const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true) - if (!batch.success || !batch.messages) { - return { success: false, error: batch.error || '获取群消息失败' } - } + const cursorResult = await this.openMemberMessageCursor(chatroomId, batchSize, true, startTime, endTime) + if (!cursorResult.success || !cursorResult.cursor) { + return { success: false, error: cursorResult.error || '创建群消息游标失败' } + } - for (const message of batch.messages) { - if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) { - matchedMessages.push(message) + const cursor = cursorResult.cursor + try { + while (true) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) { + return { success: false, error: batch.error || '获取群消息失败' } } - } + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + if (rows.length === 0) break - const fetchedCount = batch.messages.length - if (fetchedCount <= 0 || !batch.hasMore) break - offset += fetchedCount + for (const row of rows) { + const senderFromRow = this.extractRowSenderUsername(row) + if (senderFromRow && !matchesTargetSender(senderFromRow)) { + continue + } + const message = this.parseSingleMessageRow(row) + if (!message) continue + if (matchesTargetSender(message.senderUsername)) { + matchedMessages.push(message) + } + } + + if (!batch.hasMore) break + } + } finally { + await wcdbService.closeMessageCursor(cursor) } return { success: true, data: matchedMessages } } + async getGroupMemberMessages( + chatroomId: string, + memberUsername: string, + options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number } + ): Promise<{ success: boolean; data?: GroupMemberMessagesPage; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success) return { success: false, error: conn.error } + + const normalizedChatroomId = String(chatroomId || '').trim() + const normalizedMemberUsername = String(memberUsername || '').trim() + if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' } + if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' } + + const startTimeValue = Number.isFinite(options?.startTime) && typeof options?.startTime === 'number' + ? Math.max(0, Math.floor(options.startTime)) + : 0 + const endTimeValue = Number.isFinite(options?.endTime) && typeof options?.endTime === 'number' + ? Math.max(0, Math.floor(options.endTime)) + : 0 + const limit = Number.isFinite(options?.limit) && typeof options?.limit === 'number' + ? Math.max(1, Math.min(100, Math.floor(options.limit))) + : 50 + let cursor = Number.isFinite(options?.cursor) && typeof options?.cursor === 'number' + ? Math.max(0, Math.floor(options.cursor)) + : 0 + + const matchedMessages: Message[] = [] + const senderMatchCache = new Map() + const matchesTargetSender = (sender: string | null | undefined): boolean => { + const key = String(sender || '').trim().toLowerCase() + if (!key) return false + const cached = senderMatchCache.get(key) + if (typeof cached === 'boolean') return cached + const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender) + senderMatchCache.set(key, matched) + return matched + } + const batchSize = Math.max(limit * 4, 240) + let hasMore = false + + const cursorResult = await this.openMemberMessageCursor( + normalizedChatroomId, + batchSize, + false, + startTimeValue, + endTimeValue + ) + if (!cursorResult.success || !cursorResult.cursor) { + return { success: false, error: cursorResult.error || '创建群成员消息游标失败' } + } + + let consumedRows = 0 + const dbCursor = cursorResult.cursor + + try { + while (matchedMessages.length < limit) { + const batch = await wcdbService.fetchMessageBatch(dbCursor) + if (!batch.success) { + return { success: false, error: batch.error || '获取群成员消息失败' } + } + + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + if (rows.length === 0) { + hasMore = false + break + } + + let startIndex = 0 + if (cursor > consumedRows) { + const skipCount = Math.min(cursor - consumedRows, rows.length) + consumedRows += skipCount + startIndex = skipCount + if (startIndex >= rows.length) { + if (!batch.hasMore) { + hasMore = false + break + } + continue + } + } + + for (let index = startIndex; index < rows.length; index += 1) { + const row = rows[index] + consumedRows += 1 + + const senderFromRow = this.extractRowSenderUsername(row) + if (senderFromRow && !matchesTargetSender(senderFromRow)) { + continue + } + + const message = this.parseSingleMessageRow(row) + if (!message) continue + if (!matchesTargetSender(message.senderUsername)) { + continue + } + + matchedMessages.push(message) + if (matchedMessages.length >= limit) { + cursor = consumedRows + hasMore = index < rows.length - 1 || batch.hasMore === true + break + } + } + + if (matchedMessages.length >= limit) break + + cursor = consumedRows + if (!batch.hasMore) { + hasMore = false + break + } + } + } finally { + await wcdbService.closeMessageCursor(dbCursor) + } + + return { + success: true, + data: { + messages: matchedMessages, + hasMore, + nextCursor: cursor + } + } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> { try { const conn = await this.ensureConnected() @@ -483,6 +1059,167 @@ class GroupAnalyticsService { } } + private async loadGroupMembersPanelDataFresh( + chatroomId: string, + includeMessageCounts: boolean + ): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string }> { + const membersResult = await wcdbService.getGroupMembers(chatroomId) + if (!membersResult.success || !membersResult.members) { + return { success: false, error: membersResult.error || '获取群成员失败' } + } + + const members = membersResult.members as Array<{ + username: string + avatarUrl?: string + originalName?: string + [key: string]: unknown + }> + if (members.length === 0) return { success: true, data: [] } + + const usernames = members + .map((member) => String(member.username || '').trim()) + .filter(Boolean) + if (usernames.length === 0) return { success: true, data: [] } + + const displayNamesPromise = wcdbService.getDisplayNames(usernames) + const contactLookupPromise = this.buildGroupMemberContactLookup(usernames) + const ownerPromise = this.detectGroupOwnerUsername(chatroomId, members) + const messageCountLookupPromise = includeMessageCounts + ? this.buildGroupMessageCountLookup(chatroomId) + : Promise.resolve(new Map()) + + const [displayNames, contactLookup, ownerUsername, messageCountLookup] = await Promise.all([ + displayNamesPromise, + contactLookupPromise, + ownerPromise, + messageCountLookupPromise + ]) + + const nicknameCandidates = this.buildIdCandidates([ + ...members.map((member) => member.username), + ...members.map((member) => member.originalName), + ...Array.from(contactLookup.values()).map((contact) => contact?.username), + ...Array.from(contactLookup.values()).map((contact) => contact?.userName), + ...Array.from(contactLookup.values()).map((contact) => contact?.encryptUsername), + ...Array.from(contactLookup.values()).map((contact) => contact?.encryptUserName), + ...Array.from(contactLookup.values()).map((contact) => contact?.alias) + ]) + const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates) + const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') + let myGroupMessageCountHint: number | undefined + + const data: GroupMembersPanelEntry[] = members + .map((member) => { + const wxid = String(member.username || '').trim() + if (!wxid) return null + + const contact = this.resolveContactByCandidates(contactLookup, [wxid, member.originalName]) + const nickname = contact?.nickName || '' + const remark = contact?.remark || '' + const alias = contact?.alias || '' + const normalizedWxid = this.cleanAccountDirName(wxid) + const lookupCandidates = this.buildIdCandidates([ + wxid, + member.originalName as string | undefined, + contact?.username, + contact?.userName, + contact?.encryptUsername, + contact?.encryptUserName, + alias + ]) + if (normalizedWxid === myWxid) { + lookupCandidates.push(myWxid) + } + const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates) + const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid + + return { + username: wxid, + displayName, + nickname, + alias, + remark, + groupNickname, + avatarUrl: member.avatarUrl, + isOwner: Boolean(ownerUsername && ownerUsername === wxid), + isFriend: this.isFriendMember(wxid, contact), + messageCount: this.resolveMessageCountByCandidates(messageCountLookup, lookupCandidates) + } + }) + .filter((member): member is GroupMembersPanelEntry => Boolean(member)) + + if (includeMessageCounts && myWxid) { + const selfEntry = data.find((member) => this.cleanAccountDirName(member.username) === myWxid) + if (selfEntry && Number.isFinite(selfEntry.messageCount)) { + myGroupMessageCountHint = Math.max(0, Math.floor(selfEntry.messageCount)) + } + } + + if (includeMessageCounts && Number.isFinite(myGroupMessageCountHint)) { + void chatService.setGroupMyMessageCountHint(chatroomId, myGroupMessageCountHint as number) + } + + return { success: true, data: this.sortGroupMembersPanelEntries(data) } + } + + async getGroupMembersPanelData( + chatroomId: string, + options?: { forceRefresh?: boolean; includeMessageCounts?: boolean } + ): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> { + try { + const normalizedChatroomId = String(chatroomId || '').trim() + if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' } + + const forceRefresh = Boolean(options?.forceRefresh) + const includeMessageCounts = options?.includeMessageCounts !== false + const cacheKey = this.buildGroupMembersPanelCacheKey(normalizedChatroomId, includeMessageCounts) + const now = Date.now() + const cached = this.groupMembersPanelCache.get(cacheKey) + if (!forceRefresh && cached && now - cached.updatedAt < this.groupMembersPanelCacheTtlMs) { + return { success: true, data: cached.data, fromCache: true, updatedAt: cached.updatedAt } + } + + if (!forceRefresh) { + const pending = this.groupMembersPanelInFlight.get(cacheKey) + if (pending) return pending + } + + const requestPromise = (async () => { + const conn = await this.ensureConnected() + if (!conn.success) return { success: false, error: conn.error } + + const timeoutMs = includeMessageCounts + ? this.groupMembersPanelFullTimeoutMs + : this.groupMembersPanelMembersTimeoutMs + const fresh = await this.withPromiseTimeout( + this.loadGroupMembersPanelDataFresh(normalizedChatroomId, includeMessageCounts), + timeoutMs, + { + success: false, + error: includeMessageCounts + ? '群成员发言统计加载超时,请稍后重试' + : '群成员列表加载超时,请稍后重试' + } + ) + if (!fresh.success || !fresh.data) { + return { success: false, error: fresh.error || '获取群成员面板数据失败' } + } + + const updatedAt = Date.now() + this.groupMembersPanelCache.set(cacheKey, { updatedAt, data: fresh.data }) + this.pruneGroupMembersPanelCache() + return { success: true, data: fresh.data, fromCache: false, updatedAt } + })().finally(() => { + this.groupMembersPanelInFlight.delete(cacheKey) + }) + + this.groupMembersPanelInFlight.set(cacheKey, requestPromise) + return await requestPromise + } catch (e) { + return { success: false, error: String(e) } + } + } + async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> { try { const conn = await this.ensureConnected() @@ -497,6 +1234,7 @@ class GroupAnalyticsService { username: string avatarUrl?: string originalName?: string + [key: string]: unknown }> const usernames = members.map((m) => m.username).filter(Boolean) @@ -543,6 +1281,7 @@ class GroupAnalyticsService { const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates) const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') + const ownerUsername = await this.detectGroupOwnerUsername(chatroomId, members) const data: GroupMember[] = members.map((m) => { const wxid = m.username || '' const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid @@ -572,7 +1311,8 @@ class GroupAnalyticsService { alias, remark, groupNickname, - avatarUrl: m.avatarUrl + avatarUrl: m.avatarUrl, + isOwner: Boolean(ownerUsername && ownerUsername === wxid) } }) diff --git a/electron/services/groupMyMessageCountCacheService.ts b/electron/services/groupMyMessageCountCacheService.ts new file mode 100644 index 0000000..68ee346 --- /dev/null +++ b/electron/services/groupMyMessageCountCacheService.ts @@ -0,0 +1,204 @@ +import { join, dirname } from 'path' +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs' +import { ConfigService } from './config' + +const CACHE_VERSION = 1 +const MAX_GROUP_ENTRIES_PER_SCOPE = 3000 +const MAX_SCOPE_ENTRIES = 12 + +export interface GroupMyMessageCountCacheEntry { + updatedAt: number + messageCount: number +} + +interface GroupMyMessageCountScopeMap { + [chatroomId: string]: GroupMyMessageCountCacheEntry +} + +interface GroupMyMessageCountCacheStore { + version: number + scopes: Record +} + +function toNonNegativeInt(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined + return Math.max(0, Math.floor(value)) +} + +function normalizeEntry(raw: unknown): GroupMyMessageCountCacheEntry | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + const updatedAt = toNonNegativeInt(source.updatedAt) + const messageCount = toNonNegativeInt(source.messageCount) + if (updatedAt === undefined || messageCount === undefined) return null + return { + updatedAt, + messageCount + } +} + +export class GroupMyMessageCountCacheService { + private readonly cacheFilePath: string + private store: GroupMyMessageCountCacheStore = { + version: CACHE_VERSION, + scopes: {} + } + + constructor(cacheBasePath?: string) { + const basePath = cacheBasePath && cacheBasePath.trim().length > 0 + ? cacheBasePath + : ConfigService.getInstance().getCacheBasePath() + this.cacheFilePath = join(basePath, 'group-my-message-counts.json') + this.ensureCacheDir() + this.load() + } + + private ensureCacheDir(): void { + const dir = dirname(this.cacheFilePath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + } + + private load(): void { + if (!existsSync(this.cacheFilePath)) return + try { + const raw = readFileSync(this.cacheFilePath, 'utf8') + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object') { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + + const payload = parsed as Record + const scopesRaw = payload.scopes + if (!scopesRaw || typeof scopesRaw !== 'object') { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + + const scopes: Record = {} + for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record)) { + if (!scopeValue || typeof scopeValue !== 'object') continue + const normalizedScope: GroupMyMessageCountScopeMap = {} + for (const [chatroomId, entryRaw] of Object.entries(scopeValue as Record)) { + const entry = normalizeEntry(entryRaw) + if (!entry) continue + normalizedScope[chatroomId] = entry + } + if (Object.keys(normalizedScope).length > 0) { + scopes[scopeKey] = normalizedScope + } + } + + this.store = { + version: CACHE_VERSION, + scopes + } + } catch (error) { + console.error('GroupMyMessageCountCacheService: 载入缓存失败', error) + this.store = { version: CACHE_VERSION, scopes: {} } + } + } + + get(scopeKey: string, chatroomId: string): GroupMyMessageCountCacheEntry | undefined { + if (!scopeKey || !chatroomId) return undefined + const scope = this.store.scopes[scopeKey] + if (!scope) return undefined + const entry = normalizeEntry(scope[chatroomId]) + if (!entry) { + delete scope[chatroomId] + if (Object.keys(scope).length === 0) { + delete this.store.scopes[scopeKey] + } + this.persist() + return undefined + } + return entry + } + + set(scopeKey: string, chatroomId: string, entry: GroupMyMessageCountCacheEntry): void { + if (!scopeKey || !chatroomId) return + const normalized = normalizeEntry(entry) + if (!normalized) return + + if (!this.store.scopes[scopeKey]) { + this.store.scopes[scopeKey] = {} + } + + const existing = this.store.scopes[scopeKey][chatroomId] + if (existing && existing.updatedAt > normalized.updatedAt) { + return + } + + this.store.scopes[scopeKey][chatroomId] = normalized + this.trimScope(scopeKey) + this.trimScopes() + this.persist() + } + + delete(scopeKey: string, chatroomId: string): void { + if (!scopeKey || !chatroomId) return + const scope = this.store.scopes[scopeKey] + if (!scope) return + if (!(chatroomId in scope)) return + delete scope[chatroomId] + if (Object.keys(scope).length === 0) { + delete this.store.scopes[scopeKey] + } + this.persist() + } + + clearScope(scopeKey: string): void { + if (!scopeKey) return + if (!this.store.scopes[scopeKey]) return + delete this.store.scopes[scopeKey] + this.persist() + } + + clearAll(): void { + this.store = { version: CACHE_VERSION, scopes: {} } + try { + rmSync(this.cacheFilePath, { force: true }) + } catch (error) { + console.error('GroupMyMessageCountCacheService: 清理缓存失败', error) + } + } + + private trimScope(scopeKey: string): void { + const scope = this.store.scopes[scopeKey] + if (!scope) return + const entries = Object.entries(scope) + if (entries.length <= MAX_GROUP_ENTRIES_PER_SCOPE) return + entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) + const trimmed: GroupMyMessageCountScopeMap = {} + for (const [chatroomId, entry] of entries.slice(0, MAX_GROUP_ENTRIES_PER_SCOPE)) { + trimmed[chatroomId] = entry + } + this.store.scopes[scopeKey] = trimmed + } + + private trimScopes(): void { + const scopeEntries = Object.entries(this.store.scopes) + if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return + scopeEntries.sort((a, b) => { + const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0) + const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0) + return bUpdatedAt - aUpdatedAt + }) + + const trimmedScopes: Record = {} + for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) { + trimmedScopes[scopeKey] = scopeMap + } + this.store.scopes = trimmedScopes + } + + private persist(): void { + try { + writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8') + } catch (error) { + console.error('GroupMyMessageCountCacheService: 保存缓存失败', error) + } + } +} diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 86ea6c9..163106c 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -11,6 +11,7 @@ import { wcdbService } from './wcdbService' import { ConfigService } from './config' import { videoService } from './videoService' import { imageDecryptService } from './imageDecryptService' +import { groupAnalyticsService } from './groupAnalyticsService' // ChatLab 格式定义 interface ChatLabHeader { @@ -102,6 +103,8 @@ class HttpService { private port: number = 5031 private running: boolean = false private connections: Set = new Set() + private messagePushClients: Set = new Set() + private messagePushHeartbeatTimer: ReturnType | null = null private connectionMutex: boolean = false constructor() { @@ -152,6 +155,7 @@ class HttpService { this.server.listen(this.port, '127.0.0.1', () => { this.running = true + this.startMessagePushHeartbeat() console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`) resolve({ success: true, port: this.port }) }) @@ -164,6 +168,16 @@ class HttpService { async stop(): Promise { return new Promise((resolve) => { if (this.server) { + for (const client of this.messagePushClients) { + try { + client.end() + } catch {} + } + this.messagePushClients.clear() + if (this.messagePushHeartbeatTimer) { + clearInterval(this.messagePushHeartbeatTimer) + this.messagePushHeartbeatTimer = null + } // 使用互斥锁保护连接集合操作 this.connectionMutex = true const socketsToClose = Array.from(this.connections) @@ -210,6 +224,28 @@ class HttpService { return this.getApiMediaExportPath() } + getMessagePushStreamUrl(): string { + return `http://127.0.0.1:${this.port}/api/v1/push/messages` + } + + broadcastMessagePush(payload: Record): void { + if (!this.running || this.messagePushClients.size === 0) return + const eventBody = `event: message.new\ndata: ${JSON.stringify(payload)}\n\n` + + for (const client of Array.from(this.messagePushClients)) { + try { + if (client.writableEnded || client.destroyed) { + this.messagePushClients.delete(client) + continue + } + client.write(eventBody) + } catch { + this.messagePushClients.delete(client) + try { client.end() } catch {} + } + } + } + /** * 处理 HTTP 请求 */ @@ -232,12 +268,16 @@ class HttpService { // 路由处理 if (pathname === '/health' || pathname === '/api/v1/health') { this.sendJson(res, { status: 'ok' }) + } else if (pathname === '/api/v1/push/messages') { + this.handleMessagePushStream(req, res) } else if (pathname === '/api/v1/messages') { await this.handleMessages(url, res) } else if (pathname === '/api/v1/sessions') { await this.handleSessions(url, res) } else if (pathname === '/api/v1/contacts') { await this.handleContacts(url, res) + } else if (pathname === '/api/v1/group-members') { + await this.handleGroupMembers(url, res) } else if (pathname.startsWith('/api/v1/media/')) { this.handleMediaRequest(pathname, res) } else { @@ -249,6 +289,50 @@ class HttpService { } } + private startMessagePushHeartbeat(): void { + if (this.messagePushHeartbeatTimer) return + this.messagePushHeartbeatTimer = setInterval(() => { + for (const client of Array.from(this.messagePushClients)) { + try { + if (client.writableEnded || client.destroyed) { + this.messagePushClients.delete(client) + continue + } + client.write(': ping\n\n') + } catch { + this.messagePushClients.delete(client) + try { client.end() } catch {} + } + } + }, 25000) + } + + private handleMessagePushStream(req: http.IncomingMessage, res: http.ServerResponse): void { + if (this.configService.get('messagePushEnabled') !== true) { + this.sendError(res, 403, 'Message push is disabled') + return + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }) + res.flushHeaders?.() + res.write(`event: ready\ndata: ${JSON.stringify({ success: true, stream: this.getMessagePushStreamUrl() })}\n\n`) + + this.messagePushClients.add(res) + + const cleanup = () => { + this.messagePushClients.delete(res) + } + + req.on('close', cleanup) + res.on('close', cleanup) + res.on('error', cleanup) + } + private handleMediaRequest(pathname: string, res: http.ServerResponse): void { const mediaBasePath = this.getApiMediaExportPath() const relativePath = pathname.replace('/api/v1/media/', '') @@ -340,6 +424,7 @@ class HttpService { const trimmedRows = allRows.slice(0, limit) const finalHasMore = hasMore || allRows.length > limit const messages = chatService.mapRowsToMessagesForApi(trimmedRows) + await this.backfillMissingSenderUsernames(talker, messages) return { success: true, messages, hasMore: finalHasMore } } finally { await wcdbService.closeMessageCursor(cursor) @@ -359,6 +444,41 @@ class HttpService { return Math.min(Math.max(parsed, min), max) } + private async backfillMissingSenderUsernames(talker: string, messages: Message[]): Promise { + if (!talker.endsWith('@chatroom')) return + + const targets = messages.filter((msg) => !String(msg.senderUsername || '').trim()) + if (targets.length === 0) return + + const myWxid = (this.configService.get('myWxid') || '').trim() + for (const msg of targets) { + const localId = Number(msg.localId || 0) + if (Number.isFinite(localId) && localId > 0) { + try { + const detail = await wcdbService.getMessageById(talker, localId) + if (detail.success && detail.message) { + const hydrated = chatService.mapRowsToMessagesForApi([detail.message])[0] + if (hydrated?.senderUsername) { + msg.senderUsername = hydrated.senderUsername + } + if ((msg.isSend === null || msg.isSend === undefined) && hydrated?.isSend !== undefined) { + msg.isSend = hydrated.isSend + } + if (!msg.rawContent && hydrated?.rawContent) { + msg.rawContent = hydrated.rawContent + } + } + } catch (error) { + console.warn('[HttpService] backfill sender failed:', error) + } + } + + if (!msg.senderUsername && msg.isSend === 1 && myWxid) { + msg.senderUsername = myWxid + } + } + } + private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean { for (const key of keys) { const raw = url.searchParams.get(key) @@ -553,6 +673,54 @@ class HttpService { } } + /** + * 处理群成员查询 + * GET /api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=0 + */ + private async handleGroupMembers(url: URL, res: http.ServerResponse): Promise { + const chatroomId = (url.searchParams.get('chatroomId') || url.searchParams.get('talker') || '').trim() + const includeMessageCounts = this.parseBooleanParam(url, ['includeMessageCounts', 'withCounts'], false) + const forceRefresh = this.parseBooleanParam(url, ['forceRefresh'], false) + + if (!chatroomId) { + this.sendError(res, 400, 'Missing chatroomId') + return + } + + try { + const result = await groupAnalyticsService.getGroupMembersPanelData(chatroomId, { + forceRefresh, + includeMessageCounts + }) + if (!result.success || !result.data) { + this.sendError(res, 500, result.error || 'Failed to get group members') + return + } + + this.sendJson(res, { + success: true, + chatroomId, + count: result.data.length, + fromCache: result.fromCache, + updatedAt: result.updatedAt, + members: result.data.map((member) => ({ + wxid: member.username, + displayName: member.displayName, + nickname: member.nickname || '', + remark: member.remark || '', + alias: member.alias || '', + groupNickname: member.groupNickname || '', + avatarUrl: member.avatarUrl, + isOwner: Boolean(member.isOwner), + isFriend: Boolean(member.isFriend), + messageCount: Number.isFinite(member.messageCount) ? member.messageCount : 0 + })) + }) + } catch (error) { + this.sendError(res, 500, String(error)) + } + } + private getApiMediaExportPath(): string { return path.join(this.configService.getCacheBasePath(), 'api-media') } @@ -762,6 +930,20 @@ class HttpService { return 0 } + private normalizeAccountId(value: string): string { + const trimmed = String(value || '').trim() + if (!trimmed) return trimmed + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed + } + + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed + } + /** * 获取显示名称 */ @@ -778,6 +960,110 @@ class HttpService { return {} } + private async getAvatarUrls(usernames: string[]): Promise> { + const lookupUsernames = Array.from(new Set( + usernames.flatMap((username) => { + const normalized = String(username || '').trim() + if (!normalized) return [] + const cleaned = this.normalizeAccountId(normalized) + return cleaned && cleaned !== normalized ? [normalized, cleaned] : [normalized] + }) + )) + + if (lookupUsernames.length === 0) return {} + + try { + const result = await wcdbService.getAvatarUrls(lookupUsernames) + if (result.success && result.map) { + const avatarMap: Record = {} + for (const [username, avatarUrl] of Object.entries(result.map)) { + const normalizedUsername = String(username || '').trim() + const normalizedAvatarUrl = String(avatarUrl || '').trim() + if (!normalizedUsername || !normalizedAvatarUrl) continue + + avatarMap[normalizedUsername] = normalizedAvatarUrl + avatarMap[normalizedUsername.toLowerCase()] = normalizedAvatarUrl + + const cleaned = this.normalizeAccountId(normalizedUsername) + if (cleaned) { + avatarMap[cleaned] = normalizedAvatarUrl + avatarMap[cleaned.toLowerCase()] = normalizedAvatarUrl + } + } + return avatarMap + } + } catch (e) { + console.error('[HttpService] Failed to get avatar urls:', e) + } + + return {} + } + + private resolveAvatarUrl(avatarMap: Record, candidates: Array): string | undefined { + for (const candidate of candidates) { + const normalized = String(candidate || '').trim() + if (!normalized) continue + + const cleaned = this.normalizeAccountId(normalized) + const avatarUrl = avatarMap[normalized] + || avatarMap[normalized.toLowerCase()] + || avatarMap[cleaned] + || avatarMap[cleaned.toLowerCase()] + + if (avatarUrl) return avatarUrl + } + + return undefined + } + + private lookupGroupNickname(groupNicknamesMap: Map, sender: string): string { + if (!sender) return '' + const cleaned = this.normalizeAccountId(sender) + return groupNicknamesMap.get(sender) + || groupNicknamesMap.get(sender.toLowerCase()) + || groupNicknamesMap.get(cleaned) + || groupNicknamesMap.get(cleaned.toLowerCase()) + || '' + } + + private resolveChatLabSenderInfo( + msg: Message, + talkerId: string, + talkerName: string, + myWxid: string, + isGroup: boolean, + senderNames: Record, + groupNicknamesMap: Map + ): { sender: string; accountName: string; groupNickname?: string } { + let sender = String(msg.senderUsername || '').trim() + let usedUnknownPlaceholder = false + const sameAsMe = sender && myWxid && sender.toLowerCase() === myWxid.toLowerCase() + const isSelf = msg.isSend === 1 || sameAsMe + + if (!sender && isSelf && myWxid) { + sender = myWxid + } + + if (!sender) { + if (msg.localType === 10000 || msg.localType === 266287972401) { + sender = talkerId + } else { + sender = `unknown_sender_${msg.localId || msg.createTime || 0}` + usedUnknownPlaceholder = true + } + } + + const groupNickname = isGroup ? this.lookupGroupNickname(groupNicknamesMap, sender) : '' + const displayName = senderNames[sender] || groupNickname || (usedUnknownPlaceholder ? '' : sender) + const accountName = isSelf ? '我' : (displayName || '未知发送者') + + return { + sender, + accountName, + groupNickname: groupNickname || undefined + } + } + /** * 转换为 ChatLab 格式 */ @@ -789,6 +1075,7 @@ class HttpService { ): Promise { const isGroup = talkerId.endsWith('@chatroom') const myWxid = this.configService.get('myWxid') || '' + const normalizedMyWxid = this.normalizeAccountId(myWxid).toLowerCase() // 收集所有发送者 const senderSet = new Set() @@ -807,7 +1094,21 @@ class HttpService { try { const result = await wcdbService.getGroupNicknames(talkerId) if (result.success && result.nicknames) { - groupNicknamesMap = new Map(Object.entries(result.nicknames)) + groupNicknamesMap = new Map() + for (const [memberIdRaw, nicknameRaw] of Object.entries(result.nicknames)) { + const memberId = String(memberIdRaw || '').trim() + const nickname = String(nicknameRaw || '').trim() + if (!memberId || !nickname) continue + + groupNicknamesMap.set(memberId, nickname) + groupNicknamesMap.set(memberId.toLowerCase(), nickname) + + const cleaned = this.normalizeAccountId(memberId) + if (cleaned) { + groupNicknamesMap.set(cleaned, nickname) + groupNicknamesMap.set(cleaned.toLowerCase(), nickname) + } + } } } catch (e) { console.error('[HttpService] Failed to get group nicknames:', e) @@ -817,36 +1118,45 @@ class HttpService { // 构建成员列表 const memberMap = new Map() for (const msg of messages) { - const sender = msg.senderUsername || '' - if (sender && !memberMap.has(sender)) { - const displayName = senderNames[sender] || sender - const isSelf = sender === myWxid || sender.toLowerCase() === myWxid.toLowerCase() - // 获取群昵称(尝试多种方式) - const groupNickname = isGroup - ? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '') - : '' - memberMap.set(sender, { - platformId: sender, - accountName: isSelf ? '我' : displayName, - groupNickname: groupNickname || undefined + const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap) + if (!memberMap.has(senderInfo.sender)) { + memberMap.set(senderInfo.sender, { + platformId: senderInfo.sender, + accountName: senderInfo.accountName, + groupNickname: senderInfo.groupNickname }) } } + const [memberAvatarMap, myAvatarResult, sessionAvatarInfo] = await Promise.all([ + this.getAvatarUrls(Array.from(memberMap.keys()).filter((sender) => !sender.startsWith('unknown_sender_'))), + myWxid + ? chatService.getMyAvatarUrl() + : Promise.resolve<{ success: boolean; avatarUrl?: string }>({ success: true }), + isGroup ? chatService.getContactAvatar(talkerId) : Promise.resolve(null) + ]) + + for (const [sender, member] of memberMap.entries()) { + if (sender.startsWith('unknown_sender_')) continue + + const normalizedSender = this.normalizeAccountId(sender).toLowerCase() + const isSelfMember = Boolean(normalizedMyWxid && normalizedSender && normalizedSender === normalizedMyWxid) + const avatarUrl = (isSelfMember ? myAvatarResult.avatarUrl : undefined) + || this.resolveAvatarUrl(memberAvatarMap, isSelfMember ? [sender, myWxid] : [sender]) + + if (avatarUrl) { + member.avatar = avatarUrl + } + } + // 转换消息 const chatLabMessages: ChatLabMessage[] = messages.map(msg => { - const sender = msg.senderUsername || '' - const isSelf = msg.isSend === 1 || sender === myWxid - const accountName = isSelf ? '我' : (senderNames[sender] || sender) - // 获取该发送者的群昵称 - const groupNickname = isGroup - ? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '') - : '' + const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap) return { - sender, - accountName, - groupNickname: groupNickname || undefined, + sender: senderInfo.sender, + accountName: senderInfo.accountName, + groupNickname: senderInfo.groupNickname, timestamp: msg.createTime, type: this.mapMessageType(msg.localType, msg), content: this.getMessageContent(msg), @@ -866,6 +1176,7 @@ class HttpService { platform: 'wechat', type: isGroup ? 'group' : 'private', groupId: isGroup ? talkerId : undefined, + groupAvatar: isGroup ? sessionAvatarInfo?.avatarUrl : undefined, ownerId: myWxid || undefined }, members: Array.from(memberMap.values()), @@ -915,7 +1226,7 @@ class HttpService { * 映射 Type 49 子类型 */ private mapType49(msg: Message): number { - const xmlType = msg.xmlType + const xmlType = this.resolveType49Subtype(msg) switch (xmlType) { case '5': // 链接 @@ -939,10 +1250,97 @@ class HttpService { } } + private extractType49Subtype(rawContent: string): string { + const content = String(rawContent || '') + if (!content) return '' + + const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) + if (appmsgMatch) { + const appmsgInner = appmsgMatch[1] + .replace(//gi, '') + .replace(//gi, '') + const typeMatch = /([\s\S]*?)<\/type>/i.exec(appmsgInner) + if (typeMatch) { + return typeMatch[1].replace(//g, '').trim() + } + } + + const fallbackMatch = /([\s\S]*?)<\/type>/i.exec(content) + if (fallbackMatch) { + return fallbackMatch[1].replace(//g, '').trim() + } + + return '' + } + + private resolveType49Subtype(msg: Message): string { + const xmlType = String(msg.xmlType || '').trim() + if (xmlType) return xmlType + + const extractedType = this.extractType49Subtype(msg.rawContent) + if (extractedType) return extractedType + + switch (msg.appMsgKind) { + case 'official-link': + case 'link': + return '5' + case 'file': + return '6' + case 'chat-record': + return '19' + case 'miniapp': + return '33' + case 'quote': + return '57' + case 'transfer': + return '2000' + case 'red-packet': + return '2001' + case 'music': + return '3' + default: + if (msg.linkUrl) return '5' + if (msg.fileName) return '6' + return '' + } + } + + private getType49Content(msg: Message): string { + const subtype = this.resolveType49Subtype(msg) + const title = msg.linkTitle || msg.fileName || '' + + switch (subtype) { + case '5': + case '49': + return title ? `[链接] ${title}` : '[链接]' + case '6': + return title ? `[文件] ${title}` : '[文件]' + case '19': + return title ? `[聊天记录] ${title}` : '[聊天记录]' + case '33': + case '36': + return title ? `[小程序] ${title}` : '[小程序]' + case '57': + return msg.parsedContent || title || '[引用消息]' + case '2000': + return title ? `[转账] ${title}` : '[转账]' + case '2001': + return title ? `[红包] ${title}` : '[红包]' + case '3': + return title ? `[音乐] ${title}` : '[音乐]' + default: + return msg.parsedContent || title || '[消息]' + } + } + /** * 获取消息内容 */ private getMessageContent(msg: Message): string | null { + if (msg.localType === 49) { + return this.getType49Content(msg) + } + // 优先使用已解析的内容 if (msg.parsedContent) { return msg.parsedContent @@ -965,7 +1363,7 @@ class HttpService { case 48: return '[位置]' case 49: - return msg.linkTitle || msg.fileName || '[消息]' + return this.getType49Content(msg) default: return msg.rawContent || null } diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 13dce67..a16ef52 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -55,14 +55,20 @@ type DecryptResult = { isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) } -type HardlinkState = { - imageTable?: string - dirTable?: string +type CachedImagePayload = { + sessionId?: string + imageMd5?: string + imageDatName?: string + preferFilePath?: boolean +} + +type DecryptImagePayload = CachedImagePayload & { + force?: boolean + hardlinkOnly?: boolean } export class ImageDecryptService { private configService = new ConfigService() - private hardlinkCache = new Map() private resolvedCache = new Map() private pending = new Map>() private readonly defaultV1AesKey = 'cfcd208495d565ef' @@ -106,7 +112,7 @@ export class ImageDecryptService { } } - async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise { + async resolveCachedImage(payload: CachedImagePayload): Promise { await this.ensureCacheIndexed() const cacheKeys = this.getCacheKeys(payload) const cacheKey = cacheKeys[0] @@ -116,7 +122,7 @@ export class ImageDecryptService { for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) if (cached && existsSync(cached) && this.isImageFile(cached)) { - const dataUrl = this.fileToDataUrl(cached) + const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) const isThumb = this.isThumbnailPath(cached) const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false if (isThumb) { @@ -124,8 +130,8 @@ export class ImageDecryptService { } else { this.updateFlags.delete(key) } - this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached)) - return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate } + this.emitCacheResolved(payload, key, this.resolveEmitPath(cached, payload.preferFilePath)) + return { success: true, localPath, hasUpdate } } if (cached && !this.isImageFile(cached)) { this.resolvedCache.delete(key) @@ -136,7 +142,7 @@ export class ImageDecryptService { const existing = this.findCachedOutput(key, false, payload.sessionId) if (existing) { this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing) - const dataUrl = this.fileToDataUrl(existing) + const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath) const isThumb = this.isThumbnailPath(existing) const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false if (isThumb) { @@ -144,27 +150,57 @@ export class ImageDecryptService { } else { this.updateFlags.delete(key) } - this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing)) - return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate } + this.emitCacheResolved(payload, key, this.resolveEmitPath(existing, payload.preferFilePath)) + return { success: true, localPath, hasUpdate } } } this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: '未找到缓存图片' } } - async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise { - await this.ensureCacheIndexed() - const cacheKey = payload.imageMd5 || payload.imageDatName + async decryptImage(payload: DecryptImagePayload): Promise { + if (!payload.hardlinkOnly) { + await this.ensureCacheIndexed() + } + const cacheKeys = this.getCacheKeys(payload) + const cacheKey = cacheKeys[0] if (!cacheKey) { return { success: false, error: '缺少图片标识' } } + if (payload.force) { + for (const key of cacheKeys) { + const cached = this.resolvedCache.get(key) + if (cached && existsSync(cached) && this.isImageFile(cached) && !this.isThumbnailPath(cached)) { + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached) + this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) + const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath)) + return { success: true, localPath } + } + if (cached && !this.isImageFile(cached)) { + this.resolvedCache.delete(key) + } + } + + if (!payload.hardlinkOnly) { + for (const key of cacheKeys) { + const existingHd = this.findCachedOutput(key, true, payload.sessionId) + if (!existingHd || this.isThumbnailPath(existingHd)) continue + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd) + this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) + const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath)) + return { success: true, localPath } + } + } + } + if (!payload.force) { const cached = this.resolvedCache.get(cacheKey) if (cached && existsSync(cached) && this.isImageFile(cached)) { - const dataUrl = this.fileToDataUrl(cached) - const localPath = dataUrl || this.filePathToUrl(cached) - this.emitCacheResolved(payload, cacheKey, localPath) + const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath)) return { success: true, localPath } } if (cached && !this.isImageFile(cached)) { @@ -184,11 +220,47 @@ export class ImageDecryptService { } } + async preloadImageHardlinkMd5s(md5List: string[]): Promise { + const normalizedList = Array.from( + new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)) + ) + if (normalizedList.length === 0) return + + const wxid = this.configService.get('myWxid') + const dbPath = this.configService.get('dbPath') + if (!wxid || !dbPath) return + + const accountDir = this.resolveAccountDir(dbPath, wxid) + if (!accountDir) return + + try { + const ready = await this.ensureWcdbReady() + if (!ready) return + const requests = normalizedList.map((md5) => ({ md5, accountDir })) + const result = await wcdbService.resolveImageHardlinkBatch(requests) + if (!result.success || !Array.isArray(result.rows)) return + + for (const row of result.rows) { + const md5 = String(row?.md5 || '').trim().toLowerCase() + if (!md5) continue + const fullPath = String(row?.data?.full_path || '').trim() + if (!fullPath || !existsSync(fullPath)) continue + this.cacheDatPath(accountDir, md5, fullPath) + const fileName = String(row?.data?.file_name || '').trim().toLowerCase() + if (fileName) { + this.cacheDatPath(accountDir, fileName, fullPath) + } + } + } catch { + // ignore preload failures + } + } + private async decryptImageInternal( - payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }, + payload: DecryptImagePayload, cacheKey: string ): Promise { - this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force }) + this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true }) try { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') @@ -208,7 +280,11 @@ export class ImageDecryptService { payload.imageMd5, payload.imageDatName, payload.sessionId, - { allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) } + { + allowThumbnail: !payload.force, + skipResolvedCache: Boolean(payload.force), + hardlinkOnly: payload.hardlinkOnly === true + } ) // 如果要求高清图但没找到,直接返回提示 @@ -225,26 +301,26 @@ export class ImageDecryptService { if (!extname(datPath).toLowerCase().includes('dat')) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) - const dataUrl = this.fileToDataUrl(datPath) - const localPath = dataUrl || this.filePathToUrl(datPath) + const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath) const isThumb = this.isThumbnailPath(datPath) - this.emitCacheResolved(payload, cacheKey, localPath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath)) return { success: true, localPath, isThumb } } - // 查找已缓存的解密文件 - const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId) - if (existing) { - this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) }) - const isHd = this.isHdPath(existing) - // 如果要求高清但找到的是缩略图,继续解密高清图 - if (!(payload.force && !isHd)) { - this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing) - const dataUrl = this.fileToDataUrl(existing) - const localPath = dataUrl || this.filePathToUrl(existing) - const isThumb = this.isThumbnailPath(existing) - this.emitCacheResolved(payload, cacheKey, localPath) - return { success: true, localPath, isThumb } + // 查找已缓存的解密文件(hardlink-only 模式下跳过全缓存目录扫描) + if (!payload.hardlinkOnly) { + const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId) + if (existing) { + this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) }) + const isHd = this.isHdPath(existing) + // 如果要求高清但找到的是缩略图,继续解密高清图 + if (!(payload.force && !isHd)) { + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing) + const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath) + const isThumb = this.isThumbnailPath(existing) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath)) + return { success: true, localPath, isThumb } + } } } @@ -303,9 +379,11 @@ export class ImageDecryptService { if (!isThumb) { this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) } - const dataUrl = this.bufferToDataUrl(decrypted, finalExt) - const localPath = dataUrl || this.filePathToUrl(outputPath) - this.emitCacheResolved(payload, cacheKey, localPath) + const localPath = payload.preferFilePath + ? outputPath + : (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath)) + const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath) + this.emitCacheResolved(payload, cacheKey, emitPath) return { success: true, localPath, isThumb } } catch (e) { this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) @@ -400,37 +478,53 @@ export class ImageDecryptService { imageMd5?: string, imageDatName?: string, sessionId?: string, - options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean } + options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean } ): Promise { const allowThumbnail = options?.allowThumbnail ?? true const skipResolvedCache = options?.skipResolvedCache ?? false + const hardlinkOnly = options?.hardlinkOnly ?? false this.logInfo('[ImageDecrypt] resolveDatPath', { imageMd5, imageDatName, allowThumbnail, - skipResolvedCache + skipResolvedCache, + hardlinkOnly }) if (!skipResolvedCache) { if (imageMd5) { const cached = this.resolvedCache.get(imageMd5) - if (cached && existsSync(cached)) return cached + if (cached && existsSync(cached)) { + const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) + this.cacheDatPath(accountDir, imageMd5, preferred) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferred) + return preferred + } } if (imageDatName) { const cached = this.resolvedCache.get(imageDatName) - if (cached && existsSync(cached)) return cached + if (cached && existsSync(cached)) { + const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) + this.cacheDatPath(accountDir, imageDatName, preferred) + if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferred) + return preferred + } } } // 1. 通过 MD5 快速定位 (MsgAttach 目录) - if (imageMd5) { - const res = await this.fastProbabilisticSearch(accountDir, imageMd5, allowThumbnail) + if (!hardlinkOnly && allowThumbnail && imageMd5) { + const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail) if (res) return res + if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) { + const datNameRes = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail) + if (datNameRes) return datNameRes + } } // 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位 - if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { - const res = await this.fastProbabilisticSearch(accountDir, imageDatName, allowThumbnail) + if (!hardlinkOnly && allowThumbnail && !imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { + const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail) if (res) return res } @@ -439,16 +533,17 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId }) const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId) if (hardlinkPath) { - const isThumb = this.isThumbnailPath(hardlinkPath) + const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail) + const isThumb = this.isThumbnailPath(preferredPath) if (allowThumbnail || !isThumb) { - this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: hardlinkPath }) - this.cacheDatPath(accountDir, imageMd5, hardlinkPath) - if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) - return hardlinkPath + this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: preferredPath }) + this.cacheDatPath(accountDir, imageMd5, preferredPath) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferredPath) + return preferredPath } // hardlink 找到的是缩略图,但要求高清图 // 尝试在同一目录下查找高清图变体(快速查找,不遍历) - const hdPath = this.findHdVariantInSameDir(hardlinkPath) + const hdPath = this.findHdVariantInSameDir(preferredPath) if (hdPath) { this.cacheDatPath(accountDir, imageMd5, hdPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) @@ -462,16 +557,19 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId }) const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) if (fallbackPath) { - const isThumb = this.isThumbnailPath(fallbackPath) + const preferredPath = this.getPreferredDatVariantPath(fallbackPath, allowThumbnail) + const isThumb = this.isThumbnailPath(preferredPath) if (allowThumbnail || !isThumb) { - this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: fallbackPath }) - this.cacheDatPath(accountDir, imageDatName, fallbackPath) - return fallbackPath + this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath }) + this.cacheDatPath(accountDir, imageDatName, preferredPath) + if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferredPath) + return preferredPath } // 找到缩略图但要求高清图,尝试同目录查找高清图变体 - const hdPath = this.findHdVariantInSameDir(fallbackPath) + const hdPath = this.findHdVariantInSameDir(preferredPath) if (hdPath) { this.cacheDatPath(accountDir, imageDatName, hdPath) + if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hdPath) return hdPath } return null @@ -484,14 +582,15 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId }) const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) if (hardlinkPath) { - const isThumb = this.isThumbnailPath(hardlinkPath) + const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail) + const isThumb = this.isThumbnailPath(preferredPath) if (allowThumbnail || !isThumb) { - this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: hardlinkPath }) - this.cacheDatPath(accountDir, imageDatName, hardlinkPath) - return hardlinkPath + this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: preferredPath }) + this.cacheDatPath(accountDir, imageDatName, preferredPath) + return preferredPath } // hardlink 找到的是缩略图,但要求高清图 - const hdPath = this.findHdVariantInSameDir(hardlinkPath) + const hdPath = this.findHdVariantInSameDir(preferredPath) if (hdPath) { this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath @@ -501,6 +600,11 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) } + if (hardlinkOnly) { + this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink-only)', { imageMd5, imageDatName }) + return null + } + // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) if (!allowThumbnail) { return null @@ -510,9 +614,10 @@ export class ImageDecryptService { if (!skipResolvedCache) { const cached = this.resolvedCache.get(imageDatName) if (cached && existsSync(cached)) { - if (allowThumbnail || !this.isThumbnailPath(cached)) return cached + const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) + if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred // 缓存的是缩略图,尝试找高清图 - const hdPath = this.findHdVariantInSameDir(cached) + const hdPath = this.findHdVariantInSameDir(preferred) if (hdPath) return hdPath } } @@ -634,45 +739,19 @@ export class ImageDecryptService { private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise { try { - const hardlinkPath = this.resolveHardlinkDbPath(accountDir) - if (!hardlinkPath) { - return null - } - const ready = await this.ensureWcdbReady() if (!ready) { this.logInfo('[ImageDecrypt] hardlink db not ready') return null } - const state = await this.getHardlinkState(accountDir, hardlinkPath) - if (!state.imageTable) { - this.logInfo('[ImageDecrypt] hardlink table missing', { hardlinkPath }) - return null - } + const resolveResult = await wcdbService.resolveImageHardlink(md5, accountDir) + if (!resolveResult.success || !resolveResult.data) return null + const fileName = String(resolveResult.data.file_name || '').trim() + const fullPath = String(resolveResult.data.full_path || '').trim() + if (!fileName) return null - const escapedMd5 = this.escapeSqlString(md5) - const rowResult = await wcdbService.execQuery( - 'media', - hardlinkPath, - `SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1` - ) - const row = rowResult.success && rowResult.rows ? rowResult.rows[0] : null - - if (!row) { - this.logInfo('[ImageDecrypt] hardlink row miss', { md5, table: state.imageTable }) - return null - } - - const dir1 = this.getRowValue(row, 'dir1') - const dir2 = this.getRowValue(row, 'dir2') - const fileName = this.getRowValue(row, 'file_name') ?? this.getRowValue(row, 'fileName') - if (dir1 === undefined || dir2 === undefined || !fileName) { - this.logInfo('[ImageDecrypt] hardlink row incomplete', { row }) - return null - } - - const lowerFileName = fileName.toLowerCase() + const lowerFileName = String(fileName).toLowerCase() if (lowerFileName.endsWith('.dat')) { const baseLower = lowerFileName.slice(0, -4) if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) { @@ -681,57 +760,11 @@ export class ImageDecryptService { } } - // dir1 和 dir2 是 rowid,需要从 dir2id 表查询对应的目录名 - let dir1Name: string | null = null - let dir2Name: string | null = null - - if (state.dirTable) { - try { - // 通过 rowid 查询目录名 - const dir1Result = await wcdbService.execQuery( - 'media', - hardlinkPath, - `SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir1)} LIMIT 1` - ) - if (dir1Result.success && dir1Result.rows && dir1Result.rows.length > 0) { - const value = this.getRowValue(dir1Result.rows[0], 'username') - if (value) dir1Name = String(value) - } - - const dir2Result = await wcdbService.execQuery( - 'media', - hardlinkPath, - `SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir2)} LIMIT 1` - ) - if (dir2Result.success && dir2Result.rows && dir2Result.rows.length > 0) { - const value = this.getRowValue(dir2Result.rows[0], 'username') - if (value) dir2Name = String(value) - } - } catch { - // ignore - } + if (fullPath && existsSync(fullPath)) { + this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath }) + return fullPath } - - if (!dir1Name || !dir2Name) { - this.logInfo('[ImageDecrypt] hardlink dir resolve miss', { dir1, dir2, dir1Name, dir2Name }) - return null - } - - // 构建路径: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName} - const possiblePaths = [ - join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Img', fileName), - join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName), - join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName), - ] - - for (const fullPath of possiblePaths) { - if (existsSync(fullPath)) { - this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath }) - return fullPath - } - } - - this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths }) + this.logInfo('[ImageDecrypt] hardlink path miss', { fullPath, md5 }) return null } catch { // ignore @@ -739,35 +772,6 @@ export class ImageDecryptService { return null } - private async getHardlinkState(accountDir: string, hardlinkPath: string): Promise { - const cached = this.hardlinkCache.get(hardlinkPath) - if (cached) return cached - - const imageResult = await wcdbService.execQuery( - 'media', - hardlinkPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1" - ) - const dirResult = await wcdbService.execQuery( - 'media', - hardlinkPath, - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1" - ) - const imageTable = imageResult.success && imageResult.rows && imageResult.rows.length > 0 - ? this.getRowValue(imageResult.rows[0], 'name') - : undefined - const dirTable = dirResult.success && dirResult.rows && dirResult.rows.length > 0 - ? this.getRowValue(dirResult.rows[0], 'name') - : undefined - const state: HardlinkState = { - imageTable: imageTable ? String(imageTable) : undefined, - dirTable: dirTable ? String(dirTable) : undefined - } - this.logInfo('[ImageDecrypt] hardlink state', { hardlinkPath, imageTable: state.imageTable, dirTable: state.dirTable }) - this.hardlinkCache.set(hardlinkPath, state) - return state - } - private async ensureWcdbReady(): Promise { if (wcdbService.isReady()) return true const dbPath = this.configService.get('dbPath') @@ -801,7 +805,8 @@ export class ImageDecryptService { const key = `${accountDir}|${datName}` const cached = this.resolvedCache.get(key) if (cached && existsSync(cached)) { - if (allowThumbnail || !this.isThumbnailPath(cached)) return cached + const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) + if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred } const root = join(accountDir, 'msg', 'attach') @@ -810,7 +815,7 @@ export class ImageDecryptService { // 优化1:快速概率性查找 // 包含:1. 基于文件名的前缀猜测 (旧版) // 2. 基于日期的最近月份扫描 (新版无索引时) - const fastHit = await this.fastProbabilisticSearch(root, datName) + const fastHit = await this.fastProbabilisticSearch(root, datName, allowThumbnail) if (fastHit) { this.resolvedCache.set(key, fastHit) return fastHit @@ -830,33 +835,28 @@ export class ImageDecryptService { * 包含:1. 微信旧版结构 filename.substr(0, 2)/... * 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename */ - private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise { + private async fastProbabilisticSearch(root: string, datName: string, allowThumbnail = true): Promise { const { promises: fs } = require('fs') const { join } = require('path') try { // --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) --- const lowerName = datName.toLowerCase() - let baseName = lowerName - if (baseName.endsWith('.dat')) { - baseName = baseName.slice(0, -4) - if (baseName.endsWith('_t') || baseName.endsWith('.t') || baseName.endsWith('_hd')) { - baseName = baseName.slice(0, -3) - } else if (baseName.endsWith('_thumb')) { - baseName = baseName.slice(0, -6) - } - } + const baseName = this.normalizeDatBase(lowerName) + const targetNames = this.buildPreferredDatNames(baseName, allowThumbnail) const candidates: string[] = [] if (/^[a-f0-9]{32}$/.test(baseName)) { const dir1 = baseName.substring(0, 2) const dir2 = baseName.substring(2, 4) - candidates.push( - join(root, dir1, dir2, datName), - join(root, dir1, dir2, 'Img', datName), - join(root, dir1, dir2, 'mg', datName), - join(root, dir1, dir2, 'Image', datName) - ) + for (const targetName of targetNames) { + candidates.push( + join(root, dir1, dir2, targetName), + join(root, dir1, dir2, 'Img', targetName), + join(root, dir1, dir2, 'mg', targetName), + join(root, dir1, dir2, 'Image', targetName) + ) + } } for (const path of candidates) { @@ -877,19 +877,13 @@ export class ImageDecryptService { const now = new Date() const months: string[] = [] - for (let i = 0; i < 2; i++) { + // Imported mobile history can live in older YYYY-MM buckets; keep this bounded but wider than "recent 2 months". + for (let i = 0; i < 24; i++) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1) const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` months.push(mStr) } - const targetNames = [datName] - if (baseName !== lowerName) { - targetNames.push(`${baseName}.dat`) - targetNames.push(`${baseName}_t.dat`) - targetNames.push(`${baseName}_thumb.dat`) - } - const batchSize = 20 for (let i = 0; i < sessionDirs.length; i += batchSize) { const batch = sessionDirs.slice(i, i + batchSize) @@ -919,36 +913,13 @@ export class ImageDecryptService { /** * 在同一目录下查找高清图变体 - * 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat + * 优先 `_h`,再回退其他非缩略图变体 */ private findHdVariantInSameDir(thumbPath: string): string | null { try { const dir = dirname(thumbPath) - const fileName = basename(thumbPath).toLowerCase() - - // 提取基础名称(去掉 _t.dat 或 .t.dat) - let baseName = fileName - if (baseName.endsWith('_t.dat')) { - baseName = baseName.slice(0, -6) - } else if (baseName.endsWith('.t.dat')) { - baseName = baseName.slice(0, -6) - } else { - return null - } - - // 尝试查找高清图变体 - const variants = [ - `${baseName}_h.dat`, - `${baseName}.h.dat`, - `${baseName}.dat` - ] - - for (const variant of variants) { - const variantPath = join(dir, variant) - if (existsSync(variantPath)) { - return variantPath - } - } + const fileName = basename(thumbPath) + return this.findPreferredDatVariantInDir(dir, fileName, false) } catch { } return null } @@ -998,7 +969,86 @@ export class ImageDecryptService { void worker.terminate() resolve(null) }) - }) + }) + } + + private stripDatVariantSuffix(base: string): string { + const lower = base.toLowerCase() + const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c'] + for (const suffix of suffixes) { + if (lower.endsWith(suffix)) { + return lower.slice(0, -suffix.length) + } + } + if (/[._][a-z]$/.test(lower)) { + return lower.slice(0, -2) + } + return lower + } + + private getDatVariantPriority(name: string): number { + const lower = name.toLowerCase() + const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower + if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600 + if (!this.hasXVariant(baseLower)) return 500 + if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450 + if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 + if (this.isThumbnailDat(lower)) return 100 + return 350 + } + + private buildPreferredDatNames(baseName: string, allowThumbnail: boolean): string[] { + if (!baseName) return [] + const names = [ + `${baseName}_h.dat`, + `${baseName}.h.dat`, + `${baseName}.dat`, + `${baseName}_hd.dat`, + `${baseName}.hd.dat`, + `${baseName}_c.dat`, + `${baseName}.c.dat` + ] + if (allowThumbnail) { + names.push( + `${baseName}_thumb.dat`, + `${baseName}.thumb.dat`, + `${baseName}_t.dat`, + `${baseName}.t.dat` + ) + } + return Array.from(new Set(names)) + } + + private findPreferredDatVariantInDir(dirPath: string, baseName: string, allowThumbnail: boolean): string | null { + let entries: string[] + try { + entries = readdirSync(dirPath) + } catch { + return null + } + const target = this.normalizeDatBase(baseName.toLowerCase()) + let bestPath: string | null = null + let bestScore = Number.NEGATIVE_INFINITY + for (const entry of entries) { + const lower = entry.toLowerCase() + if (!lower.endsWith('.dat')) continue + if (!allowThumbnail && this.isThumbnailDat(lower)) continue + const baseLower = lower.slice(0, -4) + if (this.normalizeDatBase(baseLower) !== target) continue + const score = this.getDatVariantPriority(lower) + if (score > bestScore) { + bestScore = score + bestPath = join(dirPath, entry) + } + } + return bestPath + } + + private getPreferredDatVariantPath(datPath: string, allowThumbnail: boolean): string { + const lower = datPath.toLowerCase() + if (!lower.endsWith('.dat')) return datPath + const preferred = this.findPreferredDatVariantInDir(dirname(datPath), basename(datPath), allowThumbnail) + return preferred || datPath } private normalizeDatBase(name: string): string { @@ -1006,18 +1056,21 @@ export class ImageDecryptService { if (base.endsWith('.dat') || base.endsWith('.jpg')) { base = base.slice(0, -4) } - while (/[._][a-z]$/.test(base)) { - base = base.slice(0, -2) + for (;;) { + const stripped = this.stripDatVariantSuffix(base) + if (stripped === base) { + return base + } + base = stripped } - return base } private hasImageVariantSuffix(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) + return this.stripDatVariantSuffix(baseLower) !== baseLower } private isLikelyImageDatBase(baseLower: string): boolean { - return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower) + return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(this.normalizeDatBase(baseLower)) } @@ -1206,24 +1259,7 @@ export class ImageDecryptService { } private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null { - let entries: string[] - try { - entries = readdirSync(dirPath) - } catch { - return null - } - const target = this.normalizeDatBase(baseName.toLowerCase()) - for (const entry of entries) { - const lower = entry.toLowerCase() - if (!lower.endsWith('.dat')) continue - if (this.isThumbnailDat(lower)) continue - const baseLower = lower.slice(0, -4) - // 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的) - if (!this.hasXVariant(baseLower)) continue - if (this.normalizeDatBase(baseLower) !== target) continue - return join(dirPath, entry) - } - return null + return this.findPreferredDatVariantInDir(dirPath, baseName, false) } private isNonThumbnailVariantDat(datPath: string): boolean { @@ -1231,8 +1267,7 @@ export class ImageDecryptService { if (!lower.endsWith('.dat')) return false if (this.isThumbnailDat(lower)) return false const baseLower = lower.slice(0, -4) - // 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的) - return this.hasXVariant(baseLower) + return this.isLikelyImageDatBase(baseLower) } private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void { @@ -1521,6 +1556,16 @@ export class ImageDecryptService { return `data:${mimeType};base64,${buffer.toString('base64')}` } + private resolveLocalPathForPayload(filePath: string, preferFilePath?: boolean): string { + if (preferFilePath) return filePath + return this.resolveEmitPath(filePath, false) + } + + private resolveEmitPath(filePath: string, preferFilePath?: boolean): string { + if (preferFilePath) return this.filePathToUrl(filePath) + return this.fileToDataUrl(filePath) || this.filePathToUrl(filePath) + } + private fileToDataUrl(filePath: string): string | null { try { const ext = extname(filePath).toLowerCase() @@ -1858,7 +1903,7 @@ export class ImageDecryptService { private hasXVariant(base: string): boolean { const lower = base.toLowerCase() - return lower.endsWith('_h') || lower.endsWith('_hd') || lower.endsWith('_thumb') || lower.endsWith('_t') + return this.stripDatVariantSuffix(lower) !== lower } private isHdPath(p: string): boolean { @@ -1912,7 +1957,6 @@ export class ImageDecryptService { async clearCache(): Promise<{ success: boolean; error?: string }> { this.resolvedCache.clear() - this.hardlinkCache.clear() this.pending.clear() this.updateFlags.clear() this.cacheIndexed = false diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 3168f1c..25b0aa1 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -12,6 +12,7 @@ type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: stri type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } export class KeyService { + private readonly isMac = process.platform === 'darwin' private koffi: any = null private lib: any = null private initialized = false @@ -509,6 +510,58 @@ export class KeyService { return false } + private isLoginRelatedText(value: string): boolean { + const normalized = String(value || '').replace(/\s+/g, '').toLowerCase() + if (!normalized) return false + const keywords = [ + '登录', + '扫码', + '二维码', + '请在手机上确认', + '手机确认', + '切换账号', + 'wechatlogin', + 'qrcode', + 'scan' + ] + return keywords.some((keyword) => normalized.includes(keyword)) + } + + private async detectWeChatLoginRequired(pid: number): Promise { + if (!this.ensureUser32()) return false + let loginRequired = false + + const enumWindowsCallback = this.koffi.register((hWnd: any, _lParam: any) => { + if (!this.IsWindowVisible(hWnd)) return true + const title = this.getWindowTitle(hWnd) + if (!this.isWeChatWindowTitle(title)) return true + + const pidBuf = Buffer.alloc(4) + this.GetWindowThreadProcessId(hWnd, pidBuf) + const windowPid = pidBuf.readUInt32LE(0) + if (windowPid !== pid) return true + + if (this.isLoginRelatedText(title)) { + loginRequired = true + return false + } + + const children = this.collectChildWindowInfos(hWnd) + for (const child of children) { + if (this.isLoginRelatedText(child.title) || this.isLoginRelatedText(child.className)) { + loginRequired = true + return false + } + } + return true + }, this.WNDENUMPROC_PTR) + + this.EnumWindows(enumWindowsCallback, 0) + this.koffi.unregister(enumWindowsCallback) + + return loginRequired + } + private async waitForWeChatWindowComponents(pid: number, timeoutMs = 15000): Promise { if (!this.ensureUser32()) return true const startTime = Date.now() @@ -553,34 +606,14 @@ export class KeyService { const logs: string[] = [] - onStatus?.('正在定位微信安装路径...', 0) - let wechatPath = await this.findWeChatInstallPath() - if (!wechatPath) { - const err = '未找到微信安装路径,请确认已安装PC微信' + onStatus?.('正在查找微信进程...', 0) + const pid = await this.findWeChatPid() + if (!pid) { + const err = '未找到微信进程,请先启动微信' onStatus?.(err, 2) return { success: false, error: err } } - onStatus?.('正在关闭微信以进行获取...', 0) - const closed = await this.killWeChatProcesses() - if (!closed) { - const err = '无法自动关闭微信,请手动退出后重试' - onStatus?.(err, 2) - return { success: false, error: err } - } - - onStatus?.('正在启动微信...', 0) - const sub = spawn(wechatPath, { - detached: true, - stdio: 'ignore', - cwd: dirname(wechatPath) - }) - sub.unref() - - onStatus?.('等待微信界面就绪...', 0) - const pid = await this.waitForWeChatWindow() - if (!pid) return { success: false, error: '启动微信失败或等待界面就绪超时' } - onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0) onStatus?.('正在检测微信界面组件...', 0) await this.waitForWeChatWindowComponents(pid, 15000) @@ -605,6 +638,7 @@ export class KeyService { const keyBuffer = Buffer.alloc(128) const start = Date.now() + let loginRequiredDetected = false try { while (Date.now() - start < timeoutMs) { @@ -624,6 +658,9 @@ export class KeyService { const level = levelOut[0] ?? 0 if (msg) { logs.push(msg) + if (this.isLoginRelatedText(msg)) { + loginRequiredDetected = true + } onStatus?.(msg, level) } } @@ -635,6 +672,15 @@ export class KeyService { } catch { } } + const loginRequired = loginRequiredDetected || await this.detectWeChatLoginRequired(pid) + if (loginRequired) { + return { + success: false, + error: '微信已启动但尚未完成登录,请先在微信客户端完成登录后再重试自动获取密钥。', + logs + } + } + return { success: false, error: '获取密钥超时', logs } } @@ -649,6 +695,68 @@ export class KeyService { return wxid.substring(0, second) } + private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } { + const cleanedWxid = this.cleanWxid(wxid) + const xorKey = code & 0xFF + const dataToHash = code.toString() + cleanedWxid + const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex') + const aesKey = md5Full.substring(0, 16) + return { xorKey, aesKey } + } + + private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean { + try { + if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false + const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true + if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true + if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true + if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true + if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true + return false + } catch { + return false + } + } + + private async collectWxidCandidates(manualDir?: string, wxidParam?: string): Promise { + const candidates: string[] = [] + const pushUnique = (value: string) => { + const v = String(value || '').trim() + if (!v || candidates.includes(v)) return + candidates.push(v) + } + + if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam) + + if (manualDir) { + const normalized = manualDir.replace(/[\\/]+$/, '') + const dirName = normalized.split(/[\\/]/).pop() ?? '' + if (dirName.startsWith('wxid_')) pushUnique(dirName) + + const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i) + if (marker) { + const root = normalized.slice(0, marker.index! + marker[0].length) + try { + const { readdirSync, statSync } = await import('fs') + const { join } = await import('path') + for (const entry of readdirSync(root)) { + if (!entry.startsWith('wxid_')) continue + const full = join(root, entry) + try { + if (statSync(full).isDirectory()) pushUnique(entry) + } catch { } + } + } catch { } + } + } + + pushUnique('unknown') + return candidates + } + async autoGetImageKey( manualDir?: string, onProgress?: (message: string) => void, @@ -684,51 +792,295 @@ export class KeyService { const codes: number[] = accounts[0].keys.map((k: any) => k.code) console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid)) - // 优先级: 1. 直接传入的wxidParam 2. 从manualDir提取 3. DLL返回的wxid(可能是unknown) - let targetWxid = '' - - // 方案1: 直接使用传入的wxidParam(最优先) - if (wxidParam && wxidParam.startsWith('wxid_')) { - targetWxid = wxidParam - console.log('[ImageKey] 使用直接传入的 wxid:', targetWxid) + const wxidCandidates = await this.collectWxidCandidates(manualDir, wxidParam) + let verifyCiphertext: Buffer | null = null + if (manualDir && existsSync(manualDir)) { + const template = await this._findTemplateData(manualDir, 32) + verifyCiphertext = template.ciphertext } - - // 方案2: 从 manualDir 提取前端已配置好的正确 wxid - // 格式: "D:\weixin\xwechat_files\wxid_xxx_1234" → "wxid_xxx_1234" - if (!targetWxid && manualDir) { - const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? '' - if (dirName.startsWith('wxid_')) { - targetWxid = dirName - console.log('[ImageKey] 从 manualDir 提取 wxid:', targetWxid) + + if (verifyCiphertext) { + onProgress?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`) + for (const candidateWxid of wxidCandidates) { + for (const code of codes) { + const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) + if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue + onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) + console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code) + return { success: true, xorKey, aesKey } + } } + return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' } } - // 方案3: 回退到 DLL 发现的第一个(可能是 unknown) - if (!targetWxid) { - targetWxid = accounts[0].wxid - console.log('[ImageKey] 无法获取 wxid,使用 DLL 发现的:', targetWxid) - } + // 无模板密文可验真时回退旧策略 + const fallbackWxid = wxidCandidates[0] || accounts[0].wxid || 'unknown' + const fallbackCode = codes[0] + const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid) + onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`) + console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode) + return { success: true, xorKey, aesKey } + } - // CleanWxid: 截断到第二个下划线,与 xkey 算法一致 - const cleanedWxid = this.cleanWxid(targetWxid) - console.log('[ImageKey] wxid:', targetWxid, '→ cleaned:', cleanedWxid) + // --- 内存扫描备选方案(融合 Dart+Python 优点)--- + // 只扫 RW 可写区域(更快),同时支持 ASCII 和 UTF-16LE 两种密钥格式 + // 验证支持 JPEG/PNG/WEBP/WXGF/GIF 多种格式 - // 用 cleanedWxid + code 本地计算密钥 - // xorKey = code & 0xFF - // aesKey = MD5(code.toString() + cleanedWxid).substring(0, 16) - const code = codes[0] - const xorKey = code & 0xFF - const dataToHash = code.toString() + cleanedWxid - const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex') - const aesKey = md5Full.substring(0, 16) + async autoGetImageKeyByMemoryScan( + userDir: string, + onProgress?: (message: string) => void + ): Promise { + if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' } - onProgress?.(`密钥获取成功 (wxid: ${targetWxid}, code: ${code})`) - console.log('[ImageKey] 计算结果: xorKey=', xorKey, 'aesKey=', aesKey) + try { + // 1. 查找模板文件获取密文和 XOR 密钥 + onProgress?.('正在查找模板文件...') + let result = await this._findTemplateData(userDir, 32) + let { ciphertext, xorKey } = result + + // 如果找不到密钥,尝试扫描更多文件 + if (ciphertext && xorKey === null) { + onProgress?.('未找到有效密钥,尝试扫描更多文件...') + result = await this._findTemplateData(userDir, 100) + xorKey = result.xorKey + } + + if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' } + if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' } - return { - success: true, - xorKey, - aesKey + onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) + + // 2. 找微信 PID + const pid = await this.findWeChatPid() + if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' } + + onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`) + + // 3. 持续轮询内存扫描,最多 60 秒 + const deadline = Date.now() + 60_000 + let scanCount = 0 + while (Date.now() < deadline) { + scanCount++ + onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`) + const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress) + if (aesKey) { + onProgress?.('密钥获取成功') + return { success: true, xorKey, aesKey } + } + // 等 5 秒再试 + await new Promise(r => setTimeout(r, 5000)) + } + + return { + success: false, + error: '60 秒内未找到 AES 密钥。\n请确保已在微信中打开 2-3 张图片大图后再试。' + } + } catch (e) { + return { success: false, error: `内存扫描失败: ${e}` } } } -} \ No newline at end of file + + private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> { + const { readdirSync, readFileSync, statSync } = await import('fs') + const { join } = await import('path') + const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) + + // 递归收集 *_t.dat 文件 + const collect = (dir: string, results: string[], maxFiles: number) => { + if (results.length >= maxFiles) return + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (results.length >= maxFiles) break + const full = join(dir, entry.name) + if (entry.isDirectory()) collect(full, results, maxFiles) + else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full) + } + } catch { /* 忽略无权限目录 */ } + } + + const files: string[] = [] + collect(userDir, files, limit) + + // 按修改时间降序 + files.sort((a, b) => { + try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 } + }) + + let ciphertext: Buffer | null = null + const tailCounts: Record = {} + + for (const f of files.slice(0, 32)) { + try { + const data = readFileSync(f) + if (data.length < 8) continue + + // 统计末尾两字节用于 XOR 密钥 + if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) { + const key = `${data[data.length - 2]}_${data[data.length - 1]}` + tailCounts[key] = (tailCounts[key] ?? 0) + 1 + } + + // 提取密文(取第一个有效的) + if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) { + ciphertext = data.subarray(0xF, 0x1F) + } + } catch { /* 忽略 */ } + } + + // 计算 XOR 密钥 + let xorKey: number | null = null + let maxCount = 0 + for (const [key, count] of Object.entries(tailCounts)) { + if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k } + } + + return { ciphertext, xorKey } + } + + private async _scanMemoryForAesKey( + pid: number, + ciphertext: Buffer, + onProgress?: (msg: string) => void + ): Promise { + if (!this.ensureKernel32()) return null + + // 直接用已加载的 kernel32 实例,用 uintptr 传地址 + const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', ['void*', 'uintptr', 'void*', 'size_t']) + const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['void*', 'uintptr', 'void*', 'size_t', this.koffi.out('size_t*')]) + + // RW 保护标志(只扫可写区域,速度更快) + const RW_FLAGS = 0x04 | 0x08 | 0x40 | 0x80 // PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY + const MEM_COMMIT = 0x1000 + const PAGE_NOACCESS = 0x01 + const PAGE_GUARD = 0x100 + const MBI_SIZE = 48 // MEMORY_BASIC_INFORMATION size on x64 + + const hProcess = this.OpenProcess(0x1F0FFF, false, pid) + if (!hProcess) return null + + try { + // 枚举 RW 内存区域 + const regions: Array<[number, number]> = [] + let addr = 0 + const mbi = Buffer.alloc(MBI_SIZE) + + while (addr < 0x7FFFFFFFFFFF) { + const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE) + if (ret === 0) break + // MEMORY_BASIC_INFORMATION x64 布局: + // 0: BaseAddress (8) + // 8: AllocationBase (8) + // 16: AllocationProtect (4) + 4 padding + // 24: RegionSize (8) + // 32: State (4) + // 36: Protect (4) + // 40: Type (4) + 4 padding = 48 total + const base = Number(mbi.readBigUInt64LE(0)) + const size = Number(mbi.readBigUInt64LE(24)) + const state = mbi.readUInt32LE(32) + const protect = mbi.readUInt32LE(36) + + if (state === MEM_COMMIT && + protect !== PAGE_NOACCESS && + (protect & PAGE_GUARD) === 0 && + (protect & RW_FLAGS) !== 0 && + size <= 50 * 1024 * 1024) { + regions.push([base, size]) + } + const next = base + size + if (next <= addr) break + addr = next + } + + const totalMB = regions.reduce((s, [, sz]) => s + sz, 0) / 1024 / 1024 + onProgress?.(`扫描 ${regions.length} 个 RW 区域 (${totalMB.toFixed(0)} MB)...`) + + const CHUNK = 4 * 1024 * 1024 + const OVERLAP = 65 + + for (let i = 0; i < regions.length; i++) { + const [base, size] = regions[i] + if (i % 20 === 0) { + onProgress?.(`扫描进度 ${i}/${regions.length}...`) + await new Promise(r => setTimeout(r, 1)) // 让出事件循环 + } + + let offset = 0 + let trailing: Buffer | null = null + + while (offset < size) { + const chunkSize = Math.min(CHUNK, size - offset) + const buf = Buffer.alloc(chunkSize) + const bytesReadOut = [0] + const ok = ReadProcessMemory(hProcess, base + offset, buf, chunkSize, bytesReadOut) + if (!ok || bytesReadOut[0] === 0) { offset += chunkSize; trailing = null; continue } + + const data: Buffer = trailing ? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])]) : buf.subarray(0, bytesReadOut[0]) + + // 搜索 ASCII 32字节密钥 + const key = this._searchAsciiKey(data, ciphertext) + if (key) { this.CloseHandle(hProcess); return key } + + // 搜索 UTF-16LE 32字节密钥 + const key16 = this._searchUtf16Key(data, ciphertext) + if (key16) { this.CloseHandle(hProcess); return key16 } + + trailing = data.subarray(Math.max(0, data.length - OVERLAP)) + offset += chunkSize + } + } + + return null + } finally { + this.CloseHandle(hProcess) + } + } + + private _searchAsciiKey(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i < data.length - 34; i++) { + if (this._isAlphaNum(data[i])) continue + let valid = true + for (let j = 1; j <= 32; j++) { + if (!this._isAlphaNum(data[i + j])) { valid = false; break } + } + if (!valid) continue + if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue + const keyBytes = data.subarray(i + 1, i + 33) + if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) + } + return null + } + + private _searchUtf16Key(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i < data.length - 65; i++) { + let valid = true + for (let j = 0; j < 32; j++) { + if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break } + } + if (!valid) continue + const keyBytes = Buffer.alloc(32) + for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2] + if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) + } + return null + } + + private _isAlphaNum(b: number): boolean { + return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39) + } + + private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean { + try { + const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes.subarray(0, 16), null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + // 支持 JPEG / PNG / WEBP / WXGF / GIF + if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true + if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true + if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true + if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true + if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true + return false + } catch { return false } + } +} diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts new file mode 100644 index 0000000..7364f83 --- /dev/null +++ b/electron/services/keyServiceLinux.ts @@ -0,0 +1,364 @@ +import { app } from 'electron' +import { join } from 'path' +import { existsSync, readdirSync, statSync, readFileSync } from 'fs' +import { execFile, exec, spawn } from 'child_process' +import { promisify } from 'util' +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +const execFileAsync = promisify(execFile) +const execAsync = promisify(exec) + +type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } +type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } + +export class KeyServiceLinux { + private sudo: any + + constructor() { + try { + this.sudo = require('sudo-prompt'); + } catch (e) { + console.error('Failed to load sudo-prompt', e); + } + } + + private getHelperPath(): string { + const isPackaged = app.isPackaged + const candidates: string[] = [] + if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH) + if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux')) + candidates.push(join(process.resourcesPath, 'xkey_helper_linux')) + } else { + candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux')) + candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux')) + } + for (const p of candidates) { + if (existsSync(p)) return p + } + throw new Error('找不到 xkey_helper_linux,请检查路径') + } + + public async autoGetDbKey( + timeoutMs = 60_000, + onStatus?: (message: string, level: number) => void + ): Promise { + try { + // 1. 构造一个包含常用系统命令路径的环境变量,防止打包后找不到命令 + const envWithPath = { + ...process.env, + PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin` + }; + + onStatus?.('正在尝试结束当前微信进程...', 0) + console.log('[Debug] 开始执行进程清理逻辑...'); + + try { + const { stdout, stderr } = await execAsync('killall -9 wechat wechat-bin xwechat', { env: envWithPath }); + console.log(`[Debug] killall 成功退出. stdout: ${stdout}, stderr: ${stderr}`); + } catch (err: any) { + // 命令如果没找到进程通常会返回 code 1,这也是正常的,但我们需要记录下来 + console.log(`[Debug] killall 报错或未找到进程: ${err.message}`); + + // Fallback: 尝试使用 pkill 兜底 + try { + console.log('[Debug] 尝试使用备用命令 pkill...'); + await execAsync('pkill -9 -x "wechat|wechat-bin|xwechat"', { env: envWithPath }); + console.log('[Debug] pkill 执行完成'); + } catch (e: any) { + console.log(`[Debug] pkill 报错或未找到进程: ${e.message}`); + } + } + + // 稍微等待进程完全退出 + await new Promise(r => setTimeout(r, 1000)) + + onStatus?.('正在尝试拉起微信...', 0) + + const cleanEnv = { ...process.env }; + delete cleanEnv.ELECTRON_RUN_AS_NODE; + delete cleanEnv.ELECTRON_NO_ATTACH_CONSOLE; + delete cleanEnv.APPDIR; + delete cleanEnv.APPIMAGE; + + const wechatBins = [ + 'wechat', + 'wechat-bin', + 'xwechat', + '/opt/wechat/wechat', + '/usr/bin/wechat', + '/opt/apps/com.tencent.wechat/files/wechat' + ] + + for (const binName of wechatBins) { + try { + const child = spawn(binName, [], { + detached: true, + stdio: 'ignore', + env: cleanEnv + }); + + child.on('error', (err) => { + console.log(`[Debug] 拉起 ${binName} 失败:`, err.message); + }); + + child.unref(); + console.log(`[Debug] 尝试拉起 ${binName} 完毕`); + } catch (e: any) { + console.log(`[Debug] 尝试拉起 ${binName} 发生异常:`, e.message); + } + } + + onStatus?.('等待微信进程出现...', 0) + let pid = 0 + for (let i = 0; i < 15; i++) { // 最多等 15 秒 + await new Promise(r => setTimeout(r, 1000)) + + try { + const { stdout } = await execAsync('pidof wechat wechat-bin xwechat', { env: envWithPath }); + const pids = stdout.trim().split(/\s+/).filter(p => p); + if (pids.length > 0) { + pid = parseInt(pids[0], 10); + console.log(`[Debug] 第 ${i + 1} 秒,通过 pidof 成功获取 PID: ${pid}`); + break; + } + } catch (err: any) { + console.log(`[Debug] 第 ${i + 1} 秒,pidof 失败: ${err.message.split('\n')[0]}`); + + // Fallback: 使用 pgrep 兜底 + try { + const { stdout: pgrepOut } = await execAsync('pgrep -x "wechat|wechat-bin|xwechat"', { env: envWithPath }); + const pids = pgrepOut.trim().split(/\s+/).filter(p => p); + if (pids.length > 0) { + pid = parseInt(pids[0], 10); + console.log(`[Debug] 第 ${i + 1} 秒,通过 pgrep 成功获取 PID: ${pid}`); + break; + } + } catch (e: any) { + console.log(`[Debug] 第 ${i + 1} 秒,pgrep 也失败: ${e.message.split('\n')[0]}`); + } + } + } + + if (!pid) { + const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动并登录。' + onStatus?.(err, 2) + return { success: false, error: err } + } + + onStatus?.(`捕获到微信 PID: ${pid},准备获取密钥...`, 0) + + await new Promise(r => setTimeout(r, 2000)) + + return await this.getDbKey(pid, onStatus) + } catch (err: any) { + console.error('[Debug] 自动获取流程彻底崩溃:', err); + const errMsg = '自动获取微信 PID 失败: ' + err.message + onStatus?.(errMsg, 2) + return { success: false, error: errMsg } + } + } + + public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise { + try { + const helperPath = this.getHelperPath() + + onStatus?.('正在扫描数据库基址...', 0) + const { stdout: scanOut } = await execFileAsync(helperPath, ['db_scan', pid.toString()]) + const scanRes = JSON.parse(scanOut.trim()) + + if (!scanRes.success) { + const err = scanRes.result || '扫描失败,请确保微信已完全登录' + onStatus?.(err, 2) + return { success: false, error: err } + } + + const targetAddr = scanRes.target_addr + onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0) + + return await new Promise((resolve) => { + const options = { name: 'WeFlow' } + const command = `"${helperPath}" db_hook ${pid} ${targetAddr}` + + this.sudo.exec(command, options, (error, stdout) => { + execAsync(`kill -CONT ${pid}`).catch(() => {}) + if (error) { + onStatus?.('授权失败或被取消', 2) + resolve({ success: false, error: `授权失败或被取消: ${error.message}` }) + return + } + try { + const hookRes = JSON.parse((stdout as string).trim()) + if (hookRes.success) { + onStatus?.('密钥获取成功', 1) + resolve({ success: true, key: hookRes.key }) + } else { + onStatus?.(hookRes.result, 2) + resolve({ success: false, error: hookRes.result }) + } + } catch (e) { + onStatus?.('解析 Hook 结果失败', 2) + resolve({ success: false, error: '解析 Hook 结果失败' }) + } + }) + }) + } catch (err: any) { + onStatus?.(err.message, 2) + return { success: false, error: err.message } + } + } + + public async autoGetImageKey( + accountPath?: string, + onProgress?: (msg: string) => void, + wxid?: string + ): Promise { + try { + onProgress?.('正在初始化缓存扫描...'); + const helperPath = this.getHelperPath() + const { stdout } = await execFileAsync(helperPath, ['image_local']) + const res = JSON.parse(stdout.trim()) + if (!res.success) return { success: false, error: res.result } + + const accounts = res.data.accounts || [] + let account = accounts.find((a: any) => a.wxid === wxid) + if (!account && accounts.length > 0) account = accounts[0] + + if (account && account.keys && account.keys.length > 0) { + onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`); + const keyObj = account.keys[0] + return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey } + } + return { success: false, error: '未在缓存中找到匹配的图片密钥' } + } catch (err: any) { + return { success: false, error: err.message } + } + } + + public async autoGetImageKeyByMemoryScan( + accountPath: string, + onProgress?: (msg: string) => void + ): Promise { + try { + onProgress?.('正在查找模板文件...') + let result = await this._findTemplateData(accountPath, 32) + let { ciphertext, xorKey } = result + + if (ciphertext && xorKey === null) { + onProgress?.('未找到有效密钥,尝试扫描更多文件...') + result = await this._findTemplateData(accountPath, 100) + xorKey = result.xorKey + } + + if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' } + if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' } + + onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) + + // 2. 找微信 PID + const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' })) + const pids = stdout.trim().split(/\s+/).filter(p => p) + if (pids.length === 0) return { success: false, error: '微信未运行,无法扫描内存' } + const pid = parseInt(pids[0], 10) + + onProgress?.(`已找到微信进程 PID=${pid},正在提权扫描进程内存...`); + + // 3. 将 Buffer 转换为 hex 传递给 helper + const ciphertextHex = ciphertext.toString('hex') + const helperPath = this.getHelperPath() + + try { + console.log(`[Debug] 准备执行 Helper: ${helperPath} image_mem ${pid} ${ciphertextHex}`); + + const { stdout: memOut, stderr } = await execFileAsync(helperPath, ['image_mem', pid.toString(), ciphertextHex]) + + console.log(`[Debug] Helper stdout: ${memOut}`); + if (stderr) { + console.warn(`[Debug] Helper stderr: ${stderr}`); + } + + if (!memOut || memOut.trim() === '') { + return { success: false, error: 'Helper 返回为空,请检查是否有足够的权限(如需sudo)读取进程内存。' } + } + + const res = JSON.parse(memOut.trim()) + + if (res.success) { + onProgress?.('内存扫描成功'); + return { success: true, xorKey, aesKey: res.key } + } + return { success: false, error: res.result || '未知错误' } + + } catch (err: any) { + console.error('[Debug] 执行或解析 Helper 时发生崩溃:', err); + return { + success: false, + error: `内存扫描失败: ${err.message}\nstdout: ${err.stdout || '无'}\nstderr: ${err.stderr || '无'}` + } + } + } catch (err: any) { + return { success: false, error: `内存扫描失败: ${err.message}` } + } + } + + private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> { + const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) + + // 递归收集 *_t.dat 文件 + const collect = (dir: string, results: string[], maxFiles: number) => { + if (results.length >= maxFiles) return + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (results.length >= maxFiles) break + const full = join(dir, entry.name) + if (entry.isDirectory()) collect(full, results, maxFiles) + else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full) + } + } catch { /* 忽略无权限目录 */ } + } + + const files: string[] = [] + collect(userDir, files, limit) + + // 按修改时间降序 + files.sort((a, b) => { + try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 } + }) + + let ciphertext: Buffer | null = null + const tailCounts: Record = {} + + for (const f of files.slice(0, 32)) { + try { + const data = readFileSync(f) + if (data.length < 8) continue + + // 统计末尾两字节用于 XOR 密钥 + if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) { + const key = `${data[data.length - 2]}_${data[data.length - 1]}` + tailCounts[key] = (tailCounts[key] ?? 0) + 1 + } + + // 提取密文(取第一个有效的) + if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) { + ciphertext = data.subarray(0xF, 0x1F) + } + } catch { /* 忽略 */ } + } + + // 计算 XOR 密钥 + let xorKey: number | null = null + let maxCount = 0 + for (const [key, count] of Object.entries(tailCounts)) { + if (count > maxCount) { + maxCount = count + const [x, y] = key.split('_').map(Number) + const k = x ^ 0xFF + if (k === (y ^ 0xD9)) xorKey = k + } + } + + return { ciphertext, xorKey } + } +} \ No newline at end of file diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts new file mode 100644 index 0000000..79f9cdc --- /dev/null +++ b/electron/services/keyServiceMac.ts @@ -0,0 +1,1194 @@ +import { app, shell } from 'electron' +import { join, basename, dirname } from 'path' +import { existsSync, readdirSync, readFileSync, statSync } from 'fs' +import { execFile, spawn } from 'child_process' +import { promisify } from 'util' +import crypto from 'crypto' +import { homedir } from 'os' + +type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } +type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } +const execFileAsync = promisify(execFile) + +export class KeyServiceMac { + private koffi: any = null + private lib: any = null + private initialized = false + + private GetDbKey: any = null + private ListWeChatProcesses: any = null + private libSystem: any = null + private machTaskSelf: any = null + private taskForPid: any = null + private machVmRegion: any = null + private machVmReadOverwrite: any = null + private machPortDeallocate: any = null + private _needsElevation = false + + private getHelperPath(): string { + const isPackaged = app.isPackaged + const candidates: string[] = [] + + if (process.env.WX_KEY_HELPER_PATH) { + candidates.push(process.env.WX_KEY_HELPER_PATH) + } + + if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper')) + candidates.push(join(process.resourcesPath, 'xkey_helper')) + } else { + const cwd = process.cwd() + candidates.push(join(cwd, 'resources', 'xkey_helper')) + candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper')) + candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper')) + } + + for (const path of candidates) { + if (existsSync(path)) return path + } + + throw new Error('xkey_helper not found') + } + + private getImageScanHelperPath(): string { + const isPackaged = app.isPackaged + const candidates: string[] = [] + + if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper')) + candidates.push(join(process.resourcesPath, 'image_scan_helper')) + } else { + const cwd = process.cwd() + candidates.push(join(cwd, 'resources', 'image_scan_helper')) + candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper')) + } + + for (const path of candidates) { + if (existsSync(path)) return path + } + + throw new Error('image_scan_helper not found') + } + + private getDylibPath(): string { + const isPackaged = app.isPackaged + const candidates: string[] = [] + + if (process.env.WX_KEY_DYLIB_PATH) { + candidates.push(process.env.WX_KEY_DYLIB_PATH) + } + + if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib')) + candidates.push(join(process.resourcesPath, 'libwx_key.dylib')) + } else { + const cwd = process.cwd() + candidates.push(join(cwd, 'resources', 'libwx_key.dylib')) + candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib')) + } + + for (const path of candidates) { + if (existsSync(path)) return path + } + + throw new Error('libwx_key.dylib not found') + } + + async initialize(): Promise { + if (this.initialized) return + + try { + this.koffi = require('koffi') + const dylibPath = this.getDylibPath() + + if (!existsSync(dylibPath)) { + throw new Error('libwx_key.dylib not found: ' + dylibPath) + } + + this.lib = this.koffi.load(dylibPath) + + this.GetDbKey = this.lib.func('const char* GetDbKey()') + this.ListWeChatProcesses = this.lib.func('const char* ListWeChatProcesses()') + + this.initialized = true + } catch (e: any) { + throw new Error('Failed to initialize KeyServiceMac: ' + e.message) + } + } + + private async checkSipStatus(): Promise<{ enabled: boolean; error?: string }> { + try { + const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status']) + const enabled = stdout.toLowerCase().includes('enabled') + return { enabled } + } catch (e: any) { + return { enabled: false, error: e.message } + } + } + + async autoGetDbKey( + timeoutMs = 60_000, + onStatus?: (message: string, level: number) => void + ): Promise { + try { + // 检测 SIP 状态 + const sipStatus = await this.checkSipStatus() + if (sipStatus.enabled) { + return { + success: false, + error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. Intel 芯片:重启 Mac 并按住 Command + R 进入恢复模式\n2. Apple 芯片(M 系列):关机后长按开机(指纹)键,选择“设置(选项)”进入恢复模式\n3. 打开终端,输入: csrutil disable\n4. 重启电脑' + } + } + + onStatus?.('正在获取数据库密钥...', 0) + onStatus?.('正在请求管理员授权并执行 helper...', 0) + let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string } + try { + const elevatedResult = await this.getDbKeyByHelperElevated(timeoutMs, onStatus) + parsed = this.parseDbKeyResult(elevatedResult) + console.log('[KeyServiceMac] GetDbKey elevated returned:', parsed.raw) + } catch (e: any) { + const msg = `${e?.message || e}` + if (msg.includes('(-128)') || msg.includes('User canceled')) { + return { success: false, error: '已取消管理员授权' } + } + throw e + } + + if (!parsed.success) { + const errorMsg = this.mapDbKeyErrorMessage(parsed.code, parsed.detail) + onStatus?.(errorMsg, 2) + return { success: false, error: errorMsg } + } + + onStatus?.('密钥获取成功', 1) + return { success: true, key: parsed.key } + } catch (e: any) { + console.error('[KeyServiceMac] Error:', e) + console.error('[KeyServiceMac] Stack:', e.stack) + onStatus?.('获取失败: ' + e.message, 2) + return { success: false, error: e.message } + } + } + + private parseDbKeyResult(raw: any): { success: boolean; key?: string; code?: string; detail?: string; raw: string } { + const text = typeof raw === 'string' ? raw : '' + if (!text) return { success: false, code: 'UNKNOWN', raw: text } + if (!text.startsWith('ERROR:')) return { success: true, key: text, raw: text } + + const parts = text.split(':') + return { + success: false, + code: parts[1] || 'UNKNOWN', + detail: parts.slice(2).join(':') || undefined, + raw: text + } + } + + private async getDbKeyParsed( + timeoutMs: number, + onStatus?: (message: string, level: number) => void + ): Promise<{ success: boolean; key?: string; code?: string; detail?: string; raw: string }> { + const helperResult = await this.getDbKeyByHelper(timeoutMs, onStatus) + return this.parseDbKeyResult(helperResult) + } + + private async getWeChatPid(): Promise { + try { + // 优先使用 pgrep -x 精确匹配进程名 + try { + const { stdout } = await execFileAsync('/usr/bin/pgrep', ['-x', 'WeChat']) + const ids = stdout.split(/\r?\n/).map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 0) + if (ids.length > 0) return Math.max(...ids) + } catch { + // ignore and fallback + } + + // pgrep -f 匹配完整命令行路径(打包后 pgrep -x 可能失败时的备选) + try { + const { stdout } = await execFileAsync('/usr/bin/pgrep', ['-f', 'WeChat.app/Contents/MacOS/WeChat']) + const ids = stdout.split(/\r?\n/).map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 0) + if (ids.length > 0) return Math.max(...ids) + } catch { + // ignore and fallback to ps + } + + const { stdout } = await execFileAsync('/bin/ps', ['-A', '-o', 'pid,comm,command']) + const lines = stdout.split('\n').slice(1) + + const candidates: Array<{ pid: number; comm: string; command: string }> = [] + for (const line of lines) { + const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/) + if (!match) continue + + const pid = parseInt(match[1], 10) + const comm = match[2] + const command = match[3] + + // 打包后 command 列可能被截断或为空,同时检查 comm 列 + const pathMatch = command.includes('/Applications/WeChat.app/Contents/MacOS/WeChat') || + command.includes('/Contents/MacOS/WeChat') || + comm === 'WeChat' + if (pathMatch) candidates.push({ pid, comm, command }) + } + + if (candidates.length === 0) throw new Error('WeChat process not found') + + const filtered = candidates.filter(p => { + const cmd = p.command + return !cmd.includes('WeChatAppEx.app/') && + !cmd.includes('/WeChatAppEx') && + !cmd.includes(' WeChatAppEx') && + !cmd.includes('crashpad_handler') && + !cmd.includes('Helper') && + p.comm !== 'WeChat Helper' + }) + if (filtered.length === 0) throw new Error('No valid WeChat main process found') + + const preferredMain = filtered.filter(p => + p.command.includes('/Contents/MacOS/WeChat') || p.comm === 'WeChat' + ) + const selectedPool = preferredMain.length > 0 ? preferredMain : filtered + const selected = selectedPool.reduce((max, p) => p.pid > max.pid ? p : max) + return selected.pid + } catch (e: any) { + throw new Error('Failed to get WeChat PID: ' + e.message) + } + } + + private async getDbKeyByHelper( + timeoutMs: number, + onStatus?: (message: string, level: number) => void + ): Promise { + const helperPath = this.getHelperPath() + const waitMs = Math.max(timeoutMs, 30_000) + const timeoutSec = Math.ceil(waitMs / 1000) + 30 + const pid = await this.getWeChatPid() + onStatus?.(`已找到微信进程 PID=${pid},正在定位目标函数...`, 0) + // 最佳努力清理同路径残留 helper(普通权限) + try { await execFileAsync('/usr/bin/pkill', ['-f', helperPath], { timeout: 2000 }) } catch { } + + return await new Promise((resolve, reject) => { + // xkey_helper 参数协议:helper [timeout_ms] + const child = spawn(helperPath, [String(pid), String(waitMs)], { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + let stdoutBuf = '' + let stderrBuf = '' + let settled = false + let killTimer: ReturnType | null = null + let pidNotified = false + let locatedNotified = false + let hookNotified = false + + const done = (fn: () => void) => { + if (settled) return + settled = true + if (killTimer) clearTimeout(killTimer) + fn() + } + + const processHelperLine = (line: string) => { + if (!line) return + console.log('[KeyServiceMac][helper][stderr]', line) + const pidMatch = line.match(/Selected PID=(\d+)/) + if (pidMatch && !pidNotified) { + pidNotified = true + onStatus?.(`已找到微信进程 PID=${pidMatch[1]},正在定位目标函数...`, 0) + } + if (!locatedNotified && (line.includes('strict hit=') || line.includes('sink matched by strict semantic signature'))) { + locatedNotified = true + onStatus?.('已定位到目标函数,正在安装 Hook...', 0) + } + if (line.includes('hook installed @')) { + hookNotified = true + onStatus?.('Hook 已安装,等待微信触发密钥调用...', 0) + } + if (line.includes('[MASTER] hex64=')) { + onStatus?.('检测到密钥回调,正在回填...', 0) + } + } + + child.stdout.on('data', (chunk: Buffer | string) => { + const data = chunk.toString() + stdout += data + stdoutBuf += data + const parts = stdoutBuf.split(/\r?\n/) + stdoutBuf = parts.pop()! + }) + + child.stderr.on('data', (chunk: Buffer | string) => { + const data = chunk.toString() + stderr += data + stderrBuf += data + const parts = stderrBuf.split(/\r?\n/) + stderrBuf = parts.pop()! + for (const line of parts) processHelperLine(line.trim()) + }) + + child.on('error', (err) => { + done(() => reject(err)) + }) + + child.on('close', () => { + if (stderrBuf.trim()) processHelperLine(stderrBuf.trim()) + + const lines = stdout.split(/\r?\n/).map(x => x.trim()).filter(Boolean) + const last = lines[lines.length - 1] + if (!last) { + done(() => reject(new Error(stderr.trim() || 'helper returned empty output'))) + return + } + + let payload: any + try { + payload = JSON.parse(last) + } catch { + done(() => reject(new Error('helper returned invalid json: ' + last))) + return + } + + if (payload?.success === true && typeof payload?.key === 'string') { + if (!hookNotified) { + onStatus?.('Hook 已触发,正在回填密钥...', 0) + } + done(() => resolve(payload.key)) + return + } + if (typeof payload?.result === 'string') { + done(() => resolve(payload.result)) + return + } + done(() => reject(new Error('helper json missing key/result'))) + }) + + killTimer = setTimeout(() => { + try { child.kill('SIGTERM') } catch { } + done(() => reject(new Error(`helper timeout after ${waitMs}ms`))) + }, waitMs + 10_000) + }) + } + + private shellSingleQuote(text: string): string { + return `'${String(text).replace(/'/g, `'\\''`)}'` + } + + private async getDbKeyByHelperElevated( + timeoutMs: number, + onStatus?: (message: string, level: number) => void + ): Promise { + const helperPath = this.getHelperPath() + const waitMs = Math.max(timeoutMs, 30_000) + const timeoutSec = Math.ceil(waitMs / 1000) + 30 + const pid = await this.getWeChatPid() + // 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败 + // 通过 try/on error 回传详细错误,避免只看到 "Command failed" + const scriptLines = [ + `set helperPath to ${JSON.stringify(helperPath)}`, + `set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`, + `set timeoutSec to ${timeoutSec}`, + 'try', + 'with timeout of timeoutSec seconds', + 'set outText to do shell script cmd with administrator privileges', + 'end timeout', + 'return "WF_OK::" & outText', + 'on error errMsg number errNum partial result pr', + 'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)', + 'end try' + ] + onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0) + + let stdout = '' + try { + const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), { + timeout: waitMs + 20_000 + }) + stdout = result.stdout + } catch (e: any) { + const msg = `${e?.stderr || ''}\n${e?.stdout || ''}\n${e?.message || ''}`.trim() + throw new Error(msg || 'elevated helper execution failed') + } + + const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean) + if (!lines.length) throw new Error('elevated helper returned empty output') + const joined = lines.join('\n') + + if (joined.startsWith('WF_ERR::')) { + const parts = joined.split('::') + const errNum = parts[1] || 'unknown' + const errMsg = parts[2] || 'unknown' + const partial = parts.slice(3).join('::') + throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${errMsg}, partial=${partial || '(empty)'}`) + } + const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined + + // 从所有行里提取所有 JSON 对象(同一行可能有多个拼接),找含 key/result 的那个 + const extractJsonObjects = (s: string): any[] => { + const results: any[] = [] + const re = /\{[^{}]*\}/g + let m: RegExpExecArray | null + while ((m = re.exec(s)) !== null) { + try { results.push(JSON.parse(m[0])) } catch { } + } + return results + } + const fullOutput = normalizedOutput + const allJson = extractJsonObjects(fullOutput) + // 优先找 success=true && key 字段 + const successPayload = allJson.find(p => p?.success === true && typeof p?.key === 'string') + if (successPayload) return successPayload.key + // 其次找 result 字段 + const resultPayload = allJson.find(p => typeof p?.result === 'string') + if (resultPayload) return resultPayload.result + throw new Error('elevated helper returned invalid json: ' + lines[lines.length - 1]) + } + + private mapDbKeyErrorMessage(code?: string, detail?: string): string { + if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行' + if (code === 'ATTACH_FAILED') { + const isDevElectron = process.execPath.includes('/node_modules/electron/') + if ((detail || '').includes('task_for_pid:5')) { + if (isDevElectron) { + return `无法附加到微信进程(task_for_pid 被拒绝)。当前为开发环境 Electron:${process.execPath}\n建议使用打包后的 WeFlow.app(已携带调试 entitlements)再重试。` + } + return '无法附加到微信进程(task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements。' + } + return `无法附加到进程 (${detail || ''})` + } + if (code === 'FRIDA_FAILED') { + if ((detail || '').includes('FRIDA_TIMEOUT')) { + return '定位已成功但在等待时间内未捕获到密钥调用。请保持微信前台并进行一次会话/数据库访问后重试。' + } + return `Frida 语义定位失败 (${detail || ''})` + } + if (code === 'HOOK_FAILED') { + if ((detail || '').includes('HOOK_TIMEOUT')) { + return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。' + } + if ((detail || '').includes('attach_wait_timeout')) { + return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。' + } + return `原生 Hook 失败 (${detail || ''})` + } + if (code === 'HOOK_TARGET_ONLY') { + return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。` + } + if (code === 'SCAN_FAILED') return '内存扫描失败' + return '未知错误' + } + + private async enableDebugPermissionWithPrompt(): Promise { + const script = [ + 'do shell script "/usr/sbin/DevToolsSecurity -enable" with administrator privileges' + ] + + try { + await execFileAsync('/usr/bin/osascript', script.flatMap(line => ['-e', line]), { + timeout: 30_000 + }) + return true + } catch (e: any) { + const msg = `${e?.stderr || ''}\n${e?.message || ''}` + const cancelled = msg.includes('User canceled') || msg.includes('(-128)') + if (!cancelled) { + console.error('[KeyServiceMac] enableDebugPermissionWithPrompt failed:', msg) + } + return false + } + } + + private async openDeveloperToolsPrivacySettings(): Promise { + const url = 'x-apple.systempreferences:com.apple.preference.security?Privacy_DevTools' + try { + await shell.openExternal(url) + } catch (e) { + console.error('[KeyServiceMac] Failed to open settings page:', e) + } + } + + private async revealCurrentExecutableInFinder(): Promise { + try { + shell.showItemInFolder(process.execPath) + } catch (e) { + console.error('[KeyServiceMac] Failed to reveal executable in Finder:', e) + } + } + + async autoGetImageKey( + accountPath?: string, + onStatus?: (message: string) => void, + wxid?: string + ): Promise { + try { + onStatus?.('正在从缓存目录扫描图片密钥...') + const codes = this.collectKvcommCodes(accountPath) + if (codes.length === 0) { + return { success: false, error: '未找到有效的密钥码(kvcomm 缓存为空)' } + } + + const wxidCandidates = this.collectWxidCandidates(accountPath, wxid) + if (wxidCandidates.length === 0) { + return { success: false, error: '未找到可用的账号候选,请先选择正确的账号目录' } + } + + const accountPathCandidates = this.collectAccountPathCandidates(accountPath) + + // 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错 + if (accountPathCandidates.length > 0) { + onStatus?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`) + for (const candidateAccountPath of accountPathCandidates) { + if (!existsSync(candidateAccountPath)) continue + const template = await this._findTemplateData(candidateAccountPath, 32) + if (!template.ciphertext) continue + + const accountDirWxid = basename(candidateAccountPath) + const orderedWxids: string[] = [] + this.pushAccountIdCandidates(orderedWxids, accountDirWxid) + for (const candidate of wxidCandidates) { + this.pushAccountIdCandidates(orderedWxids, candidate) + } + + for (const candidateWxid of orderedWxids) { + for (const code of codes) { + const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) + if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue + onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) + return { success: true, xorKey, aesKey } + } + } + } + return { + success: false, + error: '缓存 code 与当前账号 wxid 未匹配。若数据库密钥获取后微信刚刚崩溃并重启,可能当前选中的账号目录已经不是最新会话;请先重新扫描 wxid,或直接使用内存扫描。' + } + } + + // 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code) + const fallbackWxid = wxidCandidates[0] + const fallbackCode = codes[0] + const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid) + onStatus?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`) + return { success: true, xorKey, aesKey } + } catch (e: any) { + return { success: false, error: `自动获取图片密钥失败: ${e.message}` } + } + } + + async autoGetImageKeyByMemoryScan( + userDir: string, + onProgress?: (message: string) => void + ): Promise { + try { + // 1. 查找模板文件获取密文和 XOR 密钥 + onProgress?.('正在查找模板文件...') + let result = await this._findTemplateData(userDir, 32) + let { ciphertext, xorKey } = result + + if (ciphertext && xorKey === null) { + onProgress?.('未找到有效密钥,尝试扫描更多文件...') + result = await this._findTemplateData(userDir, 100) + xorKey = result.xorKey + } + + if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' } + if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' } + + onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`) + + // 2. 持续轮询微信 PID 与内存扫描,兼容微信崩溃后重启 PID 变化 + const deadline = Date.now() + 60_000 + let scanCount = 0 + let lastPid: number | null = null + while (Date.now() < deadline) { + const pid = await this.findWeChatPid() + if (!pid) { + onProgress?.('暂未检测到微信主进程,请确认微信已经重新打开...') + await new Promise(r => setTimeout(r, 2000)) + continue + } + if (lastPid !== pid) { + lastPid = pid + onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`) + } + scanCount++ + onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`) + const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress) + if (aesKey) { + onProgress?.('密钥获取成功') + return { success: true, xorKey, aesKey } + } + await new Promise(r => setTimeout(r, 5000)) + } + + return { success: false, error: '60 秒内未找到 AES 密钥' } + } catch (e: any) { + return { success: false, error: `内存扫描失败: ${e.message}` } + } + } + + private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> { + const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]) + + const collect = (dir: string, results: string[], maxFiles: number) => { + if (results.length >= maxFiles) return + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (results.length >= maxFiles) break + const full = join(dir, entry.name) + if (entry.isDirectory()) collect(full, results, maxFiles) + else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full) + } + } catch { } + } + + const files: string[] = [] + collect(userDir, files, limit) + + files.sort((a, b) => { + try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 } + }) + + let ciphertext: Buffer | null = null + const tailCounts: Record = {} + + for (const f of files.slice(0, 32)) { + try { + const data = readFileSync(f) + if (data.length < 8) continue + + if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) { + const key = `${data[data.length - 2]}_${data[data.length - 1]}` + tailCounts[key] = (tailCounts[key] ?? 0) + 1 + } + + if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) { + ciphertext = data.subarray(0xF, 0x1F) + } + } catch { } + } + + let xorKey: number | null = null + let maxCount = 0 + for (const [key, count] of Object.entries(tailCounts)) { + if (count > maxCount) { + maxCount = count + const [x, y] = key.split('_').map(Number) + const k = x ^ 0xFF + if (k === (y ^ 0xD9)) xorKey = k + } + } + + return { ciphertext, xorKey } + } + + private ensureMachApis(): boolean { + if (this.machTaskSelf && this.taskForPid && this.machVmRegion && this.machVmReadOverwrite) return true + try { + if (!this.koffi) this.koffi = require('koffi') + this.libSystem = this.koffi.load('/usr/lib/libSystem.B.dylib') + this.machTaskSelf = this.libSystem.func('mach_task_self', 'uint32', []) + this.taskForPid = this.libSystem.func('task_for_pid', 'int', ['uint32', 'int', this.koffi.out('uint32*')]) + this.machVmRegion = this.libSystem.func('mach_vm_region', 'int', [ + 'uint32', + this.koffi.out('uint64*'), + this.koffi.out('uint64*'), + 'int', + 'void*', + this.koffi.out('uint32*'), + this.koffi.out('uint32*') + ]) + this.machVmReadOverwrite = this.libSystem.func('mach_vm_read_overwrite', 'int', [ + 'uint32', + 'uint64', + 'uint64', + 'void*', + this.koffi.out('uint64*') + ]) + this.machPortDeallocate = this.libSystem.func('mach_port_deallocate', 'int', ['uint32', 'uint32']) + return true + } catch (e) { + console.error('[KeyServiceMac] 初始化 Mach API 失败:', e) + return false + } + } + + private async _scanMemoryForAesKey( + pid: number, + ciphertext: Buffer, + onProgress?: (message: string) => void + ): Promise { + // 优先通过 image_scan_helper 子进程调用 + try { + const helperPath = this.getImageScanHelperPath() + const ciphertextHex = ciphertext.toString('hex') + + // 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用) + if (!this._needsElevation) { + const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false) + if (direct.key) return direct.key + if (direct.permissionError) { + console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式') + this._needsElevation = true + onProgress?.('需要管理员权限,请在弹出的对话框中输入密码...') + } + } + + // 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid) + if (this._needsElevation) { + const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true) + if (elevated.key) return elevated.key + } + } catch (e: any) { + console.warn('[KeyServiceMac] image_scan_helper unavailable, fallback to Mach API:', e?.message) + } + + // fallback: 直接通过 Mach API 扫描内存(Electron 进程可能没有 task_for_pid 权限) + if (!this.ensureMachApis()) return null + + const VM_PROT_READ = 0x1 + const VM_PROT_WRITE = 0x2 + const VM_REGION_BASIC_INFO_64 = 9 + const VM_REGION_BASIC_INFO_COUNT_64 = 9 + const KERN_SUCCESS = 0 + const MAX_REGION_SIZE = 50 * 1024 * 1024 + const CHUNK = 4 * 1024 * 1024 + const OVERLAP = 65 + + const selfTask = this.machTaskSelf() + const taskBuf = Buffer.alloc(4) + const attachKr = this.taskForPid(selfTask, pid, taskBuf) + const task = taskBuf.readUInt32LE(0) + if (attachKr !== KERN_SUCCESS || !task) return null + + try { + const regions: Array<[number, number]> = [] + let address = 0 + + while (address < 0x7FFFFFFFFFFF) { + const addrBuf = Buffer.alloc(8) + addrBuf.writeBigUInt64LE(BigInt(address), 0) + const sizeBuf = Buffer.alloc(8) + const infoBuf = Buffer.alloc(64) + const countBuf = Buffer.alloc(4) + countBuf.writeUInt32LE(VM_REGION_BASIC_INFO_COUNT_64, 0) + const objectBuf = Buffer.alloc(4) + + const kr = this.machVmRegion(task, addrBuf, sizeBuf, VM_REGION_BASIC_INFO_64, infoBuf, countBuf, objectBuf) + if (kr !== KERN_SUCCESS) break + + const base = Number(addrBuf.readBigUInt64LE(0)) + const size = Number(sizeBuf.readBigUInt64LE(0)) + const protection = infoBuf.readInt32LE(0) + const objectName = objectBuf.readUInt32LE(0) + if (objectName) { + try { this.machPortDeallocate(selfTask, objectName) } catch { } + } + + if ((protection & VM_PROT_READ) !== 0 && + (protection & VM_PROT_WRITE) !== 0 && + size > 0 && + size <= MAX_REGION_SIZE) { + regions.push([base, size]) + } + + const next = base + size + if (next <= address) break + address = next + } + + const totalMB = regions.reduce((sum, [, size]) => sum + size, 0) / 1024 / 1024 + onProgress?.(`扫描 ${regions.length} 个 RW 区域 (${totalMB.toFixed(0)} MB)...`) + + for (let ri = 0; ri < regions.length; ri++) { + const [base, size] = regions[ri] + if (ri % 20 === 0) { + onProgress?.(`扫描进度 ${ri}/${regions.length}...`) + await new Promise(r => setTimeout(r, 1)) + } + let offset = 0 + let trailing: Buffer | null = null + + while (offset < size) { + const chunkSize = Math.min(CHUNK, size - offset) + const chunk = Buffer.alloc(chunkSize) + const outSizeBuf = Buffer.alloc(8) + const kr = this.machVmReadOverwrite(task, base + offset, chunkSize, chunk, outSizeBuf) + const bytesRead = Number(outSizeBuf.readBigUInt64LE(0)) + offset += chunkSize + + if (kr !== KERN_SUCCESS || bytesRead <= 0) { + trailing = null + continue + } + + const current = chunk.subarray(0, bytesRead) + const data: Buffer = trailing ? Buffer.concat([trailing, current]) : current + const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext) + if (key) return key + // 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中) + const fallbackKey = this._searchAny16Key(data, ciphertext) + if (fallbackKey) return fallbackKey + trailing = data.subarray(Math.max(0, data.length - OVERLAP)) + } + } + return null + } finally { + try { this.machPortDeallocate(selfTask, task) } catch { } + } + } + + private _spawnScanHelper( + helperPath: string, pid: number, ciphertextHex: string, elevated: boolean + ): Promise<{ key: string | null; permissionError: boolean }> { + return new Promise((resolve, reject) => { + let child: ReturnType + if (elevated) { + const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}` + child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`], + { stdio: ['ignore', 'pipe', 'pipe'] }) + } else { + child = spawn(helperPath, [String(pid), ciphertextHex], { stdio: ['ignore', 'pipe', 'pipe'] }) + } + const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]' + let stdout = '', stderr = '' + child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() }) + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + console.log(tag, chunk.toString().trim()) + }) + child.on('error', reject) + child.on('close', () => { + const permissionError = !elevated && stderr.includes('task_for_pid failed') + try { + const lines = stdout.split(/\r?\n/).map(x => x.trim()).filter(Boolean) + const last = lines[lines.length - 1] + if (!last) { resolve({ key: null, permissionError }); return } + const payload = JSON.parse(last) + resolve({ + key: payload?.success && payload?.aesKey ? payload.aesKey : null, + permissionError + }) + } catch { + resolve({ key: null, permissionError }) + } + }) + setTimeout(() => { try { child.kill('SIGTERM') } catch {} }, elevated ? 60_000 : 30_000) + }) + } + + private async findWeChatPid(): Promise { + try { + return await this.getWeChatPid() + } catch { + return null + } + } + + cleanup(): void { + this.lib = null + this.initialized = false + this.libSystem = null + this.machTaskSelf = null + this.taskForPid = null + this.machVmRegion = null + this.machVmReadOverwrite = null + this.machPortDeallocate = null + } + + private normalizeAccountId(value: string): string { + const trimmed = String(value || '').trim() + if (!trimmed) return '' + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + return match?.[1] || trimmed + } + + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed + } + + private isIgnoredAccountName(value: string): boolean { + const lowered = String(value || '').trim().toLowerCase() + if (!lowered) return true + return lowered === 'xwechat_files' || + lowered === 'all_users' || + lowered === 'backup' || + lowered === 'wmpf' || + lowered === 'app_data' + } + + private isReasonableAccountId(value: string): boolean { + const trimmed = String(value || '').trim() + if (!trimmed) return false + if (trimmed.includes('/') || trimmed.includes('\\')) return false + return !this.isIgnoredAccountName(trimmed) + } + + private isAccountDirPath(entryPath: string): boolean { + return existsSync(join(entryPath, 'db_storage')) || + existsSync(join(entryPath, 'msg')) || + existsSync(join(entryPath, 'FileStorage', 'Image')) || + existsSync(join(entryPath, 'FileStorage', 'Image2')) + } + + private resolveXwechatRootFromPath(accountPath?: string): string | null { + const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '') + if (!normalized) return null + const marker = '/xwechat_files' + const markerIdx = normalized.indexOf(marker) + if (markerIdx < 0) return null + return normalized.slice(0, markerIdx + marker.length) + } + + private pushAccountIdCandidates(candidates: string[], value?: string): void { + const pushUnique = (item: string) => { + const trimmed = String(item || '').trim() + if (!trimmed || candidates.includes(trimmed)) return + candidates.push(trimmed) + } + + const raw = String(value || '').trim() + if (!this.isReasonableAccountId(raw)) return + pushUnique(raw) + const normalized = this.normalizeAccountId(raw) + if (normalized && normalized !== raw && this.isReasonableAccountId(normalized)) { + pushUnique(normalized) + } + } + + private cleanWxid(wxid: string): string { + return this.normalizeAccountId(wxid) + } + + private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } { + const cleanedWxid = this.cleanWxid(wxid) + const xorKey = code & 0xFF + const dataToHash = code.toString() + cleanedWxid + const aesKey = crypto.createHash('md5').update(dataToHash).digest('hex').substring(0, 16) + return { xorKey, aesKey } + } + + private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] { + const candidates: string[] = [] + + // 1) 显式传参优先 + this.pushAccountIdCandidates(candidates, wxidParam) + + if (accountPath) { + const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '') + const dirName = basename(normalized) + // 2) 当前目录名本身就是账号目录 + this.pushAccountIdCandidates(candidates, dirName) + + // 3) 从 xwechat_files 根目录枚举全部账号目录 + const root = this.resolveXwechatRootFromPath(accountPath) + if (root) { + if (existsSync(root)) { + try { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const entryPath = join(root, entry.name) + if (!this.isAccountDirPath(entryPath)) continue + this.pushAccountIdCandidates(candidates, entry.name) + } + } catch { + // ignore + } + } + } + } + + if (candidates.length === 0) candidates.push('unknown') + return candidates + } + + private collectAccountPathCandidates(accountPath?: string): string[] { + const candidates: string[] = [] + const pushUnique = (value?: string) => { + const v = String(value || '').trim() + if (!v || candidates.includes(v)) return + candidates.push(v) + } + + if (accountPath) pushUnique(accountPath) + + if (accountPath) { + const root = this.resolveXwechatRootFromPath(accountPath) + if (root) { + if (existsSync(root)) { + try { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const entryPath = join(root, entry.name) + if (!this.isAccountDirPath(entryPath)) continue + if (!this.isReasonableAccountId(entry.name)) continue + pushUnique(entryPath) + } + } catch { + // ignore + } + } + } + } + + return candidates + } + + private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean { + try { + if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false + const keyBytes = Buffer.from(aesKey, 'ascii').subarray(0, 16) + const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes, null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true + if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true + if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true + if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true + if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true + return false + } catch { + return false + } + } + + private collectKvcommCodes(accountPath?: string): number[] { + const codeSet = new Set() + const pattern = /^key_(\d+)_.+\.statistic$/i + + for (const kvcommDir of this.getKvcommCandidates(accountPath)) { + if (!existsSync(kvcommDir)) continue + try { + const files = readdirSync(kvcommDir) + for (const file of files) { + const match = file.match(pattern) + if (!match) continue + const code = Number(match[1]) + if (!Number.isFinite(code) || code <= 0 || code > 0xFFFFFFFF) continue + codeSet.add(code) + } + } catch { + // 忽略不可读目录,继续尝试其他候选路径 + } + } + + return Array.from(codeSet) + } + + private getKvcommCandidates(accountPath?: string): string[] { + const home = homedir() + const candidates = new Set([ + // 与用户实测路径一致:Documents/xwechat_files -> Documents/app_data/net/kvcomm + join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'app_data', 'net', 'kvcomm'), + join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat', 'xwechat', 'net', 'kvcomm'), + join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat', 'net', 'kvcomm'), + join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat', 'net', 'kvcomm') + ]) + + if (accountPath) { + // 规则:把路径中的 xwechat_files 替换为 app_data,然后拼 net/kvcomm + const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '') + const marker = '/xwechat_files' + const idx = normalized.indexOf(marker) + if (idx >= 0) { + const base = normalized.slice(0, idx) + candidates.add(`${base}/app_data/net/kvcomm`) + } + + let cursor = accountPath + for (let i = 0; i < 6; i++) { + candidates.add(join(cursor, 'net', 'kvcomm')) + const next = dirname(cursor) + if (next === cursor) break + cursor = next + } + } + + return Array.from(candidates) + } + + private _searchAsciiKey(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i < data.length - 34; i++) { + if (this._isAlphaNum(data[i])) continue + let valid = true + for (let j = 1; j <= 32; j++) { + if (!this._isAlphaNum(data[i + j])) { valid = false; break } + } + if (!valid) continue + if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue + const keyBytes = data.subarray(i + 1, i + 33) + if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) + } + return null + } + + private _searchUtf16Key(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i < data.length - 65; i++) { + let valid = true + for (let j = 0; j < 32; j++) { + if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break } + } + if (!valid) continue + const keyBytes = Buffer.alloc(32) + for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2] + if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16) + } + return null + } + + private _isAlphaNum(b: number): boolean { + return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39) + } + + private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean { + try { + const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes.subarray(0, 16), null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true + if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true + if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true + if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true + if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true + return false + } catch { + return false + } + } + + // 兜底策略:遍历任意 16-byte 候选,提升 macOS 内存布局差异下的命中率 + private _searchAny16Key(data: Buffer, ciphertext: Buffer): string | null { + for (let i = 0; i + 16 <= data.length; i++) { + const keyBytes = data.subarray(i, i + 16) + if (!this._verifyAesKey16Raw(keyBytes, ciphertext)) continue + if (!this._isMostlyPrintableAscii(keyBytes)) continue + return keyBytes.toString('ascii') + } + return null + } + + private _verifyAesKey16Raw(keyBytes16: Buffer, ciphertext: Buffer): boolean { + try { + const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes16, null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true + if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true + if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true + if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true + if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true + return false + } catch { + return false + } + } + + private _isMostlyPrintableAscii(keyBytes16: Buffer): boolean { + let printable = 0 + for (const b of keyBytes16) { + if (b >= 0x20 && b <= 0x7E) printable++ + } + return printable >= 14 + } +} diff --git a/electron/services/messagePushService.ts b/electron/services/messagePushService.ts new file mode 100644 index 0000000..07b219b --- /dev/null +++ b/electron/services/messagePushService.ts @@ -0,0 +1,371 @@ +import { ConfigService } from './config' +import { chatService, type ChatSession, type Message } from './chatService' +import { wcdbService } from './wcdbService' +import { httpService } from './httpService' + +interface SessionBaseline { + lastTimestamp: number + unreadCount: number +} + +interface MessagePushPayload { + event: 'message.new' + sessionId: string + messageKey: string + avatarUrl?: string + sourceName: string + groupName?: string + content: string | null +} + +const PUSH_CONFIG_KEYS = new Set([ + 'messagePushEnabled', + 'dbPath', + 'decryptKey', + 'myWxid' +]) + +class MessagePushService { + private readonly configService: ConfigService + private readonly sessionBaseline = new Map() + private readonly recentMessageKeys = new Map() + private readonly groupNicknameCache = new Map; updatedAt: number }>() + private readonly debounceMs = 350 + private readonly recentMessageTtlMs = 10 * 60 * 1000 + private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000 + private debounceTimer: ReturnType | null = null + private processing = false + private rerunRequested = false + private started = false + private baselineReady = false + + constructor() { + this.configService = ConfigService.getInstance() + } + + start(): void { + if (this.started) return + this.started = true + void this.refreshConfiguration('startup') + } + + handleDbMonitorChange(type: string, json: string): void { + if (!this.started) return + if (!this.isPushEnabled()) return + + let payload: Record | null = null + try { + payload = JSON.parse(json) + } catch { + payload = null + } + + const tableName = String(payload?.table || '').trim().toLowerCase() + if (tableName && tableName !== 'session') { + return + } + + this.scheduleSync() + } + + async handleConfigChanged(key: string): Promise { + if (!PUSH_CONFIG_KEYS.has(String(key || '').trim())) return + if (key === 'dbPath' || key === 'decryptKey' || key === 'myWxid') { + this.resetRuntimeState() + chatService.close() + } + await this.refreshConfiguration(`config:${key}`) + } + + handleConfigCleared(): void { + this.resetRuntimeState() + chatService.close() + } + + private isPushEnabled(): boolean { + return this.configService.get('messagePushEnabled') === true + } + + private resetRuntimeState(): void { + this.sessionBaseline.clear() + this.recentMessageKeys.clear() + this.groupNicknameCache.clear() + this.baselineReady = false + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = null + } + } + + private async refreshConfiguration(reason: string): Promise { + if (!this.isPushEnabled()) { + this.resetRuntimeState() + return + } + + const connectResult = await chatService.connect() + if (!connectResult.success) { + console.warn(`[MessagePushService] Bootstrap connect failed (${reason}):`, connectResult.error) + return + } + + await this.bootstrapBaseline() + } + + private async bootstrapBaseline(): Promise { + const sessionsResult = await chatService.getSessions() + if (!sessionsResult.success || !sessionsResult.sessions) { + return + } + this.setBaseline(sessionsResult.sessions as ChatSession[]) + this.baselineReady = true + } + + private scheduleSync(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + } + + this.debounceTimer = setTimeout(() => { + this.debounceTimer = null + void this.flushPendingChanges() + }, this.debounceMs) + } + + private async flushPendingChanges(): Promise { + if (this.processing) { + this.rerunRequested = true + return + } + + this.processing = true + try { + if (!this.isPushEnabled()) return + + const connectResult = await chatService.connect() + if (!connectResult.success) { + console.warn('[MessagePushService] Sync connect failed:', connectResult.error) + return + } + + const sessionsResult = await chatService.getSessions() + if (!sessionsResult.success || !sessionsResult.sessions) { + return + } + + const sessions = sessionsResult.sessions as ChatSession[] + if (!this.baselineReady) { + this.setBaseline(sessions) + this.baselineReady = true + return + } + + const previousBaseline = new Map(this.sessionBaseline) + this.setBaseline(sessions) + + const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session)) + for (const session of candidates) { + await this.pushSessionMessages(session, previousBaseline.get(session.username)) + } + } finally { + this.processing = false + if (this.rerunRequested) { + this.rerunRequested = false + this.scheduleSync() + } + } + } + + private setBaseline(sessions: ChatSession[]): void { + this.sessionBaseline.clear() + for (const session of sessions) { + this.sessionBaseline.set(session.username, { + lastTimestamp: Number(session.lastTimestamp || 0), + unreadCount: Number(session.unreadCount || 0) + }) + } + } + + private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean { + const sessionId = String(session.username || '').trim() + if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) { + return false + } + + const summary = String(session.summary || '').trim() + if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) { + return false + } + + const lastTimestamp = Number(session.lastTimestamp || 0) + const unreadCount = Number(session.unreadCount || 0) + + if (!previous) { + return unreadCount > 0 && lastTimestamp > 0 + } + + if (lastTimestamp <= previous.lastTimestamp) { + return false + } + + // unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送 + return unreadCount > previous.unreadCount + } + + private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise { + const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1) + const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000) + if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) { + return + } + + for (const message of newMessagesResult.messages) { + const messageKey = String(message.messageKey || '').trim() + if (!messageKey) continue + if (message.isSend === 1) continue + + if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) { + continue + } + + if (this.isRecentMessage(messageKey)) { + continue + } + + const payload = await this.buildPayload(session, message) + if (!payload) continue + + httpService.broadcastMessagePush(payload) + this.rememberMessageKey(messageKey) + } + } + + private async buildPayload(session: ChatSession, message: Message): Promise { + const sessionId = String(session.username || '').trim() + const messageKey = String(message.messageKey || '').trim() + if (!sessionId || !messageKey) return null + + const isGroup = sessionId.endsWith('@chatroom') + const content = this.getMessageDisplayContent(message) + + if (isGroup) { + const groupInfo = await chatService.getContactAvatar(sessionId) + const groupName = session.displayName || groupInfo?.displayName || sessionId + const sourceName = await this.resolveGroupSourceName(sessionId, message, session) + return { + event: 'message.new', + sessionId, + messageKey, + avatarUrl: session.avatarUrl || groupInfo?.avatarUrl, + groupName, + sourceName, + content + } + } + + const contactInfo = await chatService.getContactAvatar(sessionId) + return { + event: 'message.new', + sessionId, + messageKey, + avatarUrl: session.avatarUrl || contactInfo?.avatarUrl, + sourceName: session.displayName || contactInfo?.displayName || sessionId, + content + } + } + + private getMessageDisplayContent(message: Message): string | null { + switch (Number(message.localType || 0)) { + case 1: + return message.rawContent || null + case 3: + return '[图片]' + case 34: + return '[语音]' + case 43: + return '[视频]' + case 47: + return '[表情]' + case 42: + return message.cardNickname || '[名片]' + case 48: + return '[位置]' + case 49: + return message.linkTitle || message.fileName || '[消息]' + default: + return message.parsedContent || message.rawContent || null + } + } + + private async resolveGroupSourceName(chatroomId: string, message: Message, session: ChatSession): Promise { + const senderUsername = String(message.senderUsername || '').trim() + if (!senderUsername) { + return session.lastSenderDisplayName || '未知发送者' + } + + const groupNicknames = await this.getGroupNicknames(chatroomId) + const normalizedSender = this.normalizeAccountId(senderUsername) + const nickname = groupNicknames[senderUsername] + || groupNicknames[senderUsername.toLowerCase()] + || groupNicknames[normalizedSender] + || groupNicknames[normalizedSender.toLowerCase()] + + if (nickname) { + return nickname + } + + const contactInfo = await chatService.getContactAvatar(senderUsername) + return contactInfo?.displayName || senderUsername + } + + private async getGroupNicknames(chatroomId: string): Promise> { + const cacheKey = String(chatroomId || '').trim() + if (!cacheKey) return {} + + const cached = this.groupNicknameCache.get(cacheKey) + if (cached && Date.now() - cached.updatedAt < this.groupNicknameCacheTtlMs) { + return cached.nicknames + } + + const result = await wcdbService.getGroupNicknames(cacheKey) + const nicknames = result.success && result.nicknames ? result.nicknames : {} + this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() }) + return nicknames + } + + private normalizeAccountId(value: string): string { + const trimmed = String(value || '').trim() + if (!trimmed) return trimmed + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + return match ? match[1] : trimmed + } + + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed + } + + private isRecentMessage(messageKey: string): boolean { + this.pruneRecentMessageKeys() + const timestamp = this.recentMessageKeys.get(messageKey) + return typeof timestamp === 'number' && Date.now() - timestamp < this.recentMessageTtlMs + } + + private rememberMessageKey(messageKey: string): void { + this.recentMessageKeys.set(messageKey, Date.now()) + this.pruneRecentMessageKeys() + } + + private pruneRecentMessageKeys(): void { + const now = Date.now() + for (const [key, timestamp] of this.recentMessageKeys.entries()) { + if (now - timestamp > this.recentMessageTtlMs) { + this.recentMessageKeys.delete(key) + } + } + } + +} + +export const messagePushService = new MessagePushService() diff --git a/electron/services/sessionStatsCacheService.ts b/electron/services/sessionStatsCacheService.ts new file mode 100644 index 0000000..147930d --- /dev/null +++ b/electron/services/sessionStatsCacheService.ts @@ -0,0 +1,293 @@ +import { join, dirname } from 'path' +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs' +import { ConfigService } from './config' + +const CACHE_VERSION = 2 +const MAX_SESSION_ENTRIES_PER_SCOPE = 2000 +const MAX_SCOPE_ENTRIES = 12 + +export interface SessionStatsCacheStats { + totalMessages: number + voiceMessages: number + imageMessages: number + videoMessages: number + emojiMessages: number + transferMessages: number + redPacketMessages: number + callMessages: number + firstTimestamp?: number + lastTimestamp?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number +} + +export interface SessionStatsCacheEntry { + updatedAt: number + includeRelations: boolean + stats: SessionStatsCacheStats +} + +interface SessionStatsScopeMap { + [sessionId: string]: SessionStatsCacheEntry +} + +interface SessionStatsCacheStore { + version: number + scopes: Record +} + +function toNonNegativeInt(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined + return Math.max(0, Math.floor(value)) +} + +function normalizeStats(raw: unknown): SessionStatsCacheStats | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + + const totalMessages = toNonNegativeInt(source.totalMessages) + const voiceMessages = toNonNegativeInt(source.voiceMessages) + const imageMessages = toNonNegativeInt(source.imageMessages) + const videoMessages = toNonNegativeInt(source.videoMessages) + const emojiMessages = toNonNegativeInt(source.emojiMessages) + const transferMessages = toNonNegativeInt(source.transferMessages) + const redPacketMessages = toNonNegativeInt(source.redPacketMessages) + const callMessages = toNonNegativeInt(source.callMessages) + + if ( + totalMessages === undefined || + voiceMessages === undefined || + imageMessages === undefined || + videoMessages === undefined || + emojiMessages === undefined || + transferMessages === undefined || + redPacketMessages === undefined || + callMessages === undefined + ) { + return null + } + + const normalized: SessionStatsCacheStats = { + totalMessages, + voiceMessages, + imageMessages, + videoMessages, + emojiMessages, + transferMessages, + redPacketMessages, + callMessages + } + + const firstTimestamp = toNonNegativeInt(source.firstTimestamp) + if (firstTimestamp !== undefined) normalized.firstTimestamp = firstTimestamp + + const lastTimestamp = toNonNegativeInt(source.lastTimestamp) + if (lastTimestamp !== undefined) normalized.lastTimestamp = lastTimestamp + + const privateMutualGroups = toNonNegativeInt(source.privateMutualGroups) + if (privateMutualGroups !== undefined) normalized.privateMutualGroups = privateMutualGroups + + const groupMemberCount = toNonNegativeInt(source.groupMemberCount) + if (groupMemberCount !== undefined) normalized.groupMemberCount = groupMemberCount + + const groupMyMessages = toNonNegativeInt(source.groupMyMessages) + if (groupMyMessages !== undefined) normalized.groupMyMessages = groupMyMessages + + const groupActiveSpeakers = toNonNegativeInt(source.groupActiveSpeakers) + if (groupActiveSpeakers !== undefined) normalized.groupActiveSpeakers = groupActiveSpeakers + + const groupMutualFriends = toNonNegativeInt(source.groupMutualFriends) + if (groupMutualFriends !== undefined) normalized.groupMutualFriends = groupMutualFriends + + return normalized +} + +function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null { + if (!raw || typeof raw !== 'object') return null + const source = raw as Record + const updatedAt = toNonNegativeInt(source.updatedAt) + const includeRelations = typeof source.includeRelations === 'boolean' ? source.includeRelations : false + const stats = normalizeStats(source.stats) + + if (updatedAt === undefined || !stats) { + return null + } + + return { + updatedAt, + includeRelations, + stats + } +} + +export class SessionStatsCacheService { + private readonly cacheFilePath: string + private store: SessionStatsCacheStore = { + version: CACHE_VERSION, + scopes: {} + } + + constructor(cacheBasePath?: string) { + const basePath = cacheBasePath && cacheBasePath.trim().length > 0 + ? cacheBasePath + : ConfigService.getInstance().getCacheBasePath() + this.cacheFilePath = join(basePath, 'session-stats.json') + this.ensureCacheDir() + this.load() + } + + private ensureCacheDir(): void { + const dir = dirname(this.cacheFilePath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + } + + private load(): void { + if (!existsSync(this.cacheFilePath)) return + try { + const raw = readFileSync(this.cacheFilePath, 'utf8') + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object') { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + + const payload = parsed as Record + const version = Number(payload.version) + if (!Number.isFinite(version) || version !== CACHE_VERSION) { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + const scopesRaw = payload.scopes + if (!scopesRaw || typeof scopesRaw !== 'object') { + this.store = { version: CACHE_VERSION, scopes: {} } + return + } + + const scopes: Record = {} + for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record)) { + if (!scopeValue || typeof scopeValue !== 'object') continue + const normalizedScope: SessionStatsScopeMap = {} + for (const [sessionId, entryRaw] of Object.entries(scopeValue as Record)) { + const entry = normalizeEntry(entryRaw) + if (!entry) continue + normalizedScope[sessionId] = entry + } + if (Object.keys(normalizedScope).length > 0) { + scopes[scopeKey] = normalizedScope + } + } + + this.store = { + version: CACHE_VERSION, + scopes + } + } catch (error) { + console.error('SessionStatsCacheService: 载入缓存失败', error) + this.store = { version: CACHE_VERSION, scopes: {} } + } + } + + get(scopeKey: string, sessionId: string): SessionStatsCacheEntry | undefined { + if (!scopeKey || !sessionId) return undefined + const scope = this.store.scopes[scopeKey] + if (!scope) return undefined + const entry = normalizeEntry(scope[sessionId]) + if (!entry) { + delete scope[sessionId] + if (Object.keys(scope).length === 0) { + delete this.store.scopes[scopeKey] + } + this.persist() + return undefined + } + return entry + } + + set(scopeKey: string, sessionId: string, entry: SessionStatsCacheEntry): void { + if (!scopeKey || !sessionId) return + const normalized = normalizeEntry(entry) + if (!normalized) return + + if (!this.store.scopes[scopeKey]) { + this.store.scopes[scopeKey] = {} + } + this.store.scopes[scopeKey][sessionId] = normalized + + this.trimScope(scopeKey) + this.trimScopes() + this.persist() + } + + delete(scopeKey: string, sessionId: string): void { + if (!scopeKey || !sessionId) return + const scope = this.store.scopes[scopeKey] + if (!scope) return + if (!(sessionId in scope)) return + + delete scope[sessionId] + if (Object.keys(scope).length === 0) { + delete this.store.scopes[scopeKey] + } + this.persist() + } + + clearScope(scopeKey: string): void { + if (!scopeKey) return + if (!this.store.scopes[scopeKey]) return + delete this.store.scopes[scopeKey] + this.persist() + } + + clearAll(): void { + this.store = { version: CACHE_VERSION, scopes: {} } + try { + rmSync(this.cacheFilePath, { force: true }) + } catch (error) { + console.error('SessionStatsCacheService: 清理缓存失败', error) + } + } + + private trimScope(scopeKey: string): void { + const scope = this.store.scopes[scopeKey] + if (!scope) return + const entries = Object.entries(scope) + if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return + + entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt) + const trimmed: SessionStatsScopeMap = {} + for (const [sessionId, entry] of entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) { + trimmed[sessionId] = entry + } + this.store.scopes[scopeKey] = trimmed + } + + private trimScopes(): void { + const scopeEntries = Object.entries(this.store.scopes) + if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return + + scopeEntries.sort((a, b) => { + const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0) + const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0) + return bUpdatedAt - aUpdatedAt + }) + + const trimmedScopes: Record = {} + for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) { + trimmedScopes[scopeKey] = scopeMap + } + this.store.scopes = trimmedScopes + } + + private persist(): void { + try { + writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8') + } catch (error) { + console.error('SessionStatsCacheService: 保存缓存失败', error) + } + } +} diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 835850f..e0d31f9 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -27,6 +27,17 @@ export interface SnsMedia { livePhoto?: SnsLivePhoto } +export interface SnsLocation { + latitude?: number + longitude?: number + city?: string + country?: string + poiName?: string + poiAddress?: string + poiAddressName?: string + label?: string +} + export interface SnsPost { id: string tid?: string // 数据库主键(雪花 ID),用于精确删除 @@ -39,11 +50,74 @@ export interface SnsPost { media: SnsMedia[] likes: string[] comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] + location?: SnsLocation rawXml?: string linkTitle?: string linkUrl?: string } +interface SnsContactIdentity { + username: string + wxid: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string +} + +interface ParsedLikeUser { + username?: string + nickname?: string +} + +interface ParsedCommentItem { + id: string + nickname: string + username?: string + content: string + refCommentId: string + refUsername?: string + refNickname?: string + emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] +} + +interface ArkmeLikeDetail { + nickname: string + username?: string + wxid?: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string + source: 'xml' | 'legacy' +} + +interface ArkmeCommentDetail { + id: string + nickname: string + username?: string + wxid?: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string + content: string + refCommentId: string + refNickname?: string + refUsername?: string + refWxid?: string + refAlias?: string + refWechatId?: string + refRemark?: string + refNickName?: string + refDisplayName?: string + emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] + source: 'xml' | 'legacy' +} + const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => { @@ -127,7 +201,7 @@ const extractVideoKey = (xml: string): string | undefined => { /** * 从 XML 中解析评论信息(含表情包、回复关系) */ -function parseCommentsFromXml(xml: string): { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] { +function parseCommentsFromXml(xml: string): ParsedCommentItem[] { if (!xml) return [] type CommentItem = { @@ -225,16 +299,279 @@ function parseCommentsFromXml(xml: string): { id: string; nickname: string; cont return comments } +const decodeXmlText = (text: string): string => { + if (!text) return '' + return text + .replace(//g, '$1') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") +} + class SnsService { private configService: ConfigService private contactCache: ContactCacheService private imageCache = new Map() + private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null + private userPostCountsCache: { counts: Record; updatedAt: number } | null = null + private readonly exportStatsCacheTtlMs = 5 * 60 * 1000 + private readonly userPostCountsCacheTtlMs = 5 * 60 * 1000 + private lastTimelineFallbackAt = 0 + private readonly timelineFallbackCooldownMs = 3 * 60 * 1000 constructor() { this.configService = new ConfigService() this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string) } + private toOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined + } + + private async resolveContactIdentity( + username: string, + identityCache: Map> + ): Promise { + const normalized = String(username || '').trim() + if (!normalized) return null + + let pending = identityCache.get(normalized) + if (!pending) { + pending = (async () => { + const cached = this.contactCache.get(normalized) + let alias: string | undefined + let remark: string | undefined + let nickName: string | undefined + + try { + const contactResult = await wcdbService.getContact(normalized) + if (contactResult.success && contactResult.contact) { + const contact = contactResult.contact + alias = this.toOptionalString(contact.alias ?? contact.Alias) + remark = this.toOptionalString(contact.remark ?? contact.Remark) + nickName = this.toOptionalString(contact.nickName ?? contact.nick_name ?? contact.nickname ?? contact.NickName) + } + } catch { + // 联系人补全失败不影响导出 + } + + const displayName = remark || nickName || alias || cached?.displayName || normalized + return { + username: normalized, + wxid: normalized, + alias, + wechatId: alias, + remark, + nickName, + displayName + } + })() + identityCache.set(normalized, pending) + } + + return pending + } + + private parseLikeUsersFromXml(xml: string): ParsedLikeUser[] { + if (!xml) return [] + const likes: ParsedLikeUser[] = [] + try { + let likeListMatch = xml.match(/([\s\S]*?)<\/LikeUserList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeUserList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/like_user_list>/i) + if (!likeListMatch) return likes + + const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi + let m: RegExpExecArray | null + while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) { + const block = m[1] + const username = this.toOptionalString(block.match(/([^<]*)<\/username>/i)?.[1]) + const nickname = this.toOptionalString( + block.match(/([^<]*)<\/nickname>/i)?.[1] + || block.match(/([^<]*)<\/nickName>/i)?.[1] + ) + if (username || nickname) { + likes.push({ username, nickname }) + } + } + } catch (e) { + console.error('[SnsService] 解析点赞用户失败:', e) + } + return likes + } + + private async buildArkmeInteractionDetails( + post: SnsPost, + identityCache: Map> + ): Promise<{ likesDetail: ArkmeLikeDetail[]; commentsDetail: ArkmeCommentDetail[] }> { + const xmlLikes = this.parseLikeUsersFromXml(post.rawXml || '') + const likeCandidates: ParsedLikeUser[] = xmlLikes.length > 0 + ? xmlLikes + : (post.likes || []).map((nickname) => ({ nickname })) + const likeSource: 'xml' | 'legacy' = xmlLikes.length > 0 ? 'xml' : 'legacy' + const likesDetail: ArkmeLikeDetail[] = [] + const likeSeen = new Set() + + for (const like of likeCandidates) { + const identity = like.username + ? await this.resolveContactIdentity(like.username, identityCache) + : null + const nickname = like.nickname || identity?.displayName || like.username || '' + const username = identity?.username || like.username + const key = `${username || ''}|${nickname}` + if (likeSeen.has(key)) continue + likeSeen.add(key) + likesDetail.push({ + nickname, + username, + wxid: username, + alias: identity?.alias, + wechatId: identity?.wechatId, + remark: identity?.remark, + nickName: identity?.nickName, + displayName: identity?.displayName || nickname || username || '', + source: likeSource + }) + } + + const xmlComments = parseCommentsFromXml(post.rawXml || '') + const commentMap = new Map() + for (const comment of post.comments || []) { + if (comment.id) commentMap.set(comment.id, comment) + } + + const commentsBase: ParsedCommentItem[] = xmlComments.length > 0 + ? xmlComments.map((comment) => { + const fallback = comment.id ? commentMap.get(comment.id) : undefined + return { + id: comment.id || fallback?.id || '', + nickname: comment.nickname || fallback?.nickname || '', + username: comment.username, + content: comment.content || fallback?.content || '', + refCommentId: comment.refCommentId || fallback?.refCommentId || '', + refUsername: comment.refUsername, + refNickname: comment.refNickname || fallback?.refNickname, + emojis: comment.emojis && comment.emojis.length > 0 ? comment.emojis : fallback?.emojis + } + }) + : (post.comments || []).map((comment) => ({ + id: comment.id || '', + nickname: comment.nickname || '', + content: comment.content || '', + refCommentId: comment.refCommentId || '', + refNickname: comment.refNickname, + emojis: comment.emojis + })) + + if (xmlComments.length > 0) { + const mappedIds = new Set(commentsBase.map((comment) => comment.id).filter(Boolean)) + for (const comment of post.comments || []) { + if (comment.id && mappedIds.has(comment.id)) continue + commentsBase.push({ + id: comment.id || '', + nickname: comment.nickname || '', + content: comment.content || '', + refCommentId: comment.refCommentId || '', + refNickname: comment.refNickname, + emojis: comment.emojis + }) + } + } + + const commentSource: 'xml' | 'legacy' = xmlComments.length > 0 ? 'xml' : 'legacy' + const commentsDetail: ArkmeCommentDetail[] = [] + + for (const comment of commentsBase) { + const actor = comment.username + ? await this.resolveContactIdentity(comment.username, identityCache) + : null + const refActor = comment.refUsername + ? await this.resolveContactIdentity(comment.refUsername, identityCache) + : null + const nickname = comment.nickname || actor?.displayName || comment.username || '' + const username = actor?.username || comment.username + const refUsername = refActor?.username || comment.refUsername + commentsDetail.push({ + id: comment.id || '', + nickname, + username, + wxid: username, + alias: actor?.alias, + wechatId: actor?.wechatId, + remark: actor?.remark, + nickName: actor?.nickName, + displayName: actor?.displayName || nickname || username || '', + content: comment.content || '', + refCommentId: comment.refCommentId || '', + refNickname: comment.refNickname || refActor?.displayName, + refUsername, + refWxid: refUsername, + refAlias: refActor?.alias, + refWechatId: refActor?.wechatId, + refRemark: refActor?.remark, + refNickName: refActor?.nickName, + refDisplayName: refActor?.displayName, + emojis: comment.emojis, + source: commentSource + }) + } + + return { likesDetail, commentsDetail } + } + + private parseCountValue(row: any): number { + if (!row || typeof row !== 'object') return 0 + const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0] + const num = Number(raw) + return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0 + } + + private pickTimelineUsername(post: any): string { + const raw = post?.username ?? post?.user_name ?? post?.userName ?? '' + if (typeof raw !== 'string') return '' + return raw.trim() + } + + private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { + const pageSize = 500 + const uniqueUsers = new Set() + let totalPosts = 0 + let myPosts = 0 + let offset = 0 + const normalizedMyWxid = this.toOptionalString(myWxid) + + for (let round = 0; round < 2000; round++) { + const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) + if (!result.success || !Array.isArray(result.timeline)) { + throw new Error(result.error || '获取朋友圈统计失败') + } + + const rows = result.timeline + if (rows.length === 0) break + + totalPosts += rows.length + for (const row of rows) { + const username = this.pickTimelineUsername(row) + if (username) uniqueUsers.add(username) + if (normalizedMyWxid && username === normalizedMyWxid) myPosts += 1 + } + + if (rows.length < pageSize) break + offset += rows.length + } + + return { + totalPosts, + totalFriends: uniqueUsers.size, + myPosts: normalizedMyWxid ? myPosts : null + } + } + private parseLikesFromXml(xml: string): string[] { if (!xml) return [] const likes: string[] = [] @@ -333,6 +670,110 @@ class SnsService { return { media, videoKey } } + private toOptionalNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + if (!trimmed) return undefined + const parsed = Number.parseFloat(trimmed) + return Number.isFinite(parsed) ? parsed : undefined + } + + private normalizeLocation(input: unknown): SnsLocation | undefined { + if (!input || typeof input !== 'object') return undefined + + const row = input as Record + 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(/]*)>/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 = (key: K, value: SnsLocation[K] | undefined) => { + if (value !== undefined) merged[key] = value + } + + setValue('latitude', primary?.latitude ?? fallback?.latitude) + setValue('longitude', primary?.longitude ?? fallback?.longitude) + setValue('city', primary?.city ?? fallback?.city) + setValue('country', primary?.country ?? fallback?.country) + setValue('poiName', primary?.poiName ?? fallback?.poiName) + setValue('poiAddress', primary?.poiAddress ?? fallback?.poiAddress) + setValue('poiAddressName', primary?.poiAddressName ?? fallback?.poiAddressName) + setValue('label', primary?.label ?? fallback?.label) + + return Object.keys(merged).length > 0 ? merged : undefined + } + private getSnsCacheDir(): string { const cachePath = this.configService.getCacheBasePath() const snsCacheDir = join(cachePath, 'sns_cache') @@ -349,14 +790,209 @@ class SnsService { } async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { - const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine') - if (!result.success || !result.rows) { - // 尝试 userName 列名 - const result2 = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT userName FROM SnsTimeLine') - if (!result2.success || !result2.rows) return { success: false, error: result.error || result2.error } - return { success: true, usernames: result2.rows.map((r: any) => r.userName).filter(Boolean) } + const result = await wcdbService.getSnsUsernames() + if (!result.success) { + return { success: false, error: result.error || '获取朋友圈联系人失败' } } - return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) } + return { success: true, usernames: result.usernames || [] } + } + + private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { + const normalizedMyWxid = this.toOptionalString(myWxid) + const result = await wcdbService.getSnsExportStats(normalizedMyWxid || undefined) + if (!result.success || !result.data) { + return { totalPosts: 0, totalFriends: 0, myPosts: normalizedMyWxid ? 0 : null } + } + return { + totalPosts: Number(result.data.totalPosts || 0), + totalFriends: Number(result.data.totalFriends || 0), + myPosts: result.data.myPosts === null || result.data.myPosts === undefined ? null : Number(result.data.myPosts || 0) + } + } + + async getExportStats(options?: { + allowTimelineFallback?: boolean + preferCache?: boolean + }): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> { + const allowTimelineFallback = options?.allowTimelineFallback ?? true + const preferCache = options?.preferCache ?? false + const now = Date.now() + const myWxid = this.toOptionalString(this.configService.get('myWxid')) + + try { + if (preferCache && this.exportStatsCache && now - this.exportStatsCache.updatedAt <= this.exportStatsCacheTtlMs) { + return { + success: true, + data: { + totalPosts: this.exportStatsCache.totalPosts, + totalFriends: this.exportStatsCache.totalFriends, + myPosts: this.exportStatsCache.myPosts + } + } + } + + let { totalPosts, totalFriends, myPosts } = await this.getExportStatsFromTableCount(myWxid) + let fallbackAttempted = false + let fallbackError = '' + + // 某些环境下 SnsTimeLine 统计查询会返回 0,这里在允许时回退到与导出同源的 timeline 接口统计。 + if ( + allowTimelineFallback && + (totalPosts <= 0 || totalFriends <= 0) && + now - this.lastTimelineFallbackAt >= this.timelineFallbackCooldownMs + ) { + fallbackAttempted = true + try { + const timelineStats = await this.getExportStatsFromTimeline(myWxid) + this.lastTimelineFallbackAt = Date.now() + if (timelineStats.totalPosts > 0) { + totalPosts = timelineStats.totalPosts + } + if (timelineStats.totalFriends > 0) { + totalFriends = timelineStats.totalFriends + } + if (timelineStats.myPosts !== null) { + myPosts = timelineStats.myPosts + } + } catch (error) { + fallbackError = String(error) + console.error('[SnsService] getExportStats timeline fallback failed:', error) + } + } + + const normalizedStats = { + totalPosts: Math.max(0, Number(totalPosts || 0)), + totalFriends: Math.max(0, Number(totalFriends || 0)), + myPosts: myWxid + ? (myPosts === null ? null : Math.max(0, Number(myPosts || 0))) + : null + } + const computedHasData = normalizedStats.totalPosts > 0 || normalizedStats.totalFriends > 0 + const cacheHasData = !!this.exportStatsCache && (this.exportStatsCache.totalPosts > 0 || this.exportStatsCache.totalFriends > 0) + + // 计算结果全 0 时,优先使用已有非零缓存,避免瞬时异常覆盖有效统计。 + if (!computedHasData && cacheHasData && this.exportStatsCache) { + return { + success: true, + data: { + totalPosts: this.exportStatsCache.totalPosts, + totalFriends: this.exportStatsCache.totalFriends, + myPosts: this.exportStatsCache.myPosts + } + } + } + + // 当主查询结果全 0 且回退统计执行失败时,返回失败给前端显示明确状态(而非错误地展示 0)。 + if (!computedHasData && fallbackAttempted && fallbackError) { + return { success: false, error: fallbackError } + } + + this.exportStatsCache = { + totalPosts: normalizedStats.totalPosts, + totalFriends: normalizedStats.totalFriends, + myPosts: normalizedStats.myPosts, + updatedAt: Date.now() + } + + return { success: true, data: normalizedStats } + } catch (e) { + if (this.exportStatsCache) { + return { + success: true, + data: { + totalPosts: this.exportStatsCache.totalPosts, + totalFriends: this.exportStatsCache.totalFriends, + myPosts: this.exportStatsCache.myPosts + } + } + } + return { success: false, error: String(e) } + } + } + + async getExportStatsFast(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> { + return this.getExportStats({ + allowTimelineFallback: false, + preferCache: true + }) + } + + private async getUserPostCountsFromTimeline(): Promise> { + const pageSize = 500 + const counts: Record = {} + let offset = 0 + + for (let round = 0; round < 2000; round++) { + const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) + if (!result.success || !Array.isArray(result.timeline)) { + throw new Error(result.error || '获取朋友圈用户总条数失败') + } + + const rows = result.timeline + if (rows.length === 0) break + + for (const row of rows) { + const username = this.pickTimelineUsername(row) + if (!username) continue + counts[username] = (counts[username] || 0) + 1 + } + + if (rows.length < pageSize) break + offset += rows.length + } + + return counts + } + + async getUserPostCounts(options?: { + preferCache?: boolean + }): Promise<{ success: boolean; counts?: Record; error?: string }> { + const preferCache = options?.preferCache ?? true + const now = Date.now() + + try { + if ( + preferCache && + this.userPostCountsCache && + now - this.userPostCountsCache.updatedAt <= this.userPostCountsCacheTtlMs + ) { + return { success: true, counts: this.userPostCountsCache.counts } + } + + const counts = await this.getUserPostCountsFromTimeline() + this.userPostCountsCache = { + counts, + updatedAt: Date.now() + } + return { success: true, counts } + } catch (error) { + console.error('[SnsService] getUserPostCounts failed:', error) + if (this.userPostCountsCache) { + return { success: true, counts: this.userPostCountsCache.counts } + } + return { success: false, error: String(error) } + } + } + + async getUserPostStats(username: string): Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> { + const normalizedUsername = this.toOptionalString(username) + if (!normalizedUsername) { + return { success: false, error: '用户名不能为空' } + } + + const countsResult = await this.getUserPostCounts({ preferCache: true }) + if (countsResult.success) { + const totalPosts = countsResult.counts?.[normalizedUsername] ?? 0 + return { + success: true, + data: { + username: normalizedUsername, + totalPosts: Math.max(0, Number(totalPosts || 0)) + } + } + } + + return { success: false, error: countsResult.error || '统计单个好友朋友圈失败' } } // 安装朋友圈删除拦截 @@ -376,7 +1012,12 @@ class SnsService { // 从数据库直接删除朋友圈记录 async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { - return wcdbService.deleteSnsPost(postId) + const result = await wcdbService.deleteSnsPost(postId) + if (result.success) { + this.userPostCountsCache = null + this.exportStatsCache = null + } + return result } /** @@ -431,24 +1072,15 @@ class SnsService { const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) if (!result.success || !result.timeline || result.timeline.length === 0) return result - // 诊断:测试 execQuery 查 content 字段 - try { - const testResult = await wcdbService.execQuery('sns', null, 'SELECT tid, CAST(content AS TEXT) as ct, typeof(content) as ctype FROM SnsTimeLine ORDER BY tid DESC LIMIT 1') - if (testResult.success && testResult.rows?.[0]) { - const r = testResult.rows[0] - console.log('[SnsService] execQuery 诊断: ctype=', r.ctype, 'ct长度=', r.ct?.length, 'ct前200=', r.ct?.substring(0, 200)) - console.log('[SnsService] ct包含CommentUserList:', r.ct?.includes('CommentUserList')) - } else { - console.log('[SnsService] execQuery 诊断失败:', testResult.error) - } - } catch (e) { - console.log('[SnsService] execQuery 诊断异常:', e) - } - const enrichedTimeline = result.timeline.map((post: any) => { const contact = this.contactCache.get(post.username) const isVideoPost = post.type === 15 - const videoKey = extractVideoKey(post.rawXml || '') + const rawXml = post.rawXml || '' + const videoKey = extractVideoKey(rawXml) + const location = this.mergeLocation( + this.normalizeLocation((post as { location?: unknown }).location), + this.parseLocationFromXml(rawXml) + ) const fixedMedia = (post.media || []).map((m: any) => ({ url: fixSnsUrl(m.url, m.token, isVideoPost), @@ -471,7 +1103,6 @@ class SnsService { // 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析 const dllComments: any[] = post.comments || [] const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0) - const rawXml = post.rawXml || '' let finalComments: any[] if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) { @@ -490,7 +1121,8 @@ class SnsService { avatarUrl: contact?.avatarUrl, nickname: post.nickname || contact?.displayName || post.username, media: fixedMedia, - comments: finalComments + comments: finalComments, + location } }) @@ -577,14 +1209,44 @@ class SnsService { */ async exportTimeline(options: { outputDir: string - format: 'json' | 'html' + format: 'json' | 'html' | 'arkmejson' usernames?: string[] keyword?: string exportMedia?: boolean + exportImages?: boolean + exportLivePhotos?: boolean + exportVideos?: boolean startTime?: number endTime?: number - }, progressCallback?: (progress: { current: number; total: number; status: string }) => void): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> { - const { outputDir, format, usernames, keyword, exportMedia = false, startTime, endTime } = options + }, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: { + shouldPause?: () => boolean + shouldStop?: () => boolean + }): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> { + const { outputDir, format, usernames, keyword, startTime, endTime } = options + const hasExplicitMediaSelection = + typeof options.exportImages === 'boolean' || + typeof options.exportLivePhotos === 'boolean' || + typeof options.exportVideos === 'boolean' + const shouldExportImages = hasExplicitMediaSelection + ? options.exportImages === true + : options.exportMedia === true + const shouldExportLivePhotos = hasExplicitMediaSelection + ? options.exportLivePhotos === true + : options.exportMedia === true + const shouldExportVideos = hasExplicitMediaSelection + ? options.exportVideos === true + : options.exportMedia === true + const shouldExportMedia = shouldExportImages || shouldExportLivePhotos || shouldExportVideos + const getControlState = (): 'paused' | 'stopped' | null => { + if (control?.shouldStop?.()) return 'stopped' + if (control?.shouldPause?.()) return 'paused' + return null + } + const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => ( + state === 'stopped' + ? { success: true, stopped: true, filePath: '', postCount, mediaCount } + : { success: true, paused: true, filePath: '', postCount, mediaCount } + ) try { // 确保输出目录存在 @@ -601,6 +1263,10 @@ class SnsService { progressCallback?.({ current: 0, total: 0, status: '正在加载朋友圈数据...' }) while (hasMore) { + const controlState = getControlState() + if (controlState) { + return buildInterruptedResult(controlState, allPosts.length, 0) + } const result = await this.getTimeline(pageSize, 0, usernames, keyword, startTime, endTs) if (result.success && result.timeline && result.timeline.length > 0) { allPosts.push(...result.timeline) @@ -628,15 +1294,54 @@ class SnsService { let mediaCount = 0 const mediaDir = join(outputDir, 'media') - if (exportMedia) { + if (shouldExportMedia) { if (!existsSync(mediaDir)) { mkdirSync(mediaDir, { recursive: true }) } // 收集所有媒体下载任务 - const mediaTasks: { media: SnsMedia; postId: string; mi: number }[] = [] + const mediaTasks: Array<{ + kind: 'image' | 'video' | 'livephoto' + media: SnsMedia + url: string + key?: string + postId: string + mi: number + }> = [] for (const post of allPosts) { - post.media.forEach((media, mi) => mediaTasks.push({ media, postId: post.id, mi })) + post.media.forEach((media, mi) => { + const isVideo = isVideoUrl(media.url) + if (shouldExportImages && !isVideo && media.url) { + mediaTasks.push({ + kind: 'image', + media, + url: media.url, + key: media.key, + postId: post.id, + mi + }) + } + if (shouldExportVideos && isVideo && media.url) { + mediaTasks.push({ + kind: 'video', + media, + url: media.url, + key: media.key, + postId: post.id, + mi + }) + } + if (shouldExportLivePhotos && media.livePhoto?.url) { + mediaTasks.push({ + kind: 'livephoto', + media, + url: media.livePhoto.url, + key: media.livePhoto.key || media.key, + postId: post.id, + mi + }) + } + }) } // 并发下载(5路) @@ -645,29 +1350,42 @@ class SnsService { const runTask = async (task: typeof mediaTasks[0]) => { const { media, postId, mi } = task try { - const isVideo = isVideoUrl(media.url) + const isVideo = task.kind === 'video' || task.kind === 'livephoto' || isVideoUrl(task.url) const ext = isVideo ? 'mp4' : 'jpg' - const fileName = `${postId}_${mi}.${ext}` + const suffix = task.kind === 'livephoto' ? '_live' : '' + const fileName = `${postId}_${mi}${suffix}.${ext}` const filePath = join(mediaDir, fileName) if (existsSync(filePath)) { - ;(media as any).localPath = `media/${fileName}` + if (task.kind === 'livephoto') { + if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}` + } else { + ;(media as any).localPath = `media/${fileName}` + } mediaCount++ } else { - const result = await this.fetchAndDecryptImage(media.url, media.key) + const result = await this.fetchAndDecryptImage(task.url, task.key) if (result.success && result.data) { await writeFile(filePath, result.data) - ;(media as any).localPath = `media/${fileName}` + if (task.kind === 'livephoto') { + if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}` + } else { + ;(media as any).localPath = `media/${fileName}` + } mediaCount++ } else if (result.success && result.cachePath) { const cachedData = await readFile(result.cachePath) await writeFile(filePath, cachedData) - ;(media as any).localPath = `media/${fileName}` + if (task.kind === 'livephoto') { + if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}` + } else { + ;(media as any).localPath = `media/${fileName}` + } mediaCount++ } } } catch (e) { - console.warn(`[SnsExport] 媒体下载失败: ${task.media.url}`, e) + console.warn(`[SnsExport] 媒体下载失败: ${task.url}`, e) } done++ progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` }) @@ -677,11 +1395,18 @@ class SnsService { const queue = [...mediaTasks] const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => { while (queue.length > 0) { + const controlState = getControlState() + if (controlState) return controlState const task = queue.shift()! await runTask(task) } + return null }) - await Promise.all(workers) + const workerResults = await Promise.all(workers) + const interruptedState = workerResults.find(state => state === 'paused' || state === 'stopped') + if (interruptedState) { + return buildInterruptedResult(interruptedState, allPosts.length, mediaCount) + } } // 2.5 下载头像 @@ -693,6 +1418,8 @@ class SnsService { const avatarQueue = [...uniqueUsers] const avatarWorkers = Array.from({ length: Math.min(5, avatarQueue.length) }, async () => { while (avatarQueue.length > 0) { + const controlState = getControlState() + if (controlState) return controlState const post = avatarQueue.shift()! try { const fileName = `avatar_${crypto.createHash('md5').update(post.username).digest('hex').slice(0, 8)}.jpg` @@ -710,11 +1437,20 @@ class SnsService { avatarDone++ progressCallback?.({ current: avatarDone, total: uniqueUsers.length, status: `正在下载头像 (${avatarDone}/${uniqueUsers.length})...` }) } + return null }) - await Promise.all(avatarWorkers) + const avatarWorkerResults = await Promise.all(avatarWorkers) + const interruptedState = avatarWorkerResults.find(state => state === 'paused' || state === 'stopped') + if (interruptedState) { + return buildInterruptedResult(interruptedState, allPosts.length, mediaCount) + } } // 3. 生成输出文件 + const finalControlState = getControlState() + if (finalControlState) { + return buildInterruptedResult(finalControlState, allPosts.length, mediaCount) + } const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) let outputFilePath: string @@ -742,11 +1478,99 @@ class SnsService { })), likes: p.likes, comments: p.comments, + location: p.location, linkTitle: (p as any).linkTitle, linkUrl: (p as any).linkUrl })) } await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8') + } else if (format === 'arkmejson') { + outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`) + progressCallback?.({ current: 0, total: allPosts.length, status: '正在构建 ArkmeJSON 数据...' }) + + const identityCache = new Map>() + const posts: any[] = [] + let built = 0 + + for (const post of allPosts) { + const controlState = getControlState() + if (controlState) { + return buildInterruptedResult(controlState, allPosts.length, mediaCount) + } + + const authorIdentity = await this.resolveContactIdentity(post.username, identityCache) + const { likesDetail, commentsDetail } = await this.buildArkmeInteractionDetails(post, identityCache) + + posts.push({ + id: post.id, + username: post.username, + nickname: post.nickname, + author: authorIdentity + ? { + ...authorIdentity + } + : { + username: post.username, + wxid: post.username, + displayName: post.nickname || post.username + }, + createTime: post.createTime, + createTimeStr: new Date(post.createTime * 1000).toLocaleString('zh-CN'), + contentDesc: post.contentDesc, + type: post.type, + media: post.media.map(m => ({ + url: m.url, + thumb: m.thumb, + localPath: (m as any).localPath || undefined, + livePhoto: m.livePhoto ? { + url: m.livePhoto.url, + thumb: m.livePhoto.thumb, + localPath: (m.livePhoto as any).localPath || undefined + } : undefined + })), + likes: post.likes, + comments: post.comments, + location: post.location, + likesDetail, + commentsDetail, + linkTitle: (post as any).linkTitle, + linkUrl: (post as any).linkUrl + }) + + built++ + if (built % 20 === 0 || built === allPosts.length) { + progressCallback?.({ current: built, total: allPosts.length, status: `正在构建 ArkmeJSON 数据 (${built}/${allPosts.length})...` }) + } + } + + const ownerWxid = this.toOptionalString(this.configService.get('myWxid')) + const ownerIdentity = ownerWxid + ? await this.resolveContactIdentity(ownerWxid, identityCache) + : null + const recordOwner = ownerIdentity + ? { ...ownerIdentity } + : ownerWxid + ? { username: ownerWxid, wxid: ownerWxid, displayName: ownerWxid } + : { username: '', wxid: '', displayName: '' } + + const exportData = { + exportTime: new Date().toISOString(), + format: 'arkmejson', + schemaVersion: '1.0.0', + recordOwner, + mediaSelection: { + images: shouldExportImages, + livePhotos: shouldExportLivePhotos, + videos: shouldExportVideos + }, + totalPosts: allPosts.length, + filters: { + usernames: usernames || [], + keyword: keyword || '' + }, + posts + } + await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8') } else { // HTML 格式 outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`) @@ -789,6 +1613,27 @@ class SnsService { const ch = name.charAt(0) return escapeHtml(ch || '?') } + const normalizeLocationText = (value?: string): string => ( + decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim() + ) + const resolveLocationText = (location?: SnsLocation): string => { + if (!location) return '' + const primaryCandidates = [ + normalizeLocationText(location.poiName), + normalizeLocationText(location.poiAddressName), + normalizeLocationText(location.label), + normalizeLocationText(location.poiAddress) + ].filter(Boolean) + const primary = primaryCandidates[0] || '' + const region = [ + normalizeLocationText(location.country), + normalizeLocationText(location.city) + ].filter(Boolean).join(' ') + if (primary && region && !primary.includes(region)) { + return `${primary} · ${region}` + } + return primary || region + } let filterInfo = '' if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" ` @@ -812,6 +1657,10 @@ class SnsService { const linkHtml = post.linkTitle && post.linkUrl ? `${escapeHtml(post.linkTitle)}` : '' + const locationText = resolveLocationText(post.location) + const locationHtml = locationText + ? `
📍${escapeHtml(locationText)}
` + : '' const likesHtml = post.likes.length > 0 ? `
` @@ -834,6 +1683,7 @@ ${avatarHtml}
${escapeHtml(post.nickname)}${formatTime(post.createTime)}
${post.contentDesc ? `
${escapeHtml(post.contentDesc)}
` : ''} +${locationHtml} ${mediaHtml ? `
${mediaHtml}
` : ''} ${linkHtml} ${likesHtml} @@ -869,6 +1719,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hira .nick{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:2px} .tm{font-size:12px;color:var(--t3)} .txt{font-size:15px;line-height:1.6;white-space:pre-wrap;word-break:break-word;margin-bottom:12px} +.loc{display:flex;align-items:flex-start;gap:6px;font-size:13px;color:var(--t2);margin:-4px 0 12px} +.loc-i{line-height:1.3} +.loc-t{line-height:1.45;word-break:break-word} /* 媒体网格 */ .mg{display:grid;gap:6px;margin-bottom:12px;max-width:320px} diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 1893eab..761e017 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -2,357 +2,556 @@ import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs' import { app } from 'electron' import { ConfigService } from './config' -import Database from 'better-sqlite3' import { wcdbService } from './wcdbService' export interface VideoInfo { - videoUrl?: string // 视频文件路径(用于 readFile) - coverUrl?: string // 封面 data URL - thumbUrl?: string // 缩略图 data URL - exists: boolean + videoUrl?: string // 视频文件路径(用于 readFile) + coverUrl?: string // 封面 data URL + thumbUrl?: string // 缩略图 data URL + exists: boolean +} + +interface TimedCacheEntry { + value: T + expiresAt: number +} + +interface VideoIndexEntry { + videoPath?: string + coverPath?: string + thumbPath?: string } class VideoService { - private configService: ConfigService + private configService: ConfigService + private hardlinkResolveCache = new Map>() + private videoInfoCache = new Map>() + private videoDirIndexCache = new Map>>() + private pendingVideoInfo = new Map>() + private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 + private readonly videoInfoCacheTtlMs = 2 * 60 * 1000 + private readonly videoIndexCacheTtlMs = 90 * 1000 + private readonly maxCacheEntries = 2000 + private readonly maxIndexEntries = 6 - constructor() { - this.configService = new ConfigService() + constructor() { + this.configService = new ConfigService() + } + + private log(message: string, meta?: Record): void { + try { + const timestamp = new Date().toISOString() + const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' + const logDir = join(app.getPath('userData'), 'logs') + if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }) + appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8') + } catch { } + } + + private readTimedCache(cache: Map>, key: string): T | undefined { + const hit = cache.get(key) + if (!hit) return undefined + if (hit.expiresAt <= Date.now()) { + cache.delete(key) + return undefined + } + return hit.value + } + + private writeTimedCache( + cache: Map>, + key: string, + value: T, + ttlMs: number, + maxEntries: number + ): void { + cache.set(key, { value, expiresAt: Date.now() + ttlMs }) + if (cache.size <= maxEntries) return + + const now = Date.now() + for (const [cacheKey, entry] of cache) { + if (entry.expiresAt <= now) { + cache.delete(cacheKey) + } } - private log(message: string, meta?: Record): void { - try { - const timestamp = new Date().toISOString() - const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' - const logDir = join(app.getPath('userData'), 'logs') - if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }) - appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8') - } catch {} + while (cache.size > maxEntries) { + const oldestKey = cache.keys().next().value as string | undefined + if (!oldestKey) break + cache.delete(oldestKey) + } + } + + /** + * 获取数据库根目录 + */ + private getDbPath(): string { + return this.configService.get('dbPath') || '' + } + + /** + * 获取当前用户的wxid + */ + private getMyWxid(): string { + return this.configService.get('myWxid') || '' + } + + /** + * 清理 wxid 目录名(去掉后缀) + */ + private cleanWxid(wxid: string): string { + const trimmed = wxid.trim() + if (!trimmed) return trimmed + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed } - /** - * 获取数据库根目录 - */ - private getDbPath(): string { - return this.configService.get('dbPath') || '' + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + if (suffixMatch) return suffixMatch[1] + + return trimmed + } + + private getScopeKey(dbPath: string, wxid: string): string { + return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase() + } + + private resolveVideoBaseDir(dbPath: string, wxid: string): string { + const cleanedWxid = this.cleanWxid(wxid) + const dbPathLower = dbPath.toLowerCase() + const wxidLower = wxid.toLowerCase() + const cleanedWxidLower = cleanedWxid.toLowerCase() + const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) + if (dbPathContainsWxid) { + return join(dbPath, 'msg', 'video') + } + return join(dbPath, wxid, 'msg', 'video') + } + + private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] { + const dbPathLower = dbPath.toLowerCase() + const wxidLower = wxid.toLowerCase() + const cleanedWxidLower = cleanedWxid.toLowerCase() + const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) + + if (dbPathContainsWxid) { + return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')] } - /** - * 获取当前用户的wxid - */ - private getMyWxid(): string { - return this.configService.get('myWxid') || '' + return [ + join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'), + join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db') + ] + } + + /** + * 从 video_hardlink_info_v4 表查询视频文件名 + * 使用 wcdb 专属接口查询加密的 hardlink.db + */ + private async resolveVideoHardlinks( + md5List: string[], + dbPath: string, + wxid: string, + cleanedWxid: string + ): Promise> { + const scopeKey = this.getScopeKey(dbPath, wxid) + const normalizedList = Array.from( + new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)) + ) + const resolvedMap = new Map() + const unresolvedSet = new Set(normalizedList) + + for (const md5 of normalizedList) { + const cacheKey = `${scopeKey}|${md5}` + const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey) + if (cached === undefined) continue + if (cached) resolvedMap.set(md5, cached) + unresolvedSet.delete(md5) } - /** - * 获取缓存目录(解密后的数据库存放位置) - */ - private getCachePath(): string { - return this.configService.getCacheBasePath() - } + if (unresolvedSet.size === 0) return resolvedMap - /** - * 清理 wxid 目录名(去掉后缀) - */ - private cleanWxid(wxid: string): string { - const trimmed = wxid.trim() - if (!trimmed) return trimmed - - if (trimmed.toLowerCase().startsWith('wxid_')) { - const match = trimmed.match(/^(wxid_[^_]+)/i) - if (match) return match[1] - return trimmed - } - - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) - if (suffixMatch) return suffixMatch[1] - - return trimmed - } - - /** - * 从 video_hardlink_info_v4 表查询视频文件名 - * 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3) - * 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db - */ - private async queryVideoFileName(md5: string): Promise { - const cachePath = this.getCachePath() - const dbPath = this.getDbPath() - const wxid = this.getMyWxid() - const cleanedWxid = this.cleanWxid(wxid) - - this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath }) - - if (!wxid) { - this.log('queryVideoFileName: wxid 为空') - return undefined - } - - // 方法1:优先在 cachePath 下查找解密后的 hardlink.db - if (cachePath) { - const cacheDbPaths = [ - join(cachePath, cleanedWxid, 'hardlink.db'), - join(cachePath, wxid, 'hardlink.db'), - join(cachePath, 'hardlink.db'), - join(cachePath, 'databases', cleanedWxid, 'hardlink.db'), - join(cachePath, 'databases', wxid, 'hardlink.db') - ] - - for (const p of cacheDbPaths) { - if (existsSync(p)) { - try { - this.log('尝试缓存 hardlink.db', { path: p }) - const db = new Database(p, { readonly: true }) - const row = db.prepare(` - SELECT file_name, md5 FROM video_hardlink_info_v4 - WHERE md5 = ? - LIMIT 1 - `).get(md5) as { file_name: string; md5: string } | undefined - db.close() - - if (row?.file_name) { - const realMd5 = row.file_name.replace(/\.[^.]+$/, '') - this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 }) - return realMd5 - } - this.log('缓存 hardlink.db 未命中', { path: p }) - } catch (e) { - this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) }) - } - } - } - } - - // 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db - if (dbPath) { - const dbPathLower = dbPath.toLowerCase() - const wxidLower = wxid.toLowerCase() - const cleanedWxidLower = cleanedWxid.toLowerCase() - const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) - - const encryptedDbPaths: string[] = [] - if (dbPathContainsWxid) { - encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')) - } else { - encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db')) - encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')) - } - - for (const p of encryptedDbPaths) { - if (existsSync(p)) { - try { - this.log('尝试加密 hardlink.db', { path: p }) - const escapedMd5 = md5.replace(/'/g, "''") - const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1` - const result = await wcdbService.execQuery('media', p, sql) - - if (result.success && result.rows && result.rows.length > 0) { - const row = result.rows[0] - if (row?.file_name) { - const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '') - this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 }) - return realMd5 - } - } - this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) }) - } catch (e) { - this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) }) - } - } else { - this.log('加密 hardlink.db 不存在', { path: p }) - } - } - } - this.log('queryVideoFileName: 所有方法均未找到', { md5 }) - return undefined - } - - /** - * 将文件转换为 data URL - */ - private fileToDataUrl(filePath: string, mimeType: string): string | undefined { - try { - if (!existsSync(filePath)) return undefined - const buffer = readFileSync(filePath) - return `data:${mimeType};base64,${buffer.toString('base64')}` - } catch { - return undefined - } - } - - /** - * 根据视频MD5获取视频文件信息 - * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ - * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg - */ - async getVideoInfo(videoMd5: string): Promise { - const dbPath = this.getDbPath() - const wxid = this.getMyWxid() - - this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid }) - - if (!dbPath || !wxid || !videoMd5) { - this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 }) - return { exists: false } - } - - // 先尝试从数据库查询真正的视频文件名 - const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 - this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 }) - - // 检查 dbPath 是否已经包含 wxid,避免重复拼接 - const dbPathLower = dbPath.toLowerCase() - const wxidLower = wxid.toLowerCase() - const cleanedWxid = this.cleanWxid(wxid) - - let videoBaseDir: string - if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) { - videoBaseDir = join(dbPath, 'msg', 'video') + const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid) + for (const p of encryptedDbPaths) { + if (!existsSync(p) || unresolvedSet.size === 0) continue + const unresolved = Array.from(unresolvedSet) + const requests = unresolved.map((md5) => ({ md5, dbPath: p })) + try { + const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests) + if (batchResult.success && Array.isArray(batchResult.rows)) { + for (const row of batchResult.rows) { + const index = Number.isFinite(Number(row?.index)) ? Math.floor(Number(row?.index)) : -1 + const inputMd5 = index >= 0 && index < requests.length + ? requests[index].md5 + : String(row?.md5 || '').trim().toLowerCase() + if (!inputMd5) continue + const resolvedMd5 = row?.success && row?.data?.resolved_md5 + ? String(row.data.resolved_md5).trim().toLowerCase() + : '' + if (!resolvedMd5) continue + const cacheKey = `${scopeKey}|${inputMd5}` + this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries) + resolvedMap.set(inputMd5, resolvedMd5) + unresolvedSet.delete(inputMd5) + } } else { - videoBaseDir = join(dbPath, wxid, 'msg', 'video') + // 兼容不支持批量接口的版本,回退单条请求。 + for (const req of requests) { + try { + const single = await wcdbService.resolveVideoHardlinkMd5(req.md5, req.dbPath) + const resolvedMd5 = single.success && single.data?.resolved_md5 + ? String(single.data.resolved_md5).trim().toLowerCase() + : '' + if (!resolvedMd5) continue + const cacheKey = `${scopeKey}|${req.md5}` + this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries) + resolvedMap.set(req.md5, resolvedMd5) + unresolvedSet.delete(req.md5) + } catch { } + } } - - this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) }) - - if (!existsSync(videoBaseDir)) { - this.log('getVideoInfo: videoBaseDir 不存在') - return { exists: false } - } - - // 遍历年月目录查找视频文件 - try { - const allDirs = readdirSync(videoBaseDir) - const yearMonthDirs = allDirs - .filter(dir => { - const dirPath = join(videoBaseDir, dir) - return statSync(dirPath).isDirectory() - }) - .sort((a, b) => b.localeCompare(a)) - - this.log('扫描目录', { dirs: yearMonthDirs }) - - for (const yearMonth of yearMonthDirs) { - const dirPath = join(videoBaseDir, yearMonth) - const videoPath = join(dirPath, `${realVideoMd5}.mp4`) - - if (existsSync(videoPath)) { - // 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw,但封面不带) - const baseMd5 = realVideoMd5.replace(/_raw$/, '') - const coverPath = join(dirPath, `${baseMd5}.jpg`) - const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`) - - // 列出同目录下与该 md5 相关的所有文件,帮助排查封面命名 - const allFiles = readdirSync(dirPath) - const relatedFiles = allFiles.filter(f => f.toLowerCase().startsWith(realVideoMd5.slice(0, 8).toLowerCase())) - this.log('找到视频,相关文件列表', { - videoPath, - coverExists: existsSync(coverPath), - thumbExists: existsSync(thumbPath), - relatedFiles, - coverPath, - thumbPath - }) - - return { - videoUrl: videoPath, - coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), - thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), - exists: true - } - } - } - - // 没找到,列出所有目录里的 mp4 文件帮助排查(最多每目录 10 个) - this.log('未找到视频,开始全目录扫描', { - lookingForOriginal: `${videoMd5}.mp4`, - lookingForResolved: `${realVideoMd5}.mp4`, - hardlinkResolved: realVideoMd5 !== videoMd5 - }) - for (const yearMonth of yearMonthDirs) { - const dirPath = join(videoBaseDir, yearMonth) - try { - const allFiles = readdirSync(dirPath) - const mp4Files = allFiles.filter(f => f.endsWith('.mp4')).slice(0, 10) - // 检查原始 md5 是否部分匹配(前8位) - const partialMatch = mp4Files.filter(f => f.toLowerCase().startsWith(videoMd5.slice(0, 8).toLowerCase())) - this.log(`目录 ${yearMonth} 扫描结果`, { - totalFiles: allFiles.length, - mp4Count: allFiles.filter(f => f.endsWith('.mp4')).length, - sampleMp4: mp4Files, - partialMatchByOriginalMd5: partialMatch - }) - } catch (e) { - this.log(`目录 ${yearMonth} 读取失败`, { error: String(e) }) - } - } - } catch (e) { - this.log('getVideoInfo 遍历出错', { error: String(e) }) - } - - this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 }) - return { exists: false } + } catch (e) { + this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) }) + } } - /** - * 根据消息内容解析视频MD5 - */ - parseVideoMd5(content: string): string | undefined { - if (!content) return undefined + for (const md5 of unresolvedSet) { + const cacheKey = `${scopeKey}|${md5}` + this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries) + } - // 打印原始 XML 前 800 字符,帮助排查自己发的视频结构 - this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) }) + return resolvedMap + } + private async queryVideoFileName(md5: string): Promise { + const normalizedMd5 = String(md5 || '').trim().toLowerCase() + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + const cleanedWxid = this.cleanWxid(wxid) + + this.log('queryVideoFileName 开始', { md5: normalizedMd5, wxid, cleanedWxid, dbPath }) + + if (!normalizedMd5 || !wxid || !dbPath) { + this.log('queryVideoFileName: 参数缺失', { hasMd5: !!normalizedMd5, hasWxid: !!wxid, hasDbPath: !!dbPath }) + return undefined + } + + const resolvedMap = await this.resolveVideoHardlinks([normalizedMd5], dbPath, wxid, cleanedWxid) + const resolved = resolvedMap.get(normalizedMd5) + if (resolved) { + this.log('queryVideoFileName 命中', { input: normalizedMd5, resolved }) + return resolved + } + return undefined + } + + async preloadVideoHardlinkMd5s(md5List: string[]): Promise { + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + const cleanedWxid = this.cleanWxid(wxid) + if (!dbPath || !wxid) return + await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid) + } + + /** + * 将文件转换为 data URL + */ + private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined { + try { + if (!filePath || !existsSync(filePath)) return undefined + const buffer = readFileSync(filePath) + return `data:${mimeType};base64,${buffer.toString('base64')}` + } catch { + return undefined + } + } + + private getOrBuildVideoIndex(videoBaseDir: string): Map { + const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir) + if (cached) return cached + + const index = new Map() + const ensureEntry = (key: string): VideoIndexEntry => { + let entry = index.get(key) + if (!entry) { + entry = {} + index.set(key, entry) + } + return entry + } + + try { + const yearMonthDirs = readdirSync(videoBaseDir) + .filter((dir) => { + const dirPath = join(videoBaseDir, dir) + try { + return statSync(dirPath).isDirectory() + } catch { + return false + } + }) + .sort((a, b) => b.localeCompare(a)) + + for (const yearMonth of yearMonthDirs) { + const dirPath = join(videoBaseDir, yearMonth) + let files: string[] = [] try { - // 收集所有 md5 相关属性,方便对比 - const allMd5Attrs: string[] = [] - const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi - let match - while ((match = md5Regex.exec(content)) !== null) { - allMd5Attrs.push(match[0]) - } - this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs }) - - // 方法1:从 提取(收到的视频) - const videoMsgMd5Match = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (videoMsgMd5Match) { - this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] }) - return videoMsgMd5Match[1].toLowerCase() - } - - // 方法2:从 提取(自己发的视频,没有 md5 只有 rawmd5) - const rawMd5Match = /]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (rawMd5Match) { - this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] }) - return rawMd5Match[1].toLowerCase() - } - - // 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等) - const attrMatch = /(?... 标签 - const md5TagMatch = /([a-fA-F0-9]+)<\/md5>/i.exec(content) - if (md5TagMatch) { - this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] }) - return md5TagMatch[1].toLowerCase() - } - - // 方法5:兜底取 rawmd5 属性(任意位置) - const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (rawMd5Fallback) { - this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] }) - return rawMd5Fallback[1].toLowerCase() - } - - this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length }) - } catch (e) { - this.log('parseVideoMd5 异常', { error: String(e) }) + files = readdirSync(dirPath) + } catch { + continue } - return undefined + for (const file of files) { + const lower = file.toLowerCase() + const fullPath = join(dirPath, file) + + if (lower.endsWith('.mp4')) { + const md5 = lower.slice(0, -4) + const entry = ensureEntry(md5) + if (!entry.videoPath) entry.videoPath = fullPath + if (md5.endsWith('_raw')) { + const baseMd5 = md5.replace(/_raw$/, '') + const baseEntry = ensureEntry(baseMd5) + if (!baseEntry.videoPath) baseEntry.videoPath = fullPath + } + continue + } + + if (!lower.endsWith('.jpg')) continue + const jpgBase = lower.slice(0, -4) + if (jpgBase.endsWith('_thumb')) { + const baseMd5 = jpgBase.slice(0, -6) + const entry = ensureEntry(baseMd5) + if (!entry.thumbPath) entry.thumbPath = fullPath + } else { + const entry = ensureEntry(jpgBase) + if (!entry.coverPath) entry.coverPath = fullPath + } + } + } + + for (const [key, entry] of index) { + if (!key.endsWith('_raw')) continue + const baseKey = key.replace(/_raw$/, '') + const baseEntry = index.get(baseKey) + if (!baseEntry) continue + if (!entry.coverPath) entry.coverPath = baseEntry.coverPath + if (!entry.thumbPath) entry.thumbPath = baseEntry.thumbPath + } + } catch (e) { + this.log('构建视频索引失败', { videoBaseDir, error: String(e) }) } + + this.writeTimedCache( + this.videoDirIndexCache, + videoBaseDir, + index, + this.videoIndexCacheTtlMs, + this.maxIndexEntries + ) + return index + } + + private getVideoInfoFromIndex(index: Map, md5: string, includePoster = true): VideoInfo | null { + const normalizedMd5 = String(md5 || '').trim().toLowerCase() + if (!normalizedMd5) return null + + const candidates = [normalizedMd5] + const baseMd5 = normalizedMd5.replace(/_raw$/, '') + if (baseMd5 !== normalizedMd5) { + candidates.push(baseMd5) + } else { + candidates.push(`${normalizedMd5}_raw`) + } + + for (const key of candidates) { + const entry = index.get(key) + if (!entry?.videoPath) continue + if (!existsSync(entry.videoPath)) continue + if (!includePoster) { + return { + videoUrl: entry.videoPath, + exists: true + } + } + return { + videoUrl: entry.videoPath, + coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'), + thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'), + exists: true + } + } + + return null + } + + private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null { + try { + const yearMonthDirs = readdirSync(videoBaseDir) + .filter((dir) => { + const dirPath = join(videoBaseDir, dir) + try { + return statSync(dirPath).isDirectory() + } catch { + return false + } + }) + .sort((a, b) => b.localeCompare(a)) + + for (const yearMonth of yearMonthDirs) { + const dirPath = join(videoBaseDir, yearMonth) + const videoPath = join(dirPath, `${realVideoMd5}.mp4`) + if (!existsSync(videoPath)) continue + if (!includePoster) { + return { + videoUrl: videoPath, + exists: true + } + } + const baseMd5 = realVideoMd5.replace(/_raw$/, '') + const coverPath = join(dirPath, `${baseMd5}.jpg`) + const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`) + return { + videoUrl: videoPath, + coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), + thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), + exists: true + } + } + } catch (e) { + this.log('fallback 扫描视频目录失败', { error: String(e) }) + } + return null + } + + /** + * 根据视频MD5获取视频文件信息 + * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ + * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg + */ + async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise { + const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase() + const includePoster = options?.includePoster !== false + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + + this.log('getVideoInfo 开始', { videoMd5: normalizedMd5, dbPath, wxid }) + + if (!dbPath || !wxid || !normalizedMd5) { + this.log('getVideoInfo: 参数缺失', { hasDbPath: !!dbPath, hasWxid: !!wxid, hasVideoMd5: !!normalizedMd5 }) + return { exists: false } + } + + const scopeKey = this.getScopeKey(dbPath, wxid) + const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}` + + const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey) + if (cachedInfo) return cachedInfo + + const pending = this.pendingVideoInfo.get(cacheKey) + if (pending) return pending + + const task = (async (): Promise => { + const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5 + const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid) + + if (!existsSync(videoBaseDir)) { + const miss = { exists: false } + this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries) + return miss + } + + const index = this.getOrBuildVideoIndex(videoBaseDir) + const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster) + if (indexed) { + this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries) + return indexed + } + + const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster) + if (fallback) { + this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries) + return fallback + } + + const miss = { exists: false } + this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries) + this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 }) + return miss + })() + + this.pendingVideoInfo.set(cacheKey, task) + try { + return await task + } finally { + this.pendingVideoInfo.delete(cacheKey) + } + } + + /** + * 根据消息内容解析视频MD5 + */ + parseVideoMd5(content: string): string | undefined { + if (!content) return undefined + + // 打印原始 XML 前 800 字符,帮助排查自己发的视频结构 + this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) }) + + try { + // 收集所有 md5 相关属性,方便对比 + const allMd5Attrs: string[] = [] + const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi + let match + while ((match = md5Regex.exec(content)) !== null) { + allMd5Attrs.push(match[0]) + } + this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs }) + + // 方法1:从 提取(收到的视频) + const videoMsgMd5Match = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (videoMsgMd5Match) { + this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] }) + return videoMsgMd5Match[1].toLowerCase() + } + + // 方法2:从 提取(自己发的视频,没有 md5 只有 rawmd5) + const rawMd5Match = /]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (rawMd5Match) { + this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] }) + return rawMd5Match[1].toLowerCase() + } + + // 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等) + const attrMatch = /(?... 标签 + const md5TagMatch = /([a-fA-F0-9]+)<\/md5>/i.exec(content) + if (md5TagMatch) { + this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] }) + return md5TagMatch[1].toLowerCase() + } + + // 方法5:兜底取 rawmd5 属性(任意位置) + const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (rawMd5Fallback) { + this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] }) + return rawMd5Fallback[1].toLowerCase() + } + + this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length }) + } catch (e) { + this.log('parseVideoMd5 异常', { error: String(e) }) + } + + return undefined + } } export const videoService = new VideoService() diff --git a/electron/services/voiceTranscribeService.ts b/electron/services/voiceTranscribeService.ts index 5ff3d84..107cbc5 100644 --- a/electron/services/voiceTranscribeService.ts +++ b/electron/services/voiceTranscribeService.ts @@ -48,6 +48,38 @@ export class VoiceTranscribeService { private recognizer: OfflineRecognizer | null = null private isInitializing = false + private buildTranscribeWorkerEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env } + const platform = process.platform === 'win32' ? 'win' : process.platform + const platformPkg = `sherpa-onnx-${platform}-${process.arch}` + const candidates = [ + join(__dirname, '..', 'node_modules', platformPkg), + join(__dirname, 'node_modules', platformPkg), + join(process.cwd(), 'node_modules', platformPkg), + process.resourcesPath ? join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : '' + ].filter((item): item is string => Boolean(item) && existsSync(item)) + + if (process.platform === 'darwin') { + const key = 'DYLD_LIBRARY_PATH' + const existing = env[key] || '' + const merged = [...candidates, ...existing.split(':').filter(Boolean)] + env[key] = Array.from(new Set(merged)).join(':') + if (candidates.length === 0) { + console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`) + } + } else if (process.platform === 'linux') { + const key = 'LD_LIBRARY_PATH' + const existing = env[key] || '' + const merged = [...candidates, ...existing.split(':').filter(Boolean)] + env[key] = Array.from(new Set(merged)).join(':') + if (candidates.length === 0) { + console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`) + } + } + + return env + } + private resolveModelDir(): string { const configured = this.configService.get('whisperModelDir') as string | undefined if (configured) return configured @@ -206,17 +238,20 @@ export class VoiceTranscribeService { } } - const { Worker } = require('worker_threads') + const { fork } = require('child_process') const workerPath = join(__dirname, 'transcribeWorker.js') - const worker = new Worker(workerPath, { - workerData: { - modelPath, - tokensPath, - wavData, - sampleRate: 16000, - languages: supportedLanguages - } + const worker = fork(workerPath, [], { + env: this.buildTranscribeWorkerEnv(), + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + serialization: 'advanced' + }) + worker.send({ + modelPath, + tokensPath, + wavData, + sampleRate: 16000, + languages: supportedLanguages }) let finalTranscript = '' @@ -227,17 +262,31 @@ export class VoiceTranscribeService { } else if (msg.type === 'final') { finalTranscript = msg.text resolve({ success: true, transcript: finalTranscript }) - worker.terminate() + worker.disconnect() + worker.kill() } else if (msg.type === 'error') { console.error('[VoiceTranscribe] Worker 错误:', msg.error) resolve({ success: false, error: msg.error }) - worker.terminate() + worker.disconnect() + worker.kill() } }) worker.on('error', (err: Error) => resolve({ success: false, error: String(err) })) - worker.on('exit', (code: number) => { - if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` }) + worker.on('exit', (code: number | null, signal: string | null) => { + if (code === null || signal === 'SIGSEGV') { + + console.error(`[VoiceTranscribe] Worker 异常崩溃,信号: ${signal}。可能是由于底层 C++ 运行库在当前系统上发生段错误。`); + resolve({ + success: false, + error: 'SEGFAULT_ERROR' + }); + return; + } + + if (code !== 0) { + resolve({ success: false, error: `Worker exited with code ${code}` }); + } }) } catch (error) { diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 1c47b4c..9bb2a9d 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -1,50 +1,10 @@ -import { join, dirname, basename } from 'path' +import { join, dirname, basename } from 'path' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' +import { tmpdir } from 'os' // DLL 初始化错误信息,用于帮助用户诊断问题 let lastDllInitError: string | null = null -/** - * 解析 extra_buffer(protobuf)中的免打扰状态 - * - field 12 (tag 0x60): 值非0 = 免打扰 - * 折叠状态通过 contact.flag & 0x10000000 判断 - */ -function parseExtraBuffer(raw: Buffer | string | null | undefined): { isMuted: boolean } { - if (!raw) return { isMuted: false } - // execQuery 返回的 BLOB 列是十六进制字符串,需要先解码 - const buf: Buffer = typeof raw === 'string' ? Buffer.from(raw, 'hex') : raw - if (buf.length === 0) return { isMuted: false } - let isMuted = false - let i = 0 - const len = buf.length - - const readVarint = (): number => { - let result = 0, shift = 0 - while (i < len) { - const b = buf[i++] - result |= (b & 0x7f) << shift - shift += 7 - if (!(b & 0x80)) break - } - return result - } - - while (i < len) { - const tag = readVarint() - const fieldNum = tag >>> 3 - const wireType = tag & 0x07 - if (wireType === 0) { - const val = readVarint() - if (fieldNum === 12 && val !== 0) isMuted = true - } else if (wireType === 2) { - const sz = readVarint() - i += sz - } else if (wireType === 5) { i += 4 - } else if (wireType === 1) { i += 8 - } else { break } - } - return { isMuted } -} export function getLastDllInitError(): string | null { return lastDllInitError } @@ -60,6 +20,7 @@ export class WcdbCore { private currentPath: string | null = null private currentKey: string | null = null private currentWxid: string | null = null + private currentDbStoragePath: string | null = null // 函数引用 private wcdbInitProtection: any = null @@ -84,6 +45,11 @@ export class WcdbCore { private wcdbGetMessageMeta: any = null private wcdbGetContact: any = null private wcdbGetContactStatus: any = null + private wcdbGetContactTypeCounts: any = null + private wcdbGetContactsCompact: any = null + private wcdbGetContactAliasMap: any = null + private wcdbGetContactFriendFlags: any = null + private wcdbGetChatRoomExtBuffer: any = null private wcdbGetMessageTableStats: any = null private wcdbGetAggregateStats: any = null private wcdbGetAvailableYears: any = null @@ -102,10 +68,30 @@ export class WcdbCore { private wcdbListMediaDbs: any = null private wcdbGetMessageById: any = null private wcdbGetEmoticonCdnUrl: any = null + private wcdbGetEmoticonCaption: any = null + private wcdbGetEmoticonCaptionStrict: any = null private wcdbGetDbStatus: any = null private wcdbGetVoiceData: any = null + private wcdbGetVoiceDataBatch: any = null + private wcdbGetMediaSchemaSummary: any = null + private wcdbGetSessionMessageCounts: any = null + private wcdbGetSessionMessageTypeStats: any = null + private wcdbGetSessionMessageTypeStatsBatch: any = null + private wcdbGetSessionMessageDateCounts: any = null + private wcdbGetSessionMessageDateCountsBatch: any = null + private wcdbGetMessagesByType: any = null + private wcdbGetHeadImageBuffers: any = null + private wcdbSearchMessages: any = null private wcdbGetSnsTimeline: any = null private wcdbGetSnsAnnualStats: any = null + private wcdbGetSnsUsernames: any = null + private wcdbGetSnsExportStats: any = null + private wcdbGetMessageTableColumns: any = null + private wcdbGetMessageTableTimeRange: any = null + private wcdbResolveImageHardlink: any = null + private wcdbResolveImageHardlinkBatch: any = null + private wcdbResolveVideoHardlinkMd5: any = null + private wcdbResolveVideoHardlinkMd5Batch: any = null private wcdbInstallSnsBlockDeleteTrigger: any = null private wcdbUninstallSnsBlockDeleteTrigger: any = null private wcdbCheckSnsBlockDeleteTrigger: any = null @@ -114,6 +100,9 @@ export class WcdbCore { private wcdbStartMonitorPipe: any = null private wcdbStopMonitorPipe: any = null private wcdbGetMonitorPipeName: any = null + private wcdbCloudInit: any = null + private wcdbCloudReport: any = null + private wcdbCloudStop: any = null private monitorPipeClient: any = null private monitorCallback: ((type: string, json: string) => void) | null = null @@ -123,16 +112,27 @@ export class WcdbCore { private avatarUrlCache: Map = new Map() private readonly avatarCacheTtlMs = 10 * 60 * 1000 + private imageHardlinkCache: Map = new Map() + private videoHardlinkCache: Map = new Map() + private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 + private readonly hardlinkCacheMaxEntries = 20000 private logTimer: NodeJS.Timeout | null = null private lastLogTail: string | null = null + private lastResolvedLogPath: string | null = null setPaths(resourcesPath: string, userDataPath: string): void { this.resourcesPath = resourcesPath this.userDataPath = userDataPath + this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true) + } + + getLastInitError(): string | null { + return lastDllInitError } setLogEnabled(enabled: boolean): void { this.logEnabled = enabled + this.writeLog(`[bootstrap] setLogEnabled=${enabled ? '1' : '0'} env.WCDB_LOG_ENABLED=${process.env.WCDB_LOG_ENABLED || ''}`, true) if (this.isLogEnabled() && this.initialized) { this.startLogPolling() } else { @@ -140,7 +140,7 @@ export class WcdbCore { } } - // 使用命名管道 IPC + // 使用命名管道/socket IPC (Windows: Named Pipe, macOS: Unix Socket) startMonitor(callback: (type: string, json: string) => void): boolean { if (!this.wcdbStartMonitorPipe) { return false @@ -165,7 +165,6 @@ export class WcdbCore { } } catch {} } - this.connectMonitorPipe(pipePath) return true } catch (e) { @@ -182,13 +181,18 @@ export class WcdbCore { setTimeout(() => { if (!this.monitorCallback) return - this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => { - }) + this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {}) let buffer = '' this.monitorPipeClient.on('data', (data: Buffer) => { - buffer += data.toString('utf8') - const lines = buffer.split('\n') + const rawChunk = data.toString('utf8') + // macOS 侧可能使用 '\0' 或无换行分隔,统一归一化并兜底拆包 + const normalizedChunk = rawChunk + .replace(/\u0000/g, '\n') + .replace(/}\s*{/g, '}\n{') + + buffer += normalizedChunk + const lines = buffer.split(/\r?\n/) buffer = lines.pop() || '' for (const line of lines) { if (line.trim()) { @@ -200,9 +204,22 @@ export class WcdbCore { } } } + + // 兜底:如果没有分隔符但已形成完整 JSON,则直接上报 + const tail = buffer.trim() + if (tail.startsWith('{') && tail.endsWith('}')) { + try { + const parsed = JSON.parse(tail) + this.monitorCallback?.(parsed.action || 'update', tail) + buffer = '' + } catch { + // 不可解析则继续等待下一块数据 + } + } }) this.monitorPipeClient.on('error', () => { + // 保持静默,与现有错误处理策略一致 }) this.monitorPipeClient.on('close', () => { @@ -248,9 +265,15 @@ export class WcdbCore { /** - * 获取 DLL 路径 + * 获取库文件路径(跨平台) */ private getDllPath(): string { + const isMac = process.platform === 'darwin' + const isLinux = process.platform === 'linux' + const isArm64 = process.arch === 'arm64' + const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll' + const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '') + const envDllPath = process.env.WCDB_DLL_PATH if (envDllPath && envDllPath.length > 0) { return envDllPath @@ -262,22 +285,26 @@ export class WcdbCore { const candidates = [ // 环境变量指定 resource 目录 - process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, 'wcdb_api.dll') : null, + process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, subDir, libName) : null, // 显式 setPaths 设置的路径 - this.resourcesPath ? join(this.resourcesPath, 'wcdb_api.dll') : null, - // text/resources/wcdb_api.dll (打包常见结构) - join(resourcesPath, 'resources', 'wcdb_api.dll'), - // items/resourcesPath/wcdb_api.dll (扁平结构) - join(resourcesPath, 'wcdb_api.dll'), + this.resourcesPath ? join(this.resourcesPath, subDir, libName) : null, + // resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll + join(resourcesPath, 'resources', subDir, libName), + // resources/libwcdb_api.dylib 或 resources/wcdb_api.dll (扁平结构) + join(resourcesPath, subDir, libName), // CWD fallback - join(process.cwd(), 'resources', 'wcdb_api.dll') + join(process.cwd(), 'resources', subDir, libName) ].filter(Boolean) as string[] for (const path of candidates) { if (existsSync(path)) return path } - return candidates[0] || 'wcdb_api.dll' + return candidates[0] || libName + } + + private formatInitProtectionError(code: number): string { + return `错误码: ${code}` } private isLogEnabled(): boolean { @@ -289,14 +316,97 @@ export class WcdbCore { private writeLog(message: string, force = false): void { if (!force && !this.isLogEnabled()) return const line = `[${new Date().toISOString()}] ${message}` - // 同时输出到控制台和文件 + const candidates: string[] = [] + if (this.userDataPath) candidates.push(join(this.userDataPath, 'logs', 'wcdb.log')) + if (process.env.WCDB_LOG_DIR) candidates.push(join(process.env.WCDB_LOG_DIR, 'logs', 'wcdb.log')) + candidates.push(join(process.cwd(), 'logs', 'wcdb.log')) + candidates.push(join(tmpdir(), 'weflow-wcdb.log')) + + const uniq = Array.from(new Set(candidates)) + for (const filePath of uniq) { + try { + const dir = dirname(filePath) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + appendFileSync(filePath, line + '\n', { encoding: 'utf8' }) + this.lastResolvedLogPath = filePath + return + } catch (e) { + console.error(`[wcdbCore] writeLog failed path=${filePath}:`, e) + } + } + + console.error('[wcdbCore] writeLog failed for all candidates:', uniq.join(' | ')) + } + + private formatSqlForLog(sql: string, maxLen = 240): string { + const compact = String(sql || '').replace(/\s+/g, ' ').trim() + if (compact.length <= maxLen) return compact + return compact.slice(0, maxLen) + '...' + } + + private async dumpDbStatus(tag: string): Promise { try { - const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd() - const dir = join(base, 'logs') - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - appendFileSync(join(dir, 'wcdb.log'), line + '\n', { encoding: 'utf8' }) - } catch { } + if (!this.ensureReady()) { + this.writeLog(`[diag:${tag}] db_status skipped: not connected`, true) + return + } + if (!this.wcdbGetDbStatus) { + this.writeLog(`[diag:${tag}] db_status skipped: api not supported`, true) + return + } + const outPtr = [null as any] + const rc = this.wcdbGetDbStatus(this.handle, outPtr) + if (rc !== 0 || !outPtr[0]) { + this.writeLog(`[diag:${tag}] db_status failed rc=${rc} outPtr=${outPtr[0] ? 'set' : 'null'}`, true) + return + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) { + this.writeLog(`[diag:${tag}] db_status decode failed`, true) + return + } + this.writeLog(`[diag:${tag}] db_status=${jsonStr}`, true) + } catch (e) { + this.writeLog(`[diag:${tag}] db_status exception: ${String(e)}`, true) + } + } + + private async runPostOpenDiagnostics(dbPath: string, dbStoragePath: string | null, sessionDbPath: string | null, wxid: string): Promise { + try { + this.writeLog(`[diag:open] input dbPath=${dbPath} wxid=${wxid}`, true) + this.writeLog(`[diag:open] resolved dbStorage=${dbStoragePath || 'null'}`, true) + this.writeLog(`[diag:open] resolved sessionDb=${sessionDbPath || 'null'}`, true) + if (!dbStoragePath) return + try { + const entries = readdirSync(dbStoragePath) + const sample = entries.slice(0, 20).join(',') + this.writeLog(`[diag:open] dbStorage entries(${entries.length}) sample=${sample}`, true) + } catch (e) { + this.writeLog(`[diag:open] list dbStorage failed: ${String(e)}`, true) + } + + const contactProbe = await this.execQuery( + 'contact', + null, + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name LIMIT 50" + ) + if (contactProbe.success) { + const names = (contactProbe.rows || []).map((r: any) => String(r?.name || '')).filter(Boolean) + this.writeLog(`[diag:open] contact sqlite_master rows=${names.length} names=${names.join(',')}`, true) + } else { + this.writeLog(`[diag:open] contact sqlite_master failed: ${contactProbe.error || 'unknown'}`, true) + } + + const contactCount = await this.execQuery('contact', null, 'SELECT COUNT(1) AS cnt FROM contact') + if (contactCount.success && Array.isArray(contactCount.rows) && contactCount.rows.length > 0) { + this.writeLog(`[diag:open] contact count=${String((contactCount.rows[0] as any)?.cnt ?? '')}`, true) + } else { + this.writeLog(`[diag:open] contact count failed: ${contactCount.error || 'unknown'}`, true) + } + } catch (e) { + this.writeLog(`[diag:open] post-open diagnostics exception: ${String(e)}`, true) + } } /** @@ -373,6 +483,93 @@ export class WcdbCore { return null } + private isRealDbFileName(name: string): boolean { + const lower = String(name || '').toLowerCase() + if (!lower.endsWith('.db')) return false + if (lower.endsWith('.db-shm')) return false + if (lower.endsWith('.db-wal')) return false + if (lower.endsWith('.db-journal')) return false + return true + } + + private resolveContactDbPath(): string | null { + const dbStorage = this.currentDbStoragePath || this.resolveDbStoragePath(this.currentPath || '', this.currentWxid || '') + if (!dbStorage) return null + const contactDir = join(dbStorage, 'Contact') + if (!existsSync(contactDir)) return null + + const preferred = [ + join(contactDir, 'contact.db'), + join(contactDir, 'Contact.db') + ] + for (const p of preferred) { + if (existsSync(p)) return p + } + + try { + const entries = readdirSync(contactDir) + const cands = entries + .filter((name) => this.isRealDbFileName(name)) + .map((name) => join(contactDir, name)) + if (cands.length > 0) return cands[0] + } catch { } + return null + } + + private pickFirstStringField(row: Record, candidates: string[]): string { + for (const key of candidates) { + const v = row[key] + if (typeof v === 'string' && v.trim()) return v + if (v !== null && v !== undefined) { + const s = String(v).trim() + if (s) return s + } + } + return '' + } + + private escapeSqlString(value: string): string { + return String(value || '').replace(/'/g, "''") + } + + private buildContactSelectSql(usernames: string[] = []): string { + const uniq = Array.from(new Set((usernames || []).map((item) => String(item || '').trim()).filter(Boolean))) + if (uniq.length === 0) return 'SELECT * FROM contact' + const inList = uniq.map((username) => `'${this.escapeSqlString(username)}'`).join(',') + return `SELECT * FROM contact WHERE username IN (${inList})` + } + + private deriveContactTypeCounts(rows: Array>): { private: number; group: number; official: number; former_friend: number } { + const counts = { + private: 0, + group: 0, + official: 0, + former_friend: 0 + } + const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) + + for (const row of rows || []) { + const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName']) + if (!username) continue + + const localTypeRaw = row.local_type ?? row.localType ?? row.WCDB_CT_local_type ?? 0 + const localType = Number.isFinite(Number(localTypeRaw)) ? Math.floor(Number(localTypeRaw)) : 0 + const quanPin = this.pickFirstStringField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) + + if (username.endsWith('@chatroom')) { + counts.group += 1 + } else if (username.startsWith('gh_')) { + counts.official += 1 + } else if (localType === 1 && !excludeNames.has(username)) { + counts.private += 1 + } else if (localType === 0 && quanPin) { + counts.former_friend += 1 + } + } + + return counts + } + /** * 初始化 WCDB */ @@ -382,69 +579,108 @@ export class WcdbCore { try { this.koffi = require('koffi') const dllPath = this.getDllPath() + this.writeLog(`[bootstrap] initialize platform=${process.platform} dllPath=${dllPath} resourcesPath=${this.resourcesPath || ''} userDataPath=${this.userDataPath || ''}`, true) if (!existsSync(dllPath)) { console.error('WCDB DLL 不存在:', dllPath) + this.writeLog(`[bootstrap] initialize failed: dll not found path=${dllPath}`, true) return false } const dllDir = dirname(dllPath) - const wcdbCorePath = join(dllDir, 'WCDB.dll') - if (existsSync(wcdbCorePath)) { - try { - this.koffi.load(wcdbCorePath) - this.writeLog('预加载 WCDB.dll 成功') - } catch (e) { - console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e) - this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`) + const isMac = process.platform === 'darwin' + const isLinux = process.platform === 'linux' + + // 预加载依赖库 + if (isMac) { + const wcdbCorePath = join(dllDir, 'libWCDB.dylib') + if (existsSync(wcdbCorePath)) { + try { + this.koffi.load(wcdbCorePath) + this.writeLog('预加载 libWCDB.dylib 成功') + } catch (e) { + console.warn('预加载 libWCDB.dylib 失败(可能不是致命的):', e) + this.writeLog(`预加载 libWCDB.dylib 失败: ${String(e)}`) + } } - } - const sdl2Path = join(dllDir, 'SDL2.dll') - if (existsSync(sdl2Path)) { - try { - this.koffi.load(sdl2Path) - this.writeLog('预加载 SDL2.dll 成功') - } catch (e) { - console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e) - this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`) + } else if (isLinux) { + // 如果有libWCDB.so的话, 没有就算了 + } else { + const wcdbCorePath = join(dllDir, 'WCDB.dll') + if (existsSync(wcdbCorePath)) { + try { + this.koffi.load(wcdbCorePath) + this.writeLog('预加载 WCDB.dll 成功') + } catch (e) { + console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e) + this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`) + } + } + const sdl2Path = join(dllDir, 'SDL2.dll') + if (existsSync(sdl2Path)) { + try { + this.koffi.load(sdl2Path) + this.writeLog('预加载 SDL2.dll 成功') + } catch (e) { + console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e) + this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`) + } } } + this.writeLog(`[bootstrap] koffi.load begin path=${dllPath}`, true) this.lib = this.koffi.load(dllPath) + this.writeLog('[bootstrap] koffi.load ok', true) // InitProtection (Added for security) try { - this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)') + this.wcdbInitProtection = this.lib.func('int32 InitProtection(const char* resourcePath)') // 尝试多个可能的资源路径 const resourcePaths = [ dllDir, // DLL 所在目录 dirname(dllDir), // 上级目录 + process.resourcesPath, // 打包后 Contents/Resources + process.resourcesPath ? join(process.resourcesPath as string, 'resources') : null, // Contents/Resources/resources this.resourcesPath, // 配置的资源路径 join(process.cwd(), 'resources') // 开发环境 ].filter(Boolean) let protectionOk = false + let protectionCode = -1 + let bestFailCode: number | null = null + const scoreFailCode = (code: number): number => { + if (code >= -2212 && code <= -2201) return 0 // manifest/signature/hash failures + if (code === -102 || code === -101 || code === -1006) return 1 + return 2 + } for (const resPath of resourcePaths) { try { - // - protectionOk = this.wcdbInitProtection(resPath) - if (protectionOk) { - // + this.writeLog(`[bootstrap] InitProtection call path=${resPath}`, true) + protectionCode = Number(this.wcdbInitProtection(resPath)) + if (protectionCode === 0) { + protectionOk = true break } + if (bestFailCode === null || scoreFailCode(protectionCode) < scoreFailCode(bestFailCode)) { + bestFailCode = protectionCode + } + this.writeLog(`[bootstrap] InitProtection rc=${protectionCode} path=${resPath}`, true) } catch (e) { - // console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e) + this.writeLog(`[bootstrap] InitProtection exception path=${resPath}: ${String(e)}`, true) } } if (!protectionOk) { - // console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定') - // this.writeLog('InitProtection 失败,继续运行') - // 不返回 false,允许继续运行 + const finalCode = bestFailCode ?? protectionCode + lastDllInitError = this.formatInitProtectionError(finalCode) + this.writeLog(`[bootstrap] InitProtection failed finalCode=${finalCode}`, true) + return false } } catch (e) { - // console.warn('InitProtection symbol not found:', e) + lastDllInitError = this.formatInitProtectionError(-2301) + this.writeLog(`[bootstrap] InitProtection symbol load failed: ${String(e)}`, true) + return false } // 定义类型 @@ -537,6 +773,32 @@ export class WcdbCore { this.wcdbGetContactStatus = null } + try { + this.wcdbGetContactTypeCounts = this.lib.func('int32 wcdb_get_contact_type_counts(int64 handle, _Out_ void** outJson)') + } catch { + this.wcdbGetContactTypeCounts = null + } + try { + this.wcdbGetContactsCompact = this.lib.func('int32 wcdb_get_contacts_compact(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + } catch { + this.wcdbGetContactsCompact = null + } + try { + this.wcdbGetContactAliasMap = this.lib.func('int32 wcdb_get_contact_alias_map(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + } catch { + this.wcdbGetContactAliasMap = null + } + try { + this.wcdbGetContactFriendFlags = this.lib.func('int32 wcdb_get_contact_friend_flags(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + } catch { + this.wcdbGetContactFriendFlags = null + } + try { + this.wcdbGetChatRoomExtBuffer = this.lib.func('int32 wcdb_get_chat_room_ext_buffer(int64 handle, const char* chatroomId, _Out_ void** outJson)') + } catch { + this.wcdbGetChatRoomExtBuffer = null + } + // wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json) this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)') @@ -617,6 +879,22 @@ export class WcdbCore { // wcdb_status wcdb_get_emoticon_cdn_url(wcdb_handle handle, const char* db_path, const char* md5, char** out_url) this.wcdbGetEmoticonCdnUrl = this.lib.func('int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)') + // wcdb_status wcdb_get_emoticon_caption(wcdb_handle handle, const char* db_path, const char* md5, char** out_caption) + try { + this.wcdbGetEmoticonCaption = this.lib.func('int32 wcdb_get_emoticon_caption(int64 handle, const char* dbPath, const char* md5, _Out_ void** outCaption)') + } catch (e) { + this.wcdbGetEmoticonCaption = null + this.writeLog(`[diag:emoji] symbol missing wcdb_get_emoticon_caption: ${String(e)}`, true) + } + + // wcdb_status wcdb_get_emoticon_caption_strict(wcdb_handle handle, const char* md5, char** out_caption) + try { + this.wcdbGetEmoticonCaptionStrict = this.lib.func('int32 wcdb_get_emoticon_caption_strict(int64 handle, const char* md5, _Out_ void** outCaption)') + } catch (e) { + this.wcdbGetEmoticonCaptionStrict = null + this.writeLog(`[diag:emoji] symbol missing wcdb_get_emoticon_caption_strict: ${String(e)}`, true) + } + // wcdb_status wcdb_list_message_dbs(wcdb_handle handle, char** out_json) this.wcdbListMessageDbs = this.lib.func('int32 wcdb_list_message_dbs(int64 handle, _Out_ void** outJson)') @@ -639,6 +917,58 @@ export class WcdbCore { } catch { this.wcdbGetVoiceData = null } + try { + this.wcdbGetVoiceDataBatch = this.lib.func('int32 wcdb_get_voice_data_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetVoiceDataBatch = null + } + try { + this.wcdbGetMediaSchemaSummary = this.lib.func('int32 wcdb_get_media_schema_summary(int64 handle, const char* dbPath, _Out_ void** outJson)') + } catch { + this.wcdbGetMediaSchemaSummary = null + } + try { + this.wcdbGetSessionMessageCounts = this.lib.func('int32 wcdb_get_session_message_counts(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageCounts = null + } + try { + this.wcdbGetSessionMessageTypeStats = this.lib.func('int32 wcdb_get_session_message_type_stats(int64 handle, const char* sessionId, int32 beginTimestamp, int32 endTimestamp, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageTypeStats = null + } + try { + this.wcdbGetSessionMessageTypeStatsBatch = this.lib.func('int32 wcdb_get_session_message_type_stats_batch(int64 handle, const char* sessionIdsJson, const char* optionsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageTypeStatsBatch = null + } + try { + this.wcdbGetSessionMessageDateCounts = this.lib.func('int32 wcdb_get_session_message_date_counts(int64 handle, const char* sessionId, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageDateCounts = null + } + try { + this.wcdbGetSessionMessageDateCountsBatch = this.lib.func('int32 wcdb_get_session_message_date_counts_batch(int64 handle, const char* sessionIdsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetSessionMessageDateCountsBatch = null + } + try { + this.wcdbGetMessagesByType = this.lib.func('int32 wcdb_get_messages_by_type(int64 handle, const char* sessionId, int64 localType, int32 ascending, int32 limit, int32 offset, _Out_ void** outJson)') + } catch { + this.wcdbGetMessagesByType = null + } + try { + this.wcdbGetHeadImageBuffers = this.lib.func('int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + } catch { + this.wcdbGetHeadImageBuffers = null + } + + // wcdb_status wcdb_search_messages(wcdb_handle handle, const char* session_id, const char* keyword, int32_t limit, int32_t offset, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) + try { + this.wcdbSearchMessages = this.lib.func('int32 wcdb_search_messages(int64 handle, const char* sessionId, const char* keyword, int32 limit, int32 offset, int32 beginTimestamp, int32 endTimestamp, _Out_ void** outJson)') + } catch { + this.wcdbSearchMessages = null + } // wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json) try { @@ -653,6 +983,46 @@ export class WcdbCore { } catch { this.wcdbGetSnsAnnualStats = null } + try { + this.wcdbGetSnsUsernames = this.lib.func('int32 wcdb_get_sns_usernames(int64 handle, _Out_ void** outJson)') + } catch { + this.wcdbGetSnsUsernames = null + } + try { + this.wcdbGetSnsExportStats = this.lib.func('int32 wcdb_get_sns_export_stats(int64 handle, const char* myWxid, _Out_ void** outJson)') + } catch { + this.wcdbGetSnsExportStats = null + } + try { + this.wcdbGetMessageTableColumns = this.lib.func('int32 wcdb_get_message_table_columns(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)') + } catch { + this.wcdbGetMessageTableColumns = null + } + try { + this.wcdbGetMessageTableTimeRange = this.lib.func('int32 wcdb_get_message_table_time_range(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)') + } catch { + this.wcdbGetMessageTableTimeRange = null + } + try { + this.wcdbResolveImageHardlink = this.lib.func('int32 wcdb_resolve_image_hardlink(int64 handle, const char* md5, const char* accountDir, _Out_ void** outJson)') + } catch { + this.wcdbResolveImageHardlink = null + } + try { + this.wcdbResolveImageHardlinkBatch = this.lib.func('int32 wcdb_resolve_image_hardlink_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)') + } catch { + this.wcdbResolveImageHardlinkBatch = null + } + try { + this.wcdbResolveVideoHardlinkMd5 = this.lib.func('int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)') + } catch { + this.wcdbResolveVideoHardlinkMd5 = null + } + try { + this.wcdbResolveVideoHardlinkMd5Batch = this.lib.func('int32 wcdb_resolve_video_hardlink_md5_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)') + } catch { + this.wcdbResolveVideoHardlinkMd5Batch = null + } // wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error) try { @@ -702,12 +1072,33 @@ export class WcdbCore { this.wcdbVerifyUser = null } + // wcdb_status wcdb_cloud_init(int32_t interval_seconds) + try { + this.wcdbCloudInit = this.lib.func('int32 wcdb_cloud_init(int32 intervalSeconds)') + } catch { + this.wcdbCloudInit = null + } + + // wcdb_status wcdb_cloud_report(const char* stats_json) + try { + this.wcdbCloudReport = this.lib.func('int32 wcdb_cloud_report(const char* statsJson)') + } catch { + this.wcdbCloudReport = null + } + + // void wcdb_cloud_stop() + try { + this.wcdbCloudStop = this.lib.func('void wcdb_cloud_stop()') + } catch { + this.wcdbCloudStop = null + } // 初始化 const initResult = this.wcdbInit() if (initResult !== 0) { console.error('WCDB 初始化失败:', initResult) + lastDllInitError = this.formatInitProtectionError(initResult) return false } @@ -718,14 +1109,7 @@ export class WcdbCore { const errorMsg = e instanceof Error ? e.message : String(e) console.error('WCDB 初始化异常:', errorMsg) this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true) - lastDllInitError = errorMsg - // 检查是否是常见的 VC++ 运行时缺失错误 - if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') || - errorMsg.includes('The specified module could not be found')) { - lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。' - } else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) { - lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。' - } + lastDllInitError = this.formatInitProtectionError(-2302) return false } } @@ -752,8 +1136,7 @@ export class WcdbCore { if (!this.initialized) { const initOk = await this.initialize() if (!initOk) { - // 返回更详细的错误信息,帮助用户诊断问题 - const detailedError = lastDllInitError || 'WCDB 初始化失败' + const detailedError = lastDllInitError || this.formatInitProtectionError(-2303) return { success: false, error: detailedError } } } @@ -763,7 +1146,7 @@ export class WcdbCore { this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`) if (!dbStoragePath || !existsSync(dbStoragePath)) { - return { success: false, error: `数据库目录不存在: ${dbPath}` } + return { success: false, error: this.formatInitProtectionError(-3001) } } // 递归查找 session.db @@ -771,7 +1154,7 @@ export class WcdbCore { this.writeLog(`testConnection sessionDb=${sessionDbPath || 'null'}`) if (!sessionDbPath) { - return { success: false, error: `未找到 session.db 文件` } + return { success: false, error: this.formatInitProtectionError(-3002) } } // 分配输出参数内存 @@ -780,17 +1163,13 @@ export class WcdbCore { if (result !== 0) { await this.printLogs() - let errorMsg = '数据库打开失败' - if (result === -1) errorMsg = '参数错误' - else if (result === -2) errorMsg = '密钥错误' - else if (result === -3) errorMsg = '数据库打开失败' this.writeLog(`testConnection openAccount failed code=${result}`) - return { success: false, error: `${errorMsg} (错误码: ${result})` } + return { success: false, error: this.formatInitProtectionError(result) } } const tempHandle = handleOut[0] if (tempHandle <= 0) { - return { success: false, error: '无效的数据库句柄' } + return { success: false, error: this.formatInitProtectionError(-3003) } } // 测试成功:使用 shutdown 清理资源(包括测试句柄) @@ -819,7 +1198,7 @@ export class WcdbCore { } catch (e) { console.error('测试连接异常:', e) this.writeLog(`testConnection exception: ${String(e)}`) - return { success: false, error: String(e) } + return { success: false, error: this.formatInitProtectionError(-3004) } } } @@ -901,6 +1280,21 @@ export class WcdbCore { } } + private parseMessageJson(jsonStr: string): any { + const raw = String(jsonStr || '') + if (!raw) return [] + // 热路径优化:仅在检测到 16+ 位整数字段时才进行字符串包裹,避免每批次多轮全量 replace。 + const needsInt64Normalize = /"(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*-?\d{16,}/.test(raw) + if (!needsInt64Normalize) { + return JSON.parse(raw) + } + const normalized = raw.replace( + /("(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*)(-?\d{16,})/g, + '$1"$2"' + ) + return JSON.parse(normalized) + } + private ensureReady(): boolean { return this.initialized && this.handle !== null } @@ -927,6 +1321,66 @@ export class WcdbCore { return { begin: normalizedBegin, end: normalizedEnd } } + private makeHardlinkCacheKey(primary: string, secondary?: string | null): string { + const a = String(primary || '').trim().toLowerCase() + const b = String(secondary || '').trim().toLowerCase() + return `${a}\u001f${b}` + } + + private readHardlinkCache( + cache: Map, + key: string + ): { success: boolean; data?: any; error?: string } | null { + const entry = cache.get(key) + if (!entry) return null + if (Date.now() - entry.updatedAt > this.hardlinkCacheTtlMs) { + cache.delete(key) + return null + } + return this.cloneHardlinkResult(entry.result) + } + + private writeHardlinkCache( + cache: Map, + key: string, + result: { success: boolean; data?: any; error?: string } + ): void { + cache.set(key, { + result: this.cloneHardlinkResult(result), + updatedAt: Date.now() + }) + if (cache.size <= this.hardlinkCacheMaxEntries) return + + const now = Date.now() + for (const [cacheKey, entry] of cache) { + if (now - entry.updatedAt > this.hardlinkCacheTtlMs) { + cache.delete(cacheKey) + } + } + + while (cache.size > this.hardlinkCacheMaxEntries) { + const oldestKey = cache.keys().next().value as string | undefined + if (!oldestKey) break + cache.delete(oldestKey) + } + } + + private cloneHardlinkResult(result: { success: boolean; data?: any; error?: string }): { success: boolean; data?: any; error?: string } { + const data = result.data && typeof result.data === 'object' + ? { ...result.data } + : result.data + return { + success: result.success === true, + data, + error: result.error + } + } + + private clearHardlinkCaches(): void { + this.imageHardlinkCache.clear() + this.videoHardlinkCache.clear() + } + isReady(): boolean { return this.ensureReady() } @@ -936,6 +1390,7 @@ export class WcdbCore { */ async open(dbPath: string, hexKey: string, wxid: string): Promise { try { + lastDllInitError = null if (!this.initialized) { const initOk = await this.initialize() if (!initOk) return false @@ -958,19 +1413,21 @@ export class WcdbCore { } const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid) - this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`) + this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`, true) if (!dbStoragePath || !existsSync(dbStoragePath)) { console.error('数据库目录不存在:', dbPath) this.writeLog(`open failed: dbStorage not found for ${dbPath}`) + lastDllInitError = this.formatInitProtectionError(-3001) return false } const sessionDbPath = this.findSessionDb(dbStoragePath) - this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`) + this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`, true) if (!sessionDbPath) { console.error('未找到 session.db 文件') this.writeLog('open failed: session.db not found') + lastDllInitError = this.formatInitProtectionError(-3002) return false } @@ -981,11 +1438,13 @@ export class WcdbCore { console.error('打开数据库失败:', result) await this.printLogs() this.writeLog(`open failed: openAccount code=${result}`) + lastDllInitError = this.formatInitProtectionError(result) return false } const handle = handleOut[0] if (handle <= 0) { + lastDllInitError = this.formatInitProtectionError(-3003) return false } @@ -993,7 +1452,9 @@ export class WcdbCore { this.currentPath = dbPath this.currentKey = hexKey this.currentWxid = wxid + this.currentDbStoragePath = dbStoragePath this.initialized = true + lastDllInitError = null if (this.wcdbSetMyWxid && wxid) { try { this.wcdbSetMyWxid(this.handle, wxid) @@ -1004,11 +1465,14 @@ export class WcdbCore { if (this.isLogEnabled()) { this.startLogPolling() } - this.writeLog(`open ok handle=${handle}`) + this.writeLog(`open ok handle=${handle}`, true) + await this.dumpDbStatus('open') + await this.runPostOpenDiagnostics(dbPath, dbStoragePath, sessionDbPath, wxid) return true } catch (e) { console.error('打开数据库异常:', e) this.writeLog(`open exception: ${String(e)}`) + lastDllInitError = this.formatInitProtectionError(-3004) return false } } @@ -1029,7 +1493,9 @@ export class WcdbCore { this.currentPath = null this.currentKey = null this.currentWxid = null + this.currentDbStoragePath = null this.initialized = false + this.clearHardlinkCaches() this.stopLogPolling() } } @@ -1090,7 +1556,7 @@ export class WcdbCore { } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析消息失败' } - const messages = JSON.parse(jsonStr) + const messages = this.parseMessageJson(jsonStr) return { success: true, messages } } catch (e) { return { success: false, error: String(e) } @@ -1144,12 +1610,262 @@ export class WcdbCore { } } + async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + + const normalizedSessionIds = Array.from( + new Set( + (sessionIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedSessionIds.length === 0) { + return { success: true, counts: {} } + } + + try { + const counts: Record = {} + for (let i = 0; i < normalizedSessionIds.length; i += 1) { + const sessionId = normalizedSessionIds[i] + const outCount = [0] + const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount) + counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0 + + if (i > 0 && i % 160 === 0) { + await new Promise(resolve => setImmediate(resolve)) + } + } + return { success: true, counts } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSessionMessageCounts) return this.getMessageCounts(sessionIds) + try { + const outPtr = [null as any] + const result = this.wcdbGetSessionMessageCounts(this.handle, JSON.stringify(sessionIds || []), outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取会话消息总数失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析会话消息总数失败' } + const raw = JSON.parse(jsonStr) || {} + const counts: Record = {} + for (const sid of sessionIds || []) { + const value = Number(raw?.[sid] ?? 0) + counts[sid] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 + } + return { success: true, counts } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSessionMessageTypeStats( + sessionId: string, + beginTimestamp: number = 0, + endTimestamp: number = 0 + ): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSessionMessageTypeStats) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetSessionMessageTypeStats( + this.handle, + sessionId, + this.normalizeTimestamp(beginTimestamp), + this.normalizeTimestamp(endTimestamp), + outPtr + ) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取会话类型统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析会话类型统计失败' } + return { success: true, data: JSON.parse(jsonStr) || {} } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSessionMessageTypeStatsBatch( + sessionIds: string[], + options?: { + beginTimestamp?: number + endTimestamp?: number + quickMode?: boolean + includeGroupSenderCount?: boolean + } + ): Promise<{ success: boolean; data?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + const normalizedSessionIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + if (normalizedSessionIds.length === 0) return { success: true, data: {} } + + if (!this.wcdbGetSessionMessageTypeStatsBatch) { + const data: Record = {} + for (const sessionId of normalizedSessionIds) { + const single = await this.getSessionMessageTypeStats( + sessionId, + options?.beginTimestamp || 0, + options?.endTimestamp || 0 + ) + if (single.success) { + data[sessionId] = single.data || {} + } + } + return { success: true, data } + } + + try { + const outPtr = [null as any] + const optionsJson = JSON.stringify({ + begin: this.normalizeTimestamp(options?.beginTimestamp || 0), + end: this.normalizeTimestamp(options?.endTimestamp || 0), + quick_mode: options?.quickMode === true, + include_group_sender_count: options?.includeGroupSenderCount !== false + }) + const result = this.wcdbGetSessionMessageTypeStatsBatch( + this.handle, + JSON.stringify(normalizedSessionIds), + optionsJson, + outPtr + ) + if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取会话类型统计失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析批量会话类型统计失败' } + return { success: true, data: JSON.parse(jsonStr) || {} } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSessionMessageDateCounts) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetSessionMessageDateCounts(this.handle, sessionId, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取会话日消息统计失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析会话日消息统计失败' } + const raw = JSON.parse(jsonStr) || {} + const counts: Record = {} + for (const [dateKey, value] of Object.entries(raw)) { + const count = Number(value) + if (!dateKey || !Number.isFinite(count) || count <= 0) continue + counts[String(dateKey)] = Math.floor(count) + } + return { success: true, counts } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record>; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + const normalizedSessionIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + if (normalizedSessionIds.length === 0) return { success: true, data: {} } + + if (!this.wcdbGetSessionMessageDateCountsBatch) { + const data: Record> = {} + for (const sessionId of normalizedSessionIds) { + const single = await this.getSessionMessageDateCounts(sessionId) + data[sessionId] = single.success && single.counts ? single.counts : {} + } + return { success: true, data } + } + + try { + const outPtr = [null as any] + const result = this.wcdbGetSessionMessageDateCountsBatch(this.handle, JSON.stringify(normalizedSessionIds), outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取会话日消息统计失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析批量会话日消息统计失败' } + const raw = JSON.parse(jsonStr) || {} + const data: Record> = {} + for (const sessionId of normalizedSessionIds) { + const source = raw?.[sessionId] || {} + const next: Record = {} + for (const [dateKey, value] of Object.entries(source)) { + const count = Number(value) + if (!dateKey || !Number.isFinite(count) || count <= 0) continue + next[String(dateKey)] = Math.floor(count) + } + data[sessionId] = next + } + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessagesByType( + sessionId: string, + localType: number, + ascending = false, + limit = 0, + offset = 0 + ): Promise<{ success: boolean; rows?: any[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetMessagesByType) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessagesByType( + this.handle, + sessionId, + BigInt(localType), + ascending ? 1 : 0, + Math.max(0, Math.floor(limit || 0)), + Math.max(0, Math.floor(offset || 0)), + outPtr + ) + if (result !== 0 || !outPtr[0]) return { success: false, error: `按类型读取消息失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析按类型消息失败' } + const rows = JSON.parse(jsonStr) + return { success: true, rows: Array.isArray(rows) ? rows : [] } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } if (usernames.length === 0) return { success: true, map: {} } try { + if (process.platform === 'darwin') { + const uniq = Array.from(new Set(usernames.map((x) => String(x || '').trim()).filter(Boolean))) + if (uniq.length === 0) return { success: true, map: {} } + const inList = uniq.map((u) => `'${u.replace(/'/g, "''")}'`).join(',') + const sql = `SELECT * FROM contact WHERE username IN (${inList})` + const q = await this.execQuery('contact', null, sql) + if (!q.success) return { success: false, error: q.error || '获取昵称失败' } + const map: Record = {} + for (const row of (q.rows || []) as Array>) { + const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName']) + if (!username) continue + const display = this.pickFirstStringField(row, [ + 'remark', 'Remark', + 'nick_name', 'nickName', 'nickname', 'NickName', + 'alias', 'Alias' + ]) || username + map[username] = display + } + // 保证每个请求用户名至少有回退值 + for (const u of uniq) { + if (!map[u]) map[u] = u + } + return { success: true, map } + } + // 让出控制权,避免阻塞事件循环 await new Promise(resolve => setImmediate(resolve)) @@ -1198,11 +1914,48 @@ export class WcdbCore { return { success: true, map: resultMap } } + if (process.platform === 'darwin') { + const inList = toFetch.map((u) => `'${u.replace(/'/g, "''")}'`).join(',') + const sql = `SELECT * FROM contact WHERE username IN (${inList})` + const q = await this.execQuery('contact', null, sql) + if (!q.success) { + if (Object.keys(resultMap).length > 0) { + return { success: true, map: resultMap, error: q.error || '获取头像失败' } + } + return { success: false, error: q.error || '获取头像失败' } + } + + for (const row of (q.rows || []) as Array>) { + const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName']) + if (!username) continue + const url = this.pickFirstStringField(row, [ + 'big_head_img_url', 'bigHeadImgUrl', 'bigHeadUrl', 'big_head_url', + 'small_head_img_url', 'smallHeadImgUrl', 'smallHeadUrl', 'small_head_url', + 'head_img_url', 'headImgUrl', + 'avatar_url', 'avatarUrl' + ]) + if (url) { + resultMap[username] = url + this.avatarUrlCache.set(username, { url, updatedAt: now }) + } + } + return { success: true, map: resultMap } + } + // 让出控制权,避免阻塞事件循环 + const handle = this.handle await new Promise(resolve => setImmediate(resolve)) + // await 后 handle 可能已被关闭,需重新检查 + if (handle === null || this.handle !== handle) { + if (Object.keys(resultMap).length > 0) { + return { success: true, map: resultMap, error: '连接已断开' } + } + return { success: false, error: '连接已断开' } + } + const outPtr = [null as any] - const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr) + const result = this.wcdbGetAvatarUrls(handle, JSON.stringify(toFetch), outPtr) // DLL 调用后再次让出控制权 await new Promise(resolve => setImmediate(resolve)) @@ -1406,10 +2159,42 @@ export class WcdbCore { return { success: false, error: 'WCDB 未连接' } } try { + if (process.platform === 'darwin') { + const safe = String(username || '').replace(/'/g, "''") + const sql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1` + const q = await this.execQuery('contact', null, sql) + if (!q.success) { + return { success: false, error: q.error || '获取联系人失败' } + } + const row = Array.isArray(q.rows) && q.rows.length > 0 ? q.rows[0] : null + if (!row) { + return { success: false, error: `联系人不存在: ${username}` } + } + return { success: true, contact: row } + } + const outPtr = [null as any] const result = this.wcdbGetContact(this.handle, username, outPtr) if (result !== 0 || !outPtr[0]) { - return { success: false, error: `获取联系人失败: ${result}` } + this.writeLog(`[diag:getContact] primary api failed username=${username} code=${result} outPtr=${outPtr[0] ? 'set' : 'null'}`, true) + await this.dumpDbStatus('getContact-primary-fail') + await this.printLogs(true) + + // Fallback: 直接查询 contact 表,便于区分是接口失败还是 contact 库本身不可读。 + const safe = String(username || '').replace(/'/g, "''") + const fallbackSql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1` + const fallback = await this.execQuery('contact', null, fallbackSql) + if (fallback.success) { + const row = Array.isArray(fallback.rows) ? fallback.rows[0] : null + if (row) { + this.writeLog(`[diag:getContact] fallback sql hit username=${username}`, true) + return { success: true, contact: row } + } + this.writeLog(`[diag:getContact] fallback sql no row username=${username}`, true) + return { success: false, error: `联系人不存在: ${username}` } + } + this.writeLog(`[diag:getContact] fallback sql failed username=${username} err=${fallback.error || 'unknown'}`, true) + return { success: false, error: `获取联系人失败: ${result}; fallback=${fallback.error || 'unknown'}` } } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析联系人失败' } @@ -1424,24 +2209,25 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + if (!this.wcdbGetContactStatus) { + return { success: false, error: '接口未就绪' } + } try { - // 分批查询,避免 SQL 过长(execQuery 不支持参数绑定,直接拼 SQL) - const BATCH = 200 + const outPtr = [null as any] + const code = this.wcdbGetContactStatus(this.handle, JSON.stringify(usernames || []), outPtr) + if (code !== 0 || !outPtr[0]) { + return { success: false, error: `获取会话状态失败: ${code}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析会话状态失败' } + + const rawMap = JSON.parse(jsonStr) || {} const map: Record = {} - for (let i = 0; i < usernames.length; i += BATCH) { - const batch = usernames.slice(i, i + BATCH) - const inList = batch.map(u => `'${u.replace(/'/g, "''")}'`).join(',') - const sql = `SELECT username, flag, extra_buffer FROM contact WHERE username IN (${inList})` - const result = await this.execQuery('contact', null, sql) - if (!result.success || !result.rows) continue - for (const row of result.rows) { - const uname: string = row.username - // 折叠:flag bit 28 (0x10000000) - const flag = parseInt(row.flag ?? '0', 10) - const isFolded = (flag & 0x10000000) !== 0 - // 免打扰:extra_buffer field 12 非0 - const { isMuted } = parseExtraBuffer(row.extra_buffer) - map[uname] = { isFolded, isMuted } + for (const username of usernames || []) { + const state = rawMap[username] || {} + map[username] = { + isFolded: Boolean(state.isFolded), + isMuted: Boolean(state.isMuted) } } return { success: true, map } @@ -1450,6 +2236,148 @@ export class WcdbCore { } } + async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetMessageTableColumns) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessageTableColumns(this.handle, dbPath, tableName, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取消息表列失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息表列失败' } + const columns = JSON.parse(jsonStr) + return { success: true, columns: Array.isArray(columns) ? columns.map((c: any) => String(c || '')) : [] } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetMessageTableTimeRange) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetMessageTableTimeRange(this.handle, dbPath, tableName, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取消息表时间范围失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析消息表时间范围失败' } + const data = JSON.parse(jsonStr) || {} + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + const runFallback = async (reason: string) => { + const contactsResult = await this.getContactsCompact() + if (!contactsResult.success || !Array.isArray(contactsResult.contacts)) { + return { success: false, error: `获取联系人分类统计失败: ${reason}; fallback=${contactsResult.error || 'unknown'}` } + } + const counts = this.deriveContactTypeCounts(contactsResult.contacts as Array>) + this.writeLog(`[diag:getContactTypeCounts] fallback reason=${reason} private=${counts.private} group=${counts.group} official=${counts.official} former_friend=${counts.former_friend}`, true) + return { success: true, counts } + } + + if (!this.wcdbGetContactTypeCounts) return await runFallback('api_missing') + try { + const outPtr = [null as any] + const code = this.wcdbGetContactTypeCounts(this.handle, outPtr) + if (code !== 0 || !outPtr[0]) return await runFallback(`code=${code}`) + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return await runFallback('decode_empty') + const raw = JSON.parse(jsonStr) || {} + return { + success: true, + counts: { + private: Number(raw.private || 0), + group: Number(raw.group || 0), + official: Number(raw.official || 0), + former_friend: Number(raw.former_friend || 0) + } + } + } catch (e) { + return await runFallback(`exception=${String(e)}`) + } + } + + async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + const runFallback = async (reason: string) => { + const fallback = await this.execQuery('contact', null, this.buildContactSelectSql(usernames)) + if (!fallback.success) { + return { success: false, error: `获取联系人列表失败: ${reason}; fallback=${fallback.error || 'unknown'}` } + } + const rows = Array.isArray(fallback.rows) ? fallback.rows : [] + this.writeLog(`[diag:getContactsCompact] fallback reason=${reason} usernames=${Array.isArray(usernames) ? usernames.length : 0} rows=${rows.length}`, true) + return { success: true, contacts: rows } + } + + if (!this.wcdbGetContactsCompact) return await runFallback('api_missing') + try { + const outPtr = [null as any] + const payload = Array.isArray(usernames) && usernames.length > 0 ? JSON.stringify(usernames) : null + const code = this.wcdbGetContactsCompact(this.handle, payload, outPtr) + if (code !== 0 || !outPtr[0]) return await runFallback(`code=${code}`) + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return await runFallback('decode_empty') + const contacts = JSON.parse(jsonStr) + return { success: true, contacts: Array.isArray(contacts) ? contacts : [] } + } catch (e) { + return await runFallback(`exception=${String(e)}`) + } + } + + async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetContactAliasMap) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const code = this.wcdbGetContactAliasMap(this.handle, JSON.stringify(usernames || []), outPtr) + if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人 alias 失败: ${code}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析联系人 alias 失败' } + const map = JSON.parse(jsonStr) + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetContactFriendFlags) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const code = this.wcdbGetContactFriendFlags(this.handle, JSON.stringify(usernames || []), outPtr) + if (code !== 0 || !outPtr[0]) return { success: false, error: `获取联系人好友标记失败: ${code}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析联系人好友标记失败' } + const map = JSON.parse(jsonStr) + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetChatRoomExtBuffer) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const code = this.wcdbGetChatRoomExtBuffer(this.handle, chatroomId, outPtr) + if (code !== 0 || !outPtr[0]) return { success: false, error: `获取群聊 ext_buffer 失败: ${code}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析群聊 ext_buffer 失败' } + const data = JSON.parse(jsonStr) || {} + const extBuffer = String(data.ext_buffer || '').trim() + return { success: true, extBuffer: extBuffer || undefined } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } @@ -1693,7 +2621,7 @@ export class WcdbCore { } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析批次失败' } - const rows = JSON.parse(jsonStr) + const rows = this.parseMessageJson(jsonStr) return { success: true, rows, hasMore: outHasMore[0] === 1 } } catch (e) { return { success: false, error: String(e) } @@ -1736,8 +2664,11 @@ export class WcdbCore { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } } + const startedAt = Date.now() try { if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' } + const fallbackFlag = /fallback|diag|diagnostic/i.test(String(sql || '')) + this.writeLog(`[audit:execQuery] kind=${kind} path=${path || ''} sql_len=${String(sql || '').length} fallback=${fallbackFlag ? 1 : 0}`) // 如果提供了参数,使用参数化查询(需要 C++ 层支持) // 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现 @@ -1746,16 +2677,45 @@ export class WcdbCore { console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)') } + const normalizedKind = String(kind || '').toLowerCase() + const isContactQuery = normalizedKind === 'contact' || /\bfrom\s+contact\b/i.test(String(sql)) + let effectivePath = path || '' + if (normalizedKind === 'contact' && !effectivePath) { + const resolvedContactDb = this.resolveContactDbPath() + if (resolvedContactDb) { + effectivePath = resolvedContactDb + this.writeLog(`[diag:execQuery] contact path override -> ${effectivePath}`, true) + } else { + this.writeLog('[diag:execQuery] contact path override miss: Contact/contact.db not found', true) + } + } + const outPtr = [null as any] - const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr) + const result = this.wcdbExecQuery(this.handle, kind, effectivePath, sql, outPtr) if (result !== 0 || !outPtr[0]) { + if (isContactQuery) { + this.writeLog(`[diag:execQuery] contact query failed code=${result} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true) + await this.dumpDbStatus('execQuery-contact-fail') + await this.printLogs(true) + } return { success: false, error: `执行查询失败: ${result}` } } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析查询结果失败' } const rows = JSON.parse(jsonStr) + this.writeLog(`[audit:execQuery] done kind=${kind} cost_ms=${Date.now() - startedAt} rows=${Array.isArray(rows) ? rows.length : -1}`) + if (isContactQuery) { + const count = Array.isArray(rows) ? rows.length : -1 + this.writeLog(`[diag:execQuery] contact query ok rows=${count} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true) + } return { success: true, rows } } catch (e) { + this.writeLog(`[audit:execQuery] fail kind=${kind} cost_ms=${Date.now() - startedAt} err=${String(e)}`) + const isContactQuery = String(kind).toLowerCase() === 'contact' || /\bfrom\s+contact\b/i.test(String(sql)) + if (isContactQuery) { + this.writeLog(`[diag:execQuery] contact query exception kind=${kind} path=${path || ''} sql="${this.formatSqlForLog(sql)}" err=${String(e)}`, true) + await this.dumpDbStatus('execQuery-contact-exception') + } return { success: false, error: String(e) } } } @@ -1778,6 +2738,48 @@ export class WcdbCore { } } + async getEmoticonCaption(dbPath: string, md5: string): Promise<{ success: boolean; caption?: string; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbGetEmoticonCaption) { + return { success: false, error: '接口未就绪: wcdb_get_emoticon_caption' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetEmoticonCaption(this.handle, dbPath || '', md5, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取表情释义失败: ${result}` } + } + const captionStr = this.decodeJsonPtr(outPtr[0]) + if (captionStr === null) return { success: false, error: '解析表情释义失败' } + return { success: true, caption: captionStr || undefined } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbGetEmoticonCaptionStrict) { + return { success: false, error: '接口未就绪: wcdb_get_emoticon_caption_strict' } + } + try { + const outPtr = [null as any] + const result = this.wcdbGetEmoticonCaptionStrict(this.handle, md5, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取表情释义失败(strict): ${result}` } + } + const captionStr = this.decodeJsonPtr(outPtr[0]) + if (captionStr === null) return { success: false, error: '解析表情释义失败(strict)' } + return { success: true, caption: captionStr || undefined } + } catch (e) { + return { success: false, error: String(e) } + } + } + async listMessageDbs(): Promise<{ success: boolean; data?: string[]; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } try { @@ -1814,7 +2816,7 @@ export class WcdbCore { if (result !== 0 || !outPtr[0]) return { success: false, error: `查询消息失败: ${result}` } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析消息失败' } - const message = JSON.parse(jsonStr) + const message = this.parseMessageJson(jsonStr) // 处理 wcdb_get_message_by_id 返回空对象的情况 if (Object.keys(message).length === 0) return { success: false, error: '未找到消息' } return { success: true, message } @@ -1840,9 +2842,373 @@ export class WcdbCore { } } + async getVoiceDataBatch( + requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetVoiceDataBatch) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const payload = JSON.stringify(Array.isArray(requests) ? requests : []) + const result = this.wcdbGetVoiceDataBatch(this.handle, payload, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `批量获取语音数据失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析批量语音数据失败' } + const rows = JSON.parse(jsonStr) + const normalized = Array.isArray(rows) ? rows.map((row: any) => ({ + index: Number(row?.index ?? 0), + hex: row?.hex ? String(row.hex) : undefined + })) : [] + return { success: true, rows: normalized } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetMediaSchemaSummary) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetMediaSchemaSummary(this.handle, dbPath, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取媒体表结构摘要失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析媒体表结构摘要失败' } + const data = JSON.parse(jsonStr) || {} + return { success: true, data } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetHeadImageBuffers) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetHeadImageBuffers(this.handle, JSON.stringify(usernames || []), outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取头像二进制失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析头像二进制失败' } + const map = JSON.parse(jsonStr) || {} + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbResolveImageHardlink) return { success: false, error: '接口未就绪' } + try { + const normalizedMd5 = String(md5 || '').trim().toLowerCase() + const normalizedAccountDir = String(accountDir || '').trim() + if (!normalizedMd5) return { success: false, error: 'md5 为空' } + const cacheKey = this.makeHardlinkCacheKey(normalizedMd5, normalizedAccountDir) + const cached = this.readHardlinkCache(this.imageHardlinkCache, cacheKey) + if (cached) return cached + + const outPtr = [null as any] + const result = this.wcdbResolveImageHardlink(this.handle, normalizedMd5, normalizedAccountDir || null, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `解析图片 hardlink 失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析图片 hardlink 响应失败' } + const data = JSON.parse(jsonStr) || {} + const finalResult = { success: true, data } + this.writeHardlinkCache(this.imageHardlinkCache, cacheKey, finalResult) + return finalResult + } catch (e) { + return { success: false, error: String(e) } + } + } + + async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbResolveVideoHardlinkMd5) return { success: false, error: '接口未就绪' } + try { + const normalizedMd5 = String(md5 || '').trim().toLowerCase() + const normalizedDbPath = String(dbPath || '').trim() + if (!normalizedMd5) return { success: false, error: 'md5 为空' } + const cacheKey = this.makeHardlinkCacheKey(normalizedMd5, normalizedDbPath) + const cached = this.readHardlinkCache(this.videoHardlinkCache, cacheKey) + if (cached) return cached + + const outPtr = [null as any] + const result = this.wcdbResolveVideoHardlinkMd5(this.handle, normalizedMd5, normalizedDbPath || null, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `解析视频 hardlink 失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析视频 hardlink 响应失败' } + const data = JSON.parse(jsonStr) || {} + const finalResult = { success: true, data } + this.writeHardlinkCache(this.videoHardlinkCache, cacheKey, finalResult) + return finalResult + } catch (e) { + return { success: false, error: String(e) } + } + } + + async resolveImageHardlinkBatch( + requests: Array<{ md5: string; accountDir?: string }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' } + try { + const normalizedRequests = requests.map((req) => ({ + md5: String(req?.md5 || '').trim().toLowerCase(), + accountDir: String(req?.accountDir || '').trim() + })) + const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = new Array(normalizedRequests.length) + const unresolved: Array<{ index: number; md5: string; accountDir: string }> = [] + + for (let i = 0; i < normalizedRequests.length; i += 1) { + const req = normalizedRequests[i] + if (!req.md5) { + rows[i] = { index: i, md5: '', success: false, error: 'md5 为空' } + continue + } + const cacheKey = this.makeHardlinkCacheKey(req.md5, req.accountDir) + const cached = this.readHardlinkCache(this.imageHardlinkCache, cacheKey) + if (cached) { + rows[i] = { + index: i, + md5: req.md5, + success: cached.success === true, + data: cached.data, + error: cached.error + } + } else { + unresolved.push({ index: i, md5: req.md5, accountDir: req.accountDir }) + } + } + + if (unresolved.length === 0) { + return { success: true, rows } + } + + if (this.wcdbResolveImageHardlinkBatch) { + try { + const outPtr = [null as any] + const payload = JSON.stringify(unresolved.map((req) => ({ + md5: req.md5, + account_dir: req.accountDir || undefined + }))) + const result = this.wcdbResolveImageHardlinkBatch(this.handle, payload, outPtr) + if (result === 0 && outPtr[0]) { + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (jsonStr) { + const nativeRows = JSON.parse(jsonStr) + const mappedRows = Array.isArray(nativeRows) ? nativeRows.map((row: any, index: number) => { + const rowIndexRaw = Number(row?.index) + const rowIndex = Number.isFinite(rowIndexRaw) ? Math.floor(rowIndexRaw) : index + const fallbackReq = rowIndex >= 0 && rowIndex < unresolved.length ? unresolved[rowIndex] : { md5: '', accountDir: '', index: -1 } + const rowMd5 = String(row?.md5 || fallbackReq.md5 || '').trim().toLowerCase() + const success = row?.success === true || row?.success === 1 || row?.success === '1' + const data = row?.data && typeof row.data === 'object' ? row.data : {} + const error = row?.error ? String(row.error) : undefined + if (success && rowMd5) { + const cacheKey = this.makeHardlinkCacheKey(rowMd5, fallbackReq.accountDir) + this.writeHardlinkCache(this.imageHardlinkCache, cacheKey, { success: true, data }) + } + return { + index: rowIndex, + md5: rowMd5, + success, + data, + error + } + }) : [] + for (const row of mappedRows) { + const fallbackReq = row.index >= 0 && row.index < unresolved.length ? unresolved[row.index] : null + if (!fallbackReq) continue + rows[fallbackReq.index] = { + index: fallbackReq.index, + md5: row.md5 || fallbackReq.md5, + success: row.success, + data: row.data, + error: row.error + } + } + } + } + } catch { + // 回退到单条循环实现 + } + } + + for (const req of unresolved) { + if (rows[req.index]) continue + const result = await this.resolveImageHardlink(req.md5, req.accountDir) + rows[req.index] = { + index: req.index, + md5: req.md5, + success: result.success === true, + data: result.data, + error: result.error + } + } + return { success: true, rows } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async resolveVideoHardlinkMd5Batch( + requests: Array<{ md5: string; dbPath?: string }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' } + try { + const normalizedRequests = requests.map((req) => ({ + md5: String(req?.md5 || '').trim().toLowerCase(), + dbPath: String(req?.dbPath || '').trim() + })) + const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = new Array(normalizedRequests.length) + const unresolved: Array<{ index: number; md5: string; dbPath: string }> = [] + + for (let i = 0; i < normalizedRequests.length; i += 1) { + const req = normalizedRequests[i] + if (!req.md5) { + rows[i] = { index: i, md5: '', success: false, error: 'md5 为空' } + continue + } + const cacheKey = this.makeHardlinkCacheKey(req.md5, req.dbPath) + const cached = this.readHardlinkCache(this.videoHardlinkCache, cacheKey) + if (cached) { + rows[i] = { + index: i, + md5: req.md5, + success: cached.success === true, + data: cached.data, + error: cached.error + } + } else { + unresolved.push({ index: i, md5: req.md5, dbPath: req.dbPath }) + } + } + + if (unresolved.length === 0) { + return { success: true, rows } + } + + if (this.wcdbResolveVideoHardlinkMd5Batch) { + try { + const outPtr = [null as any] + const payload = JSON.stringify(unresolved.map((req) => ({ + md5: req.md5, + db_path: req.dbPath || undefined + }))) + const result = this.wcdbResolveVideoHardlinkMd5Batch(this.handle, payload, outPtr) + if (result === 0 && outPtr[0]) { + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (jsonStr) { + const nativeRows = JSON.parse(jsonStr) + const mappedRows = Array.isArray(nativeRows) ? nativeRows.map((row: any, index: number) => { + const rowIndexRaw = Number(row?.index) + const rowIndex = Number.isFinite(rowIndexRaw) ? Math.floor(rowIndexRaw) : index + const fallbackReq = rowIndex >= 0 && rowIndex < unresolved.length ? unresolved[rowIndex] : { md5: '', dbPath: '', index: -1 } + const rowMd5 = String(row?.md5 || fallbackReq.md5 || '').trim().toLowerCase() + const success = row?.success === true || row?.success === 1 || row?.success === '1' + const data = row?.data && typeof row.data === 'object' ? row.data : {} + const error = row?.error ? String(row.error) : undefined + if (success && rowMd5) { + const cacheKey = this.makeHardlinkCacheKey(rowMd5, fallbackReq.dbPath) + this.writeHardlinkCache(this.videoHardlinkCache, cacheKey, { success: true, data }) + } + return { + index: rowIndex, + md5: rowMd5, + success, + data, + error + } + }) : [] + for (const row of mappedRows) { + const fallbackReq = row.index >= 0 && row.index < unresolved.length ? unresolved[row.index] : null + if (!fallbackReq) continue + rows[fallbackReq.index] = { + index: fallbackReq.index, + md5: row.md5 || fallbackReq.md5, + success: row.success, + data: row.data, + error: row.error + } + } + } + } + } catch { + // 回退到单条循环实现 + } + } + + for (const req of unresolved) { + if (rows[req.index]) continue + const result = await this.resolveVideoHardlinkMd5(req.md5, req.dbPath) + rows[req.index] = { + index: req.index, + md5: req.md5, + success: result.success === true, + data: result.data, + error: result.error + } + } + return { success: true, rows } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** - * 验证 Windows Hello + * 数据收集初始化 */ + async cloudInit(intervalSeconds: number = 600): Promise<{ success: boolean; error?: string }> { + if (!this.initialized) { + const initOk = await this.initialize() + if (!initOk) return { success: false, error: 'WCDB init failed' } + } + if (!this.wcdbCloudInit) { + return { success: false, error: 'Cloud init API not supported by DLL' } + } + try { + const result = this.wcdbCloudInit(intervalSeconds) + if (result !== 0) { + return { success: false, error: `Cloud init failed: ${result}` } + } + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> { + if (!this.initialized) { + const initOk = await this.initialize() + if (!initOk) return { success: false, error: 'WCDB init failed' } + } + if (!this.wcdbCloudReport) { + return { success: false, error: 'Cloud report API not supported by DLL' } + } + try { + const result = this.wcdbCloudReport(statsJson || '') + if (result !== 0) { + return { success: false, error: `Cloud report failed: ${result}` } + } + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + + cloudStop(): { success: boolean; error?: string } { + if (!this.wcdbCloudStop) { + return { success: false, error: 'Cloud stop API not supported by DLL' } + } + try { + this.wcdbCloudStop() + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> { if (!this.initialized) { const initOk = await this.initialize() @@ -1873,6 +3239,36 @@ export class WcdbCore { }) } + async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbSearchMessages) return { success: false, error: '当前 DLL 版本不支持搜索消息' } + try { + const handle = this.handle + await new Promise(resolve => setImmediate(resolve)) + if (handle === null || this.handle !== handle) return { success: false, error: '连接已断开' } + const outPtr = [null as any] + const result = this.wcdbSearchMessages( + handle, + sessionId || '', + keyword, + limit || 50, + offset || 0, + beginTimestamp || 0, + endTimestamp || 0, + outPtr + ) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `搜索消息失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析搜索结果失败' } + const messages = this.parseMessageJson(jsonStr) + return { success: true, messages } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' } @@ -1925,6 +3321,45 @@ export class WcdbCore { return { success: false, error: String(e) } } } + + async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSnsUsernames) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetSnsUsernames(this.handle, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取朋友圈用户名失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析朋友圈用户名失败' } + const usernames = JSON.parse(jsonStr) + return { success: true, usernames: Array.isArray(usernames) ? usernames.map((u: any) => String(u || '').trim()).filter(Boolean) : [] } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbGetSnsExportStats) return { success: false, error: '接口未就绪' } + try { + const outPtr = [null as any] + const result = this.wcdbGetSnsExportStats(this.handle, myWxid || null, outPtr) + if (result !== 0 || !outPtr[0]) return { success: false, error: `获取朋友圈导出统计失败: ${result}` } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) return { success: false, error: '解析朋友圈导出统计失败' } + const raw = JSON.parse(jsonStr) || {} + return { + success: true, + data: { + totalPosts: Number(raw.total_posts || 0), + totalFriends: Number(raw.total_friends || 0), + myPosts: raw.my_posts === null || raw.my_posts === undefined ? null : Number(raw.my_posts || 0) + } + } + } catch (e) { + return { success: false, error: String(e) } + } + } /** * 为朋友圈安装删除 */ @@ -2093,4 +3528,3 @@ export class WcdbCore { }) } } - diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index b8834f6..f52de6c 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -136,7 +136,7 @@ export class WcdbService { */ setMonitor(callback: (type: string, json: string) => void): void { this.monitorListener = callback; - this.callWorker('setMonitor').catch(() => { }); + this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { }); } /** @@ -164,6 +164,10 @@ export class WcdbService { return this.callWorker('open', { dbPath, hexKey, wxid }) } + async getLastInitError(): Promise { + return this.callWorker('getLastInitError') + } + /** * 关闭数据库连接 */ @@ -174,10 +178,10 @@ export class WcdbService { /** * 关闭服务 */ - shutdown(): void { - this.close() + async shutdown(): Promise { + try { await this.close() } catch {} if (this.worker) { - this.worker.terminate() + try { await this.worker.terminate() } catch {} this.worker = null } } @@ -218,6 +222,52 @@ export class WcdbService { return this.callWorker('getMessageCount', { sessionId }) } + async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record; error?: string }> { + return this.callWorker('getMessageCounts', { sessionIds }) + } + + async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record; error?: string }> { + return this.callWorker('getSessionMessageCounts', { sessionIds }) + } + + async getSessionMessageTypeStats( + sessionId: string, + beginTimestamp: number = 0, + endTimestamp: number = 0 + ): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('getSessionMessageTypeStats', { sessionId, beginTimestamp, endTimestamp }) + } + + async getSessionMessageTypeStatsBatch( + sessionIds: string[], + options?: { + beginTimestamp?: number + endTimestamp?: number + quickMode?: boolean + includeGroupSenderCount?: boolean + } + ): Promise<{ success: boolean; data?: Record; error?: string }> { + return this.callWorker('getSessionMessageTypeStatsBatch', { sessionIds, options }) + } + + async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record; error?: string }> { + return this.callWorker('getSessionMessageDateCounts', { sessionId }) + } + + async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record>; error?: string }> { + return this.callWorker('getSessionMessageDateCountsBatch', { sessionIds }) + } + + async getMessagesByType( + sessionId: string, + localType: number, + ascending = false, + limit = 0, + offset = 0 + ): Promise<{ success: boolean; rows?: any[]; error?: string }> { + return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset }) + } + /** * 获取联系人昵称 */ @@ -283,6 +333,14 @@ export class WcdbService { return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset }) } + async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> { + return this.callWorker('getMessageTableColumns', { dbPath, tableName }) + } + + async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('getMessageTableTimeRange', { dbPath, tableName }) + } + /** * 获取联系人详情 */ @@ -297,6 +355,26 @@ export class WcdbService { return this.callWorker('getContactStatus', { usernames }) } + async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> { + return this.callWorker('getContactTypeCounts') + } + + async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> { + return this.callWorker('getContactsCompact', { usernames }) + } + + async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + return this.callWorker('getContactAliasMap', { usernames }) + } + + async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + return this.callWorker('getContactFriendFlags', { usernames }) + } + + async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> { + return this.callWorker('getChatRoomExtBuffer', { chatroomId }) + } + /** * 获取聚合统计数据 */ @@ -368,7 +446,7 @@ export class WcdbService { } /** - * 执行 SQL 查询(支持参数化查询) + * 执行 SQL 查询(仅主进程内部使用:fallback/diagnostic/低频兼容) */ async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> { return this.callWorker('execQuery', { kind, path, sql, params }) @@ -381,6 +459,20 @@ export class WcdbService { return this.callWorker('getEmoticonCdnUrl', { dbPath, md5 }) } + /** + * 获取表情包释义 + */ + async getEmoticonCaption(dbPath: string, md5: string): Promise<{ success: boolean; caption?: string; error?: string }> { + return this.callWorker('getEmoticonCaption', { dbPath, md5 }) + } + + /** + * 获取表情包释义(严格 DLL 接口) + */ + async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> { + return this.callWorker('getEmoticonCaptionStrict', { md5 }) + } + /** * 列出消息数据库 */ @@ -402,6 +494,10 @@ export class WcdbService { return this.callWorker('getMessageById', { sessionId, localId }) } + async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> { + return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp }) + } + /** * 获取语音数据 */ @@ -409,6 +505,40 @@ export class WcdbService { return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId }) } + async getVoiceDataBatch( + requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> { + return this.callWorker('getVoiceDataBatch', { requests }) + } + + async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('getMediaSchemaSummary', { dbPath }) + } + + async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + return this.callWorker('getHeadImageBuffers', { usernames }) + } + + async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('resolveImageHardlink', { md5, accountDir }) + } + + async resolveImageHardlinkBatch( + requests: Array<{ md5: string; accountDir?: string }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { + return this.callWorker('resolveImageHardlinkBatch', { requests }) + } + + async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath }) + } + + async resolveVideoHardlinkMd5Batch( + requests: Array<{ md5: string; dbPath?: string }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { + return this.callWorker('resolveVideoHardlinkMd5Batch', { requests }) + } + /** * 获取朋友圈 */ @@ -423,6 +553,14 @@ export class WcdbService { return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp }) } + async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> { + return this.callWorker('getSnsUsernames') + } + + async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> { + return this.callWorker('getSnsExportStats', { myWxid }) + } + /** * 安装朋友圈删除拦截 */ @@ -479,6 +617,27 @@ export class WcdbService { return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint }) } + /** + * 数据收集:初始化 + */ + async cloudInit(intervalSeconds: number): Promise<{ success: boolean; error?: string }> { + return this.callWorker('cloudInit', { intervalSeconds }) + } + + /** + * 数据收集:上报数据 + */ + async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> { + return this.callWorker('cloudReport', { statsJson }) + } + + /** + * 数据收集:停止 + */ + cloudStop(): Promise<{ success: boolean; error?: string }> { + return this.callWorker('cloudStop', {}) + } + } diff --git a/electron/transcribeWorker.ts b/electron/transcribeWorker.ts index e5a18d1..847ed06 100644 --- a/electron/transcribeWorker.ts +++ b/electron/transcribeWorker.ts @@ -1,13 +1,56 @@ import { parentPort, workerData } from 'worker_threads' +import { existsSync } from 'fs' +import { join } from 'path' interface WorkerParams { modelPath: string tokensPath: string - wavData: Buffer + wavData: Buffer | Uint8Array | { type: 'Buffer'; data: number[] } sampleRate: number languages?: string[] } +function appendLibrarySearchPath(libDir: string): void { + if (!existsSync(libDir)) return + + if (process.platform === 'darwin') { + const current = process.env.DYLD_LIBRARY_PATH || '' + const paths = current.split(':').filter(Boolean) + if (!paths.includes(libDir)) { + process.env.DYLD_LIBRARY_PATH = [libDir, ...paths].join(':') + } + return + } + + if (process.platform === 'linux') { + const current = process.env.LD_LIBRARY_PATH || '' + const paths = current.split(':').filter(Boolean) + if (!paths.includes(libDir)) { + process.env.LD_LIBRARY_PATH = [libDir, ...paths].join(':') + } + } +} + +function prepareSherpaRuntimeEnv(): void { + const platform = process.platform === 'win32' ? 'win' : process.platform + const platformPkg = `sherpa-onnx-${platform}-${process.arch}` + const resourcesPath = (process as any).resourcesPath as string | undefined + + const candidates = [ + // Dev: /project/dist-electron -> /project/node_modules/... + join(__dirname, '..', 'node_modules', platformPkg), + // Fallback for alternate layouts + join(__dirname, 'node_modules', platformPkg), + join(process.cwd(), 'node_modules', platformPkg), + // Packaged app: Resources/app.asar.unpacked/node_modules/... + resourcesPath ? join(resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : '' + ].filter(Boolean) + + for (const dir of candidates) { + appendLibrarySearchPath(dir) + } +} + // 语言标记映射 const LANGUAGE_TAGS: Record = { 'zh': '<|zh|>', @@ -95,22 +138,60 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean { } async function run() { - if (!parentPort) { - return; + const isForkProcess = !parentPort + const emit = (msg: any) => { + if (parentPort) { + parentPort.postMessage(msg) + return + } + if (typeof process.send === 'function') { + process.send(msg) + } + } + + const normalizeBuffer = (data: WorkerParams['wavData']): Buffer => { + if (Buffer.isBuffer(data)) return data + if (data instanceof Uint8Array) return Buffer.from(data) + if (data && typeof data === 'object' && (data as any).type === 'Buffer' && Array.isArray((data as any).data)) { + return Buffer.from((data as any).data) + } + return Buffer.alloc(0) + } + + const readParams = async (): Promise => { + if (parentPort) { + return workerData as WorkerParams + } + + return new Promise((resolve) => { + let settled = false + const finish = (value: WorkerParams | null) => { + if (settled) return + settled = true + resolve(value) + } + process.once('message', (msg) => finish(msg as WorkerParams)) + process.once('disconnect', () => finish(null)) + }) } try { + prepareSherpaRuntimeEnv() + const params = await readParams() + if (!params) return + // 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等) let sherpa: any; try { sherpa = require('sherpa-onnx-node'); } catch (requireError) { - parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) }); + emit({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) }); + if (isForkProcess) process.exit(1) return; } - const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams - const wavData = Buffer.from(rawWavData); + const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = params + const wavData = normalizeBuffer(rawWavData); // 确保有有效的语言列表,默认只允许中文 let allowedLanguages = languages || ['zh'] if (allowedLanguages.length === 0) { @@ -151,16 +232,18 @@ async function run() { if (isLanguageAllowed(result, allowedLanguages)) { const processedText = richTranscribePostProcess(result.text) - parentPort.postMessage({ type: 'final', text: processedText }) + emit({ type: 'final', text: processedText }) + if (isForkProcess) process.exit(0) } else { - parentPort.postMessage({ type: 'final', text: '' }) + emit({ type: 'final', text: '' }) + if (isForkProcess) process.exit(0) } } catch (error) { - parentPort.postMessage({ type: 'error', error: String(error) }) + emit({ type: 'error', error: String(error) }) + if (isForkProcess) process.exit(1) } } run(); - diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index d95f5f6..898084d 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -20,21 +20,26 @@ if (parentPort) { result = { success: true } break case 'setMonitor': - core.setMonitor((type, json) => { + { + const monitorOk = core.setMonitor((type, json) => { parentPort!.postMessage({ id: -1, type: 'monitor', payload: { type, json } }) }) - result = { success: true } + result = { success: monitorOk } break + } case 'testConnection': result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid) break case 'open': result = await core.open(payload.dbPath, payload.hexKey, payload.wxid) break + case 'getLastInitError': + result = core.getLastInitError() + break case 'close': core.close() result = { success: true } @@ -54,6 +59,27 @@ if (parentPort) { case 'getMessageCount': result = await core.getMessageCount(payload.sessionId) break + case 'getMessageCounts': + result = await core.getMessageCounts(payload.sessionIds) + break + case 'getSessionMessageCounts': + result = await core.getSessionMessageCounts(payload.sessionIds) + break + case 'getSessionMessageTypeStats': + result = await core.getSessionMessageTypeStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp) + break + case 'getSessionMessageTypeStatsBatch': + result = await core.getSessionMessageTypeStatsBatch(payload.sessionIds, payload.options) + break + case 'getSessionMessageDateCounts': + result = await core.getSessionMessageDateCounts(payload.sessionId) + break + case 'getSessionMessageDateCountsBatch': + result = await core.getSessionMessageDateCountsBatch(payload.sessionIds) + break + case 'getMessagesByType': + result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset) + break case 'getDisplayNames': result = await core.getDisplayNames(payload.usernames) break @@ -84,12 +110,33 @@ if (parentPort) { case 'getMessageMeta': result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset) break + case 'getMessageTableColumns': + result = await core.getMessageTableColumns(payload.dbPath, payload.tableName) + break + case 'getMessageTableTimeRange': + result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName) + break case 'getContact': result = await core.getContact(payload.username) break case 'getContactStatus': result = await core.getContactStatus(payload.usernames) break + case 'getContactTypeCounts': + result = await core.getContactTypeCounts() + break + case 'getContactsCompact': + result = await core.getContactsCompact(payload.usernames) + break + case 'getContactAliasMap': + result = await core.getContactAliasMap(payload.usernames) + break + case 'getContactFriendFlags': + result = await core.getContactFriendFlags(payload.usernames) + break + case 'getChatRoomExtBuffer': + result = await core.getChatRoomExtBuffer(payload.chatroomId) + break case 'getAggregateStats': result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp) break @@ -126,6 +173,12 @@ if (parentPort) { case 'getEmoticonCdnUrl': result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5) break + case 'getEmoticonCaption': + result = await core.getEmoticonCaption(payload.dbPath, payload.md5) + break + case 'getEmoticonCaptionStrict': + result = await core.getEmoticonCaptionStrict(payload.md5) + break case 'listMessageDbs': result = await core.listMessageDbs() break @@ -135,18 +188,48 @@ if (parentPort) { case 'getMessageById': result = await core.getMessageById(payload.sessionId, payload.localId) break + case 'searchMessages': + result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp) + break case 'getVoiceData': result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId) if (!result.success) { console.error('[wcdbWorker] getVoiceData failed:', result.error) } break + case 'getVoiceDataBatch': + result = await core.getVoiceDataBatch(payload.requests) + break + case 'getMediaSchemaSummary': + result = await core.getMediaSchemaSummary(payload.dbPath) + break + case 'getHeadImageBuffers': + result = await core.getHeadImageBuffers(payload.usernames) + break + case 'resolveImageHardlink': + result = await core.resolveImageHardlink(payload.md5, payload.accountDir) + break + case 'resolveImageHardlinkBatch': + result = await core.resolveImageHardlinkBatch(payload.requests) + break + case 'resolveVideoHardlinkMd5': + result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath) + break + case 'resolveVideoHardlinkMd5Batch': + result = await core.resolveVideoHardlinkMd5Batch(payload.requests) + break case 'getSnsTimeline': result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime) break case 'getSnsAnnualStats': result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp) break + case 'getSnsUsernames': + result = await core.getSnsUsernames() + break + case 'getSnsExportStats': + result = await core.getSnsExportStats(payload.myWxid) + break case 'installSnsBlockDeleteTrigger': result = await core.installSnsBlockDeleteTrigger() break @@ -171,7 +254,15 @@ if (parentPort) { case 'deleteMessage': result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint) break - + case 'cloudInit': + result = await core.cloudInit(payload.intervalSeconds) + break + case 'cloudReport': + result = await core.cloudReport(payload.statsJson) + break + case 'cloudStop': + result = core.cloudStop() + break default: result = { success: false, error: `Unknown method: ${type}` } } diff --git a/electron/windows/notificationWindow.ts b/electron/windows/notificationWindow.ts index ec58eac..fc31ccc 100644 --- a/electron/windows/notificationWindow.ts +++ b/electron/windows/notificationWindow.ts @@ -5,6 +5,28 @@ import { ConfigService } from '../services/config' let notificationWindow: BrowserWindow | null = null let closeTimer: NodeJS.Timeout | null = null +export function destroyNotificationWindow() { + if (closeTimer) { + clearTimeout(closeTimer) + closeTimer = null + } + lastNotificationData = null + + if (!notificationWindow || notificationWindow.isDestroyed()) { + notificationWindow = null + return + } + + const win = notificationWindow + notificationWindow = null + + try { + win.destroy() + } catch (error) { + console.warn('[NotificationWindow] Failed to destroy window:', error) + } +} + export function createNotificationWindow() { if (notificationWindow && !notificationWindow.isDestroyed()) { return notificationWindow @@ -110,7 +132,7 @@ async function showAndSend(win: BrowserWindow, data: any) { // 更新位置 const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize - const winWidth = 344 + const winWidth = position === 'top-center' ? 280 : 344 const winHeight = 114 const padding = 20 @@ -118,6 +140,10 @@ async function showAndSend(win: BrowserWindow, data: any) { let y = 0 switch (position) { + case 'top-center': + x = (screenWidth - winWidth) / 2 + y = padding + break case 'top-right': x = screenWidth - winWidth - padding y = padding @@ -144,7 +170,7 @@ async function showAndSend(win: BrowserWindow, data: any) { win.showInactive() // 显示但不聚焦 win.setAlwaysOnTop(true, 'screen-saver') // 最高层级 - win.webContents.send('notification:show', data) + win.webContents.send('notification:show', { ...data, position }) // 自动关闭计时器通常由渲染进程管理 // 渲染进程发送 'notification:close' 来隐藏窗口 diff --git a/package-lock.json b/package-lock.json index 92d10ba..d827aa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "2.1.0", "hasInstallScript": true, "dependencies": { - "better-sqlite3": "^12.5.0", "echarts": "^5.5.1", "echarts-for-react": "^3.0.2", "electron-store": "^10.0.0", @@ -30,12 +29,12 @@ "remark-gfm": "^4.0.1", "sherpa-onnx-node": "^1.10.38", "silk-wasm": "^3.7.1", + "sudo-prompt": "^9.2.1", "wechat-emojis": "^1.0.2", "zustand": "^5.0.2" }, "devDependencies": { "@electron/rebuild": "^4.0.2", - "@types/better-sqlite3": "^7.6.13", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.3.4", @@ -2784,16 +2783,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmmirror.com/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -3868,20 +3857,6 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/better-sqlite3": { - "version": "12.5.0", - "resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-12.5.0.tgz", - "integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" - } - }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz", @@ -3904,15 +3879,6 @@ "node": "*" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", @@ -4924,6 +4890,7 @@ "version": "6.0.0", "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -4939,6 +4906,7 @@ "version": "3.1.0", "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4947,15 +4915,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmmirror.com/defaults/-/defaults-1.0.4.tgz", @@ -5047,6 +5006,7 @@ "version": "2.1.2", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -5817,15 +5777,6 @@ "node": ">=8.3.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -5964,12 +5915,6 @@ "node": ">= 6" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.4.tgz", @@ -6272,12 +6217,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", @@ -6744,12 +6683,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -8503,12 +8436,6 @@ "node": ">=10" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", @@ -8534,12 +8461,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", @@ -9003,44 +8924,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prebuild-install/node_modules/node-abi": { - "version": "3.85.0", - "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.85.0.tgz", - "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/proc-log": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/proc-log/-/proc-log-5.0.0.tgz", @@ -9101,6 +8984,7 @@ "version": "3.0.3", "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -9130,21 +9014,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", @@ -9823,6 +9692,9 @@ "sherpa-onnx-win-x64": "^1.12.23" } }, + "node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": { + "optional": true + }, "node_modules/sherpa-onnx-win-ia32": { "version": "1.12.23", "resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz", @@ -9865,51 +9737,6 @@ "node": ">=16.11.0" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -10139,15 +9966,6 @@ "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/stubborn-fs": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/stubborn-fs/-/stubborn-fs-2.0.0.tgz", @@ -10181,6 +9999,13 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/sudo-prompt": { + "version": "9.2.1", + "resolved": "https://registry.npmmirror.com/sudo-prompt/-/sudo-prompt-9.2.1.tgz", + "integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz", @@ -10225,24 +10050,6 @@ "node": ">=10" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz", @@ -10519,18 +10326,6 @@ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", "license": "0BSD" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz", diff --git a/package.json b/package.json index 3871a23..cfe8f41 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,10 @@ "version": "2.1.0", "description": "WeFlow", "main": "dist-electron/main.js", - "author": "cc", + "author": { + "name": "cc", + "email": "yccccccy@proton.me" + }, "repository": { "type": "git", "url": "https://github.com/hicccc77/WeFlow" @@ -13,6 +16,7 @@ "postinstall": "electron-builder install-app-deps", "rebuild": "electron-rebuild", "dev": "vite", + "typecheck": "tsc --noEmit", "build": "tsc && vite build && electron-builder", "preview": "vite preview", "electron:dev": "vite --mode electron", @@ -20,7 +24,6 @@ "preinstall": "node preinstall.js" }, "dependencies": { - "better-sqlite3": "^12.5.0", "echarts": "^5.5.1", "echarts-for-react": "^3.0.2", "electron-store": "^10.0.0", @@ -41,12 +44,12 @@ "remark-gfm": "^4.0.1", "sherpa-onnx-node": "^1.10.38", "silk-wasm": "^3.7.1", + "sudo-prompt": "^9.2.1", "wechat-emojis": "^1.0.2", "zustand": "^5.0.2" }, "devDependencies": { "@electron/rebuild": "^4.0.2", - "@types/better-sqlite3": "^7.6.13", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.3.4", @@ -72,12 +75,35 @@ "directories": { "output": "release" }, + "mac": { + "target": [ + "dmg", + "zip" + ], + "category": "public.app-category.utilities", + "hardenedRuntime": false, + "gatekeeperAssess": false, + "entitlements": "electron/entitlements.mac.plist", + "entitlementsInherit": "electron/entitlements.mac.plist", + "icon": "resources/icon.icns" + }, "win": { "target": [ "nsis" ], "icon": "public/icon.ico" }, + "linux": { + "icon": "public/icon.png", + "target": [ + "appimage", + "deb", + "tar.gz" + ], + "category": "Utility", + "executableName": "weflow", + "synopsis": "WeFlow for Linux" + }, "nsis": { "oneClick": false, "differentialPackage": false, @@ -108,6 +134,10 @@ "from": "public/icon.ico", "to": "icon.ico" }, + { + "from": "public/icon.png", + "to": "icon.png" + }, { "from": "electron/assets/wasm/", "to": "assets/wasm/" @@ -120,6 +150,8 @@ "asarUnpack": [ "node_modules/silk-wasm/**/*", "node_modules/sherpa-onnx-node/**/*", + "node_modules/sherpa-onnx-*/*", + "node_modules/sherpa-onnx-*/**/*", "node_modules/ffmpeg-static/**/*" ], "extraFiles": [ @@ -139,6 +171,7 @@ "from": "resources/vcruntime140_1.dll", "to": "." } - ] + ], + "icon": "resources/icon.icns" } } \ No newline at end of file diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..7372ec7 Binary files /dev/null and b/public/icon.png differ diff --git a/resources/arm64/WCDB.dll b/resources/arm64/WCDB.dll new file mode 100644 index 0000000..60c3508 Binary files /dev/null and b/resources/arm64/WCDB.dll differ diff --git a/resources/arm64/wcdb_api.dll b/resources/arm64/wcdb_api.dll new file mode 100644 index 0000000..85d1ea8 Binary files /dev/null and b/resources/arm64/wcdb_api.dll differ diff --git a/resources/icon.icns b/resources/icon.icns new file mode 100644 index 0000000..70df606 Binary files /dev/null and b/resources/icon.icns differ diff --git a/resources/image_scan_entitlements.plist b/resources/image_scan_entitlements.plist new file mode 100644 index 0000000..023065e --- /dev/null +++ b/resources/image_scan_entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.debugger + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/resources/image_scan_helper b/resources/image_scan_helper new file mode 100755 index 0000000..b10856d Binary files /dev/null and b/resources/image_scan_helper differ diff --git a/resources/image_scan_helper.c b/resources/image_scan_helper.c new file mode 100644 index 0000000..39bcf27 --- /dev/null +++ b/resources/image_scan_helper.c @@ -0,0 +1,77 @@ +/* + * image_scan_helper - 轻量包装程序 + * 加载 libwx_key.dylib 并调用 ScanMemoryForImageKey + * 用法: image_scan_helper + * 输出: JSON {"success":true,"aesKey":"..."} 或 {"success":false,"error":"..."} + */ +#include +#include +#include +#include +#include +#include + +typedef const char* (*ScanMemoryForImageKeyFn)(int pid, const char* ciphertext); +typedef void (*FreeStringFn)(const char* str); + +int main(int argc, char* argv[]) { + if (argc != 3) { + fprintf(stderr, "Usage: %s \n", argv[0]); + printf("{\"success\":false,\"error\":\"invalid arguments\"}\n"); + return 1; + } + + int pid = atoi(argv[1]); + const char* ciphertext_hex = argv[2]; + + if (pid <= 0) { + printf("{\"success\":false,\"error\":\"invalid pid\"}\n"); + return 1; + } + + /* 定位 dylib: 与自身同目录下的 libwx_key.dylib */ + char exe_path[4096]; + uint32_t size = sizeof(exe_path); + if (_NSGetExecutablePath(exe_path, &size) != 0) { + printf("{\"success\":false,\"error\":\"cannot get executable path\"}\n"); + return 1; + } + + char* dir = dirname(exe_path); + char dylib_path[4096]; + snprintf(dylib_path, sizeof(dylib_path), "%s/libwx_key.dylib", dir); + + void* handle = dlopen(dylib_path, RTLD_LAZY); + if (!handle) { + printf("{\"success\":false,\"error\":\"dlopen failed: %s\"}\n", dlerror()); + return 1; + } + + ScanMemoryForImageKeyFn scan_fn = (ScanMemoryForImageKeyFn)dlsym(handle, "ScanMemoryForImageKey"); + if (!scan_fn) { + printf("{\"success\":false,\"error\":\"symbol not found: ScanMemoryForImageKey\"}\n"); + dlclose(handle); + return 1; + } + + FreeStringFn free_fn = (FreeStringFn)dlsym(handle, "FreeString"); + + fprintf(stderr, "[image_scan_helper] calling ScanMemoryForImageKey(pid=%d, ciphertext=%s)\n", pid, ciphertext_hex); + + const char* result = scan_fn(pid, ciphertext_hex); + + if (result && strlen(result) > 0) { + /* 检查是否是错误 */ + if (strncmp(result, "ERROR", 5) == 0) { + printf("{\"success\":false,\"error\":\"%s\"}\n", result); + } else { + printf("{\"success\":true,\"aesKey\":\"%s\"}\n", result); + } + if (free_fn) free_fn(result); + } else { + printf("{\"success\":false,\"error\":\"no key found\"}\n"); + } + + dlclose(handle); + return 0; +} diff --git a/resources/libwcdb_api.dylib b/resources/libwcdb_api.dylib new file mode 100755 index 0000000..07cb87f Binary files /dev/null and b/resources/libwcdb_api.dylib differ diff --git a/resources/libwx_key.dylib b/resources/libwx_key.dylib new file mode 100755 index 0000000..59c673a Binary files /dev/null and b/resources/libwx_key.dylib differ diff --git a/resources/linux/libwcdb_api.so b/resources/linux/libwcdb_api.so new file mode 100755 index 0000000..e206d60 Binary files /dev/null and b/resources/linux/libwcdb_api.so differ diff --git a/resources/macos/libWCDB.dylib b/resources/macos/libWCDB.dylib new file mode 100755 index 0000000..75eb279 Binary files /dev/null and b/resources/macos/libWCDB.dylib differ diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib new file mode 100755 index 0000000..db376bb Binary files /dev/null and b/resources/macos/libwcdb_api.dylib differ diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 4dcca7d..100bbc2 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/resources/wx_key.dll b/resources/wx_key.dll index 30ddb52..9ab5f7b 100644 Binary files a/resources/wx_key.dll and b/resources/wx_key.dll differ diff --git a/resources/xkey_helper b/resources/xkey_helper new file mode 100755 index 0000000..1c9b951 Binary files /dev/null and b/resources/xkey_helper differ diff --git a/resources/xkey_helper_linux b/resources/xkey_helper_linux new file mode 100755 index 0000000..54f7cb3 Binary files /dev/null and b/resources/xkey_helper_linux differ diff --git a/src/App.scss b/src/App.scss index 3c137bd..5cffbbd 100644 --- a/src/App.scss +++ b/src/App.scss @@ -69,6 +69,19 @@ flex: 1; overflow: auto; padding: 24px; + position: relative; +} + +.export-keepalive-page { + height: 100%; + + &.hidden { + display: none; + } +} + +.export-route-anchor { + display: none; } @keyframes appFadeIn { diff --git a/src/App.tsx b/src/App.tsx index c999a80..e09f7ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react' -import { Routes, Route, useNavigate, useLocation } from 'react-router-dom' +import { useEffect, useRef, useState } from 'react' +import { Routes, Route, Navigate, useNavigate, useLocation, type Location } from 'react-router-dom' import TitleBar from './components/TitleBar' import Sidebar from './components/Sidebar' import RouteGuard from './components/RouteGuard' @@ -8,6 +8,7 @@ import HomePage from './pages/HomePage' import ChatPage from './pages/ChatPage' import AnalyticsPage from './pages/AnalyticsPage' import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage' +import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage' import AnnualReportPage from './pages/AnnualReportPage' import AnnualReportWindow from './pages/AnnualReportWindow' import DualReportPage from './pages/DualReportPage' @@ -26,6 +27,7 @@ import NotificationWindow from './pages/NotificationWindow' import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore' import * as configService from './services/config' +import * as cloudControl from './services/cloudControl' import { Download, X, Shield } from 'lucide-react' import './App.scss' @@ -35,10 +37,24 @@ import LockScreen from './components/LockScreen' import { GlobalSessionMonitor } from './components/GlobalSessionMonitor' import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal' import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal' +import WindowCloseDialog from './components/WindowCloseDialog' + +function RouteStateRedirect({ to }: { to: string }) { + const location = useLocation() + + return +} function App() { const navigate = useNavigate() const location = useLocation() + const settingsBackgroundRef = useRef({ + pathname: '/home', + search: '', + hash: '', + state: null, + key: 'settings-fallback' + } as Location) const { setDbConnected, @@ -59,9 +75,19 @@ function App() { const isAgreementWindow = location.pathname === '/agreement-window' const isOnboardingWindow = location.pathname === '/onboarding-window' const isVideoPlayerWindow = location.pathname === '/video-player-window' - const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') + const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/') + const isStandaloneChatWindow = location.pathname === '/chat-window' const isNotificationWindow = location.pathname === '/notification-window' + const isSettingsRoute = location.pathname === '/settings' + const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null + const routeLocation = isSettingsRoute + ? settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current + : location + const isExportRoute = routeLocation.pathname === '/export' const [themeHydrated, setThemeHydrated] = useState(false) + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + const [showCloseDialog, setShowCloseDialog] = useState(false) + const [canMinimizeToTray, setCanMinimizeToTray] = useState(false) // 锁定状态 // const [isLocked, setIsLocked] = useState(false) // Moved to store @@ -75,6 +101,62 @@ function App() { const [agreementChecked, setAgreementChecked] = useState(false) const [agreementLoading, setAgreementLoading] = useState(true) + // 数据收集同意状态 + const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false) + + const [showWaylandWarning, setShowWaylandWarning] = useState(false) + + useEffect(() => { + const checkWaylandStatus = async () => { + try { + // 防止在非客户端环境报错,先检查 API 是否存在 + if (!window.electronAPI?.app?.checkWayland) return + + // 通过 configService 检查是否已经弹过窗 + const hasWarned = await window.electronAPI.config.get('waylandWarningShown') + + if (!hasWarned) { + const isWayland = await window.electronAPI.app.checkWayland() + if (isWayland) { + setShowWaylandWarning(true) + } + } + } catch (e) { + console.error('检查 Wayland 状态失败:', e) + } + } + + // 只有在协议同意之后并且已经进入主应用流程才检查 + if (!isAgreementWindow && !isOnboardingWindow && !agreementLoading) { + checkWaylandStatus() + } + }, [isAgreementWindow, isOnboardingWindow, agreementLoading]) + + const handleDismissWaylandWarning = async () => { + try { + // 记录到本地配置中,下次不再提示 + await window.electronAPI.config.set('waylandWarningShown', true) + } catch (e) { + console.error('保存 Wayland 提示状态失败:', e) + } + setShowWaylandWarning(false) + } + + useEffect(() => { + if (location.pathname !== '/settings') { + settingsBackgroundRef.current = location + } + }, [location]) + + useEffect(() => { + const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => { + setCanMinimizeToTray(Boolean(payload.canMinimizeToTray)) + setShowCloseDialog(true) + }) + + return () => removeCloseConfirmListener() + }, []) + useEffect(() => { const root = document.documentElement const body = document.body @@ -106,10 +188,6 @@ function App() { const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode document.documentElement.setAttribute('data-theme', currentTheme) document.documentElement.setAttribute('data-mode', effectiveMode) - const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a' - if (!isOnboardingWindow && !isNotificationWindow) { - window.electronAPI.window.setTitleBarOverlay({ symbolColor }) - } } applyMode(themeMode) @@ -170,6 +248,14 @@ function App() { const agreed = await configService.getAgreementAccepted() if (!agreed) { setShowAgreement(true) + } else { + // 协议已同意,检查数据收集同意状态 + const consent = await configService.getAnalyticsConsent() + const denyCount = await configService.getAnalyticsDenyCount() + // 如果未设置同意状态且拒绝次数小于2次,显示弹窗 + if (consent === null && denyCount < 2) { + setShowAnalyticsConsent(true) + } } } catch (e) { console.error('检查协议状态失败:', e) @@ -180,16 +266,45 @@ function App() { checkAgreement() }, []) + // 初始化数据收集 + useEffect(() => { + cloudControl.initCloudControl() + }, []) + + // 记录页面访问 + useEffect(() => { + const path = location.pathname + if (path && path !== '/') { + cloudControl.recordPage(path) + } + }, [location.pathname]) + const handleAgree = async () => { if (!agreementChecked) return await configService.setAgreementAccepted(true) setShowAgreement(false) + // 协议同意后,检查数据收集同意 + const consent = await configService.getAnalyticsConsent() + if (consent === null) { + setShowAnalyticsConsent(true) + } } const handleDisagree = () => { window.electronAPI.window.close() } + const handleAnalyticsAllow = async () => { + await configService.setAnalyticsConsent(true) + setShowAnalyticsConsent(false) + } + + const handleAnalyticsDeny = async () => { + const denyCount = await configService.getAnalyticsDenyCount() + await configService.setAnalyticsDenyCount(denyCount + 1) + setShowAnalyticsConsent(false) + } + // 监听启动时的更新通知 useEffect(() => { if (isNotificationWindow) return // Skip updates in notification window @@ -250,6 +365,26 @@ function App() { setUpdateInfo(null) } + const handleWindowCloseAction = async ( + action: 'tray' | 'quit' | 'cancel', + rememberChoice = false + ) => { + setShowCloseDialog(false) + if (rememberChoice && action !== 'cancel') { + try { + await configService.setWindowCloseBehavior(action) + } catch (error) { + console.error('保存关闭偏好失败:', error) + } + } + + try { + await window.electronAPI.window.respondCloseConfirm(action) + } catch (error) { + console.error('处理关闭确认失败:', error) + } + } + // 启动时自动检查配置并连接数据库 useEffect(() => { if (isAgreementWindow || isOnboardingWindow) return @@ -335,6 +470,8 @@ function App() { checkLock() }, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow]) + + // 独立协议窗口 if (isAgreementWindow) { return @@ -360,12 +497,51 @@ function App() { return } + // 独立会话聊天窗口(仅显示聊天内容区域) + if (isStandaloneChatWindow) { + const params = new URLSearchParams(location.search) + const sessionId = params.get('sessionId') || '' + const standaloneSource = params.get('source') + const standaloneInitialDisplayName = params.get('initialDisplayName') + const standaloneInitialAvatarUrl = params.get('initialAvatarUrl') + const standaloneInitialContactType = params.get('initialContactType') + return ( + + ) + } + // 独立通知窗口 if (isNotificationWindow) { return } // 主窗口 - 完整布局 + const handleCloseSettings = () => { + const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current + if (backgroundLocation.pathname === '/settings') { + navigate('/home', { replace: true }) + return + } + navigate( + { + pathname: backgroundLocation.pathname, + search: backgroundLocation.search, + hash: backgroundLocation.hash + }, + { + replace: true, + state: backgroundLocation.state + } + ) + } + return (
)} + {/* 数据收集同意弹窗 */} + {showAnalyticsConsent && !agreementLoading && ( +
+
+
+ +

使用数据收集说明

+
+
+
+

为了持续改进 WeFlow 并提供更好的用户体验,我们希望收集一些匿名的使用数据。

+ +

我们会收集什么?

+

• 功能使用情况(如哪些功能被使用、使用频率)

+

• 应用性能数据(如加载时间、错误日志)

+

• 设备基本信息(如操作系统版本、应用版本)

+ +

我们不会收集什么?

+

• 你的聊天记录内容

+

• 个人身份信息

+

• 联系人信息

+

• 任何可以识别你身份的数据

+

• 一切你担心会涉及隐藏的数据

+ +
+
+
+
+ + +
+
+
+
+ )} + + {showWaylandWarning && ( +
+
+
+ +

环境兼容性提示 (Wayland)

+
+
+
+

检测到您当前正在使用 Wayland 显示服务器。

+

在 Wayland 环境下,出于系统级的安全与设计机制,应用程序无法直接控制新弹出窗口的位置

+

这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。

+
+

如果您觉得窗口位置异常严重影响了使用体验,建议尝试:

+

1. 在系统登录界面,将会话切换回 X11 (Xorg) 模式。

+

2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。

+
+
+
+
+ +
+
+
+
+ )} + {/* 更新提示对话框 */} + handleWindowCloseAction(action, rememberChoice)} + onCancel={() => handleWindowCloseAction('cancel')} + /> +
- +
- +
+ +
+ + } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> } /> - } /> - } /> +
+ + {isSettingsRoute && ( + + )}
) } diff --git a/src/components/Avatar.scss b/src/components/Avatar.scss index 34b11b2..6a15310 100644 --- a/src/components/Avatar.scss +++ b/src/components/Avatar.scss @@ -50,6 +50,21 @@ border-radius: inherit; } + .avatar-loading { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary, #999); + background-color: var(--bg-tertiary, #e0e0e0); + border-radius: inherit; + + .avatar-loading-icon { + animation: avatar-spin 0.9s linear infinite; + } + } + /* Loading Skeleton */ .avatar-skeleton { position: absolute; @@ -76,4 +91,14 @@ background-position: -200% 0; } } -} \ No newline at end of file + + @keyframes avatar-spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } + } +} diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 7406bd5..69020f1 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useMemo } from 'react' -import { User } from 'lucide-react' +import { Loader2, User } from 'lucide-react' import { avatarLoadQueue } from '../utils/AvatarLoadQueue' import './Avatar.scss' @@ -13,6 +13,7 @@ interface AvatarProps { shape?: 'circle' | 'square' | 'rounded' className?: string lazy?: boolean + loading?: boolean onClick?: () => void } @@ -23,12 +24,14 @@ export const Avatar = React.memo(function Avatar({ shape = 'rounded', className = '', lazy = true, + loading = false, onClick }: AvatarProps) { // 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画 const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src]) + const isFailed = useMemo(() => src ? avatarLoadQueue.hasFailed(src) : false, [src]) const [imageLoaded, setImageLoaded] = useState(isCached) - const [imageError, setImageError] = useState(false) + const [imageError, setImageError] = useState(isFailed) const [shouldLoad, setShouldLoad] = useState(!lazy || isCached) const [isInQueue, setIsInQueue] = useState(false) const imgRef = useRef(null) @@ -42,7 +45,7 @@ export const Avatar = React.memo(function Avatar({ // Intersection Observer for lazy loading useEffect(() => { - if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached) return + if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached || imageError || isFailed) return const observer = new IntersectionObserver( (entries) => { @@ -50,10 +53,11 @@ export const Avatar = React.memo(function Avatar({ if (entry.isIntersecting && !isInQueue) { setIsInQueue(true) avatarLoadQueue.enqueue(src).then(() => { + setImageError(false) setShouldLoad(true) }).catch(() => { - // 加载失败不要立刻显示错误,让浏览器渲染去报错 - setShouldLoad(true) + setImageError(true) + setShouldLoad(false) }).finally(() => { setIsInQueue(false) }) @@ -67,14 +71,18 @@ export const Avatar = React.memo(function Avatar({ observer.observe(containerRef.current) return () => observer.disconnect() - }, [src, lazy, shouldLoad, isInQueue, isCached]) + }, [src, lazy, shouldLoad, isInQueue, isCached, imageError, isFailed]) // Reset state when src changes useEffect(() => { const cached = src ? loadedAvatarCache.has(src) : false + const failed = src ? avatarLoadQueue.hasFailed(src) : false setImageLoaded(cached) - setImageError(false) - if (lazy && !cached) { + setImageError(failed) + if (failed) { + setShouldLoad(false) + setIsInQueue(false) + } else if (lazy && !cached) { setShouldLoad(false) setIsInQueue(false) } else { @@ -95,6 +103,7 @@ export const Avatar = React.memo(function Avatar({ } const hasValidUrl = !!src && !imageError && shouldLoad + const shouldShowLoadingPlaceholder = loading && !hasValidUrl && !imageError return (
{ - if (src) loadedAvatarCache.add(src) + if (src) { + avatarLoadQueue.clearFailed(src) + loadedAvatarCache.add(src) + } setImageLoaded(true) + setImageError(false) + }} + onError={() => { + if (src) { + avatarLoadQueue.markFailed(src) + loadedAvatarCache.delete(src) + } + setImageLoaded(false) + setImageError(true) + setShouldLoad(false) }} - onError={() => setImageError(true)} loading={lazy ? "lazy" : "eager"} + referrerPolicy="no-referrer" /> + ) : shouldShowLoadingPlaceholder ? ( +
+ +
) : (
{name ? {getAvatarLetter()} : } diff --git a/src/components/BatchTranscribeGlobal.tsx b/src/components/BatchTranscribeGlobal.tsx index 3aa7c10..0c6d825 100644 --- a/src/components/BatchTranscribeGlobal.tsx +++ b/src/components/BatchTranscribeGlobal.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' import { createPortal } from 'react-dom' -import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock } from 'lucide-react' +import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock, Mic } from 'lucide-react' import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' import '../styles/batchTranscribe.scss' @@ -17,6 +17,7 @@ export const BatchTranscribeGlobal: React.FC = () => { result, sessionName, startTime, + taskType, setShowToast, setShowResult } = useBatchTranscribeStore() @@ -64,7 +65,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
- 批量转写中{sessionName ? `(${sessionName})` : ''} + {taskType === 'decrypt' ? '批量解密语音中' : '批量转写中'}{sessionName ? `(${sessionName})` : ''}
+ / +
+ + + {menuOpen && ( +
+ +
+ )} +
+
+ + {actions ?
{actions}
: null} +
+ ) +} + +export default ChatAnalysisHeader diff --git a/src/components/ConfirmDialog.scss b/src/components/ConfirmDialog.scss new file mode 100644 index 0000000..5acd32d --- /dev/null +++ b/src/components/ConfirmDialog.scss @@ -0,0 +1,123 @@ +.confirm-dialog-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + animation: fadeIn 0.2s ease-out; + + .confirm-dialog { + width: 480px; + background: var(--bg-primary); + border-radius: 20px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + position: relative; + animation: slideUp 0.2s ease-out; + overflow: hidden; + + .close-btn { + position: absolute; + top: 16px; + right: 16px; + background: rgba(0, 0, 0, 0.05); + border: none; + color: var(--text-secondary); + cursor: pointer; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + + &:hover { + background: rgba(0, 0, 0, 0.1); + color: var(--text-primary); + } + } + + .dialog-title { + padding: 40px 40px 16px; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } + + .dialog-content { + padding: 0 40px 24px; + + p { + font-size: 15px; + color: var(--text-primary); + line-height: 1.6; + margin: 0 0 16px 0; + + &:last-child { + margin-bottom: 0; + } + } + } + + .dialog-actions { + padding: 0 40px 40px; + display: flex; + justify-content: flex-end; + gap: 12px; + + button { + padding: 12px 24px; + border-radius: 12px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + border: none; + + &.btn-cancel { + background: var(--bg-tertiary); + color: var(--text-secondary); + + &:hover { + background: var(--bg-hover); + } + } + + &.btn-confirm { + background: var(--primary); + color: var(--on-primary); + + &:hover { + background: var(--primary-hover); + } + + &:active { + transform: scale(0.98); + } + } + } + } + } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..7126edf --- /dev/null +++ b/src/components/ConfirmDialog.tsx @@ -0,0 +1,32 @@ +import { X } from 'lucide-react' +import './ConfirmDialog.scss' + +interface ConfirmDialogProps { + open: boolean + title?: string + message: string + onConfirm: () => void + onCancel: () => void +} + +export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }: ConfirmDialogProps) { + if (!open) return null + + return ( +
+
e.stopPropagation()}> + + {title &&
{title}
} +
+

{message}

+
+
+ + +
+
+
+ ) +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..6d02c86 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,41 @@ +import { Component, ReactNode } from 'react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error?: Error +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: any) { + console.error('ErrorBoundary caught:', error, errorInfo) + } + + render() { + if (this.state.hasError) { + return this.props.fallback || ( +
+

消息渲染出错

+

+ {this.state.error?.message || '未知错误'} +

+
+ ) + } + + return this.props.children + } +} diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss new file mode 100644 index 0000000..3907662 --- /dev/null +++ b/src/components/Export/ExportDateRangeDialog.scss @@ -0,0 +1,337 @@ +.export-date-range-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 2400; +} + +.export-date-range-dialog { + width: min(480px, calc(100vw - 32px)); + max-height: calc(100vh - 64px); + overflow-y: auto; + border-radius: 16px; + border: 1px solid var(--border-color); + background: var(--bg-secondary-solid, var(--bg-primary)); + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; + box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16); +} + +.export-date-range-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + + h4 { + margin: 0; + font-size: 14px; + color: var(--text-primary); + } +} + +.export-date-range-dialog-close-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-secondary); +} + +.export-date-range-preset-list { + display: flex; + flex-wrap: nowrap; + gap: 4px; + overflow-x: auto; + padding-bottom: 2px; + + &::-webkit-scrollbar { + height: 4px; + } +} + +.export-date-range-preset-item { + flex: 0 0 auto; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + min-height: 30px; + padding: 0 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + font-size: 11px; + cursor: pointer; + white-space: nowrap; + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + color: var(--primary); + } +} + +.export-date-range-mode-banner { + border-radius: 10px; + padding: 7px 10px; + font-size: 11px; + line-height: 1.4; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + + &.range { + border-color: rgba(var(--primary-rgb), 0.4); + background: rgba(var(--primary-rgb), 0.1); + color: var(--primary); + } +} + +.export-date-range-boundary-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.export-date-range-boundary-card { + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); + padding: 8px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); + } + + .boundary-label { + font-size: 11px; + color: var(--text-secondary); + } +} + +.export-date-range-selection-hint { + font-size: 11px; + color: var(--text-secondary); + padding: 0 2px; +} + +.export-date-range-calendar-panel { + border: 1px solid var(--border-color); + border-radius: 12px; + background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.04), transparent 28%), var(--bg-secondary); + padding: 10px; + + &.single { + width: 100%; + } +} + +.export-date-range-calendar-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.export-date-range-calendar-date-label { + display: flex; + flex-direction: column; + gap: 3px; + + span { + font-size: 11px; + color: var(--text-secondary); + } + + strong { + font-size: 13px; + color: var(--text-primary); + } +} + +.export-date-range-date-input { + width: 100%; + min-width: 0; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + height: 30px; + padding: 0 9px; + font-size: 12px; + + &:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); + } + + &.invalid { + border-color: #e84d4d; + box-shadow: 0 0 0 1px rgba(232, 77, 77, 0.2); + } +} + +.export-date-range-calendar-nav { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-primary); + + button { + width: 28px; + height: 28px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + padding: 0; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + + &:disabled { + cursor: not-allowed; + opacity: 0.45; + } + } +} + +.export-date-range-calendar-weekdays { + margin-top: 10px; + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + + span { + text-align: center; + font-size: 10px; + color: var(--text-tertiary); + } +} + +.export-date-range-calendar-days { + margin-top: 6px; + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; +} + +.export-date-range-calendar-day { + border: 1px solid transparent; + border-radius: 10px; + min-height: 34px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 12px; + cursor: pointer; + padding: 0; + transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, transform 0.15s ease; + + &:hover { + border-color: rgba(var(--primary-rgb), 0.28); + transform: translateY(-1px); + } + + &:disabled:hover { + border-color: transparent; + transform: none; + } + + &.outside { + color: var(--text-quaternary); + opacity: 0.72; + } + + &.disabled { + cursor: not-allowed; + opacity: 0.35; + transform: none; + border-color: transparent; + } + + &.in-range { + background: rgba(var(--primary-rgb), 0.1); + color: var(--primary); + } + + &.range-start, + &.range-end { + border-color: var(--primary); + background: var(--primary); + color: #fff; + font-weight: 600; + opacity: 1; + } + + &.active-boundary { + box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.22); + } +} + +.export-date-range-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.export-date-range-dialog-btn { + border-radius: 8px; + padding: 7px 12px; + font-size: 12px; + font-weight: 600; + border: 1px solid var(--border-color); + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + + &.primary { + border-color: var(--primary); + background: var(--primary); + color: #fff; + + &:hover { + background: var(--primary-hover); + } + } + + &.secondary { + background: var(--bg-secondary); + color: var(--text-primary); + + &:hover { + border-color: var(--primary); + color: var(--primary); + } + } +} + +@media (max-width: 640px) { + .export-date-range-boundary-row { + grid-template-columns: 1fr; + } +} diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx new file mode 100644 index 0000000..8a49fdd --- /dev/null +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -0,0 +1,465 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react' +import { + EXPORT_DATE_RANGE_PRESETS, + WEEKDAY_SHORT_LABELS, + addMonths, + buildCalendarCells, + cloneExportDateRangeSelection, + createDateRangeByPreset, + createDefaultDateRange, + formatCalendarMonthTitle, + formatDateInputValue, + isSameDay, + parseDateInputValue, + startOfDay, + endOfDay, + toMonthStart, + type ExportDateRangePreset, + type ExportDateRangeSelection +} from '../../utils/exportDateRange' +import './ExportDateRangeDialog.scss' + +interface ExportDateRangeDialogProps { + open: boolean + value: ExportDateRangeSelection + title?: string + minDate?: Date | null + maxDate?: Date | null + onClose: () => void + onConfirm: (value: ExportDateRangeSelection) => void +} + +type ActiveBoundary = 'start' | 'end' + +interface ExportDateRangeDialogDraft extends ExportDateRangeSelection { + panelMonth: Date +} + +const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => { + if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null + if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null + const normalizedMin = startOfDay(minDate) + const normalizedMax = endOfDay(maxDate) + if (normalizedMin.getTime() > normalizedMax.getTime()) return null + return { + minDate: normalizedMin, + maxDate: normalizedMax + } +} + +const clampSelectionToBounds = ( + value: ExportDateRangeSelection, + minDate?: Date | null, + maxDate?: Date | null +): ExportDateRangeSelection => { + const bounds = resolveBounds(minDate, maxDate) + if (!bounds) return cloneExportDateRangeSelection(value) + + const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start) + const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end) + const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) + const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) + const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate + const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime() + + return { + preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset), + useAllTime: value.useAllTime, + dateRange: { + start: nextStart, + end: nextEnd + } + } +} + +const buildDialogDraft = ( + value: ExportDateRangeSelection, + minDate?: Date | null, + maxDate?: Date | null +): ExportDateRangeDialogDraft => { + const nextValue = clampSelectionToBounds(value, minDate, maxDate) + return { + ...nextValue, + panelMonth: toMonthStart(nextValue.dateRange.start) + } +} + +export function ExportDateRangeDialog({ + open, + value, + title = '时间范围设置', + minDate, + maxDate, + onClose, + onConfirm +}: ExportDateRangeDialogProps) { + const [draft, setDraft] = useState(() => buildDialogDraft(value, minDate, maxDate)) + const [activeBoundary, setActiveBoundary] = useState('start') + const [dateInput, setDateInput] = useState({ + start: formatDateInputValue(value.dateRange.start), + end: formatDateInputValue(value.dateRange.end) + }) + const [dateInputError, setDateInputError] = useState({ start: false, end: false }) + + useEffect(() => { + if (!open) return + const nextDraft = buildDialogDraft(value, minDate, maxDate) + setDraft(nextDraft) + setActiveBoundary('start') + setDateInput({ + start: formatDateInputValue(nextDraft.dateRange.start), + end: formatDateInputValue(nextDraft.dateRange.end) + }) + setDateInputError({ start: false, end: false }) + }, [maxDate, minDate, open, value]) + + useEffect(() => { + if (!open) return + setDateInput({ + start: formatDateInputValue(draft.dateRange.start), + end: formatDateInputValue(draft.dateRange.end) + }) + setDateInputError({ start: false, end: false }) + }, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open]) + + const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate]) + const clampStartDate = useCallback((targetDate: Date) => { + const start = startOfDay(targetDate) + if (!bounds) return start + if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate + if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate) + return start + }, [bounds]) + const clampEndDate = useCallback((targetDate: Date) => { + const end = endOfDay(targetDate) + if (!bounds) return end + if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate) + if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate + return end + }, [bounds]) + + const setRangeStart = useCallback((targetDate: Date) => { + const start = clampStartDate(targetDate) + setDraft(prev => { + const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end + return { + ...prev, + preset: 'custom', + useAllTime: false, + dateRange: { + start, + end: nextEnd + }, + panelMonth: toMonthStart(start) + } + }) + }, [clampStartDate]) + + const setRangeEnd = useCallback((targetDate: Date) => { + const end = clampEndDate(targetDate) + setDraft(prev => { + const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start + const nextEnd = end < nextStart ? endOfDay(nextStart) : end + return { + ...prev, + preset: 'custom', + useAllTime: false, + dateRange: { + start: nextStart, + end: nextEnd + }, + panelMonth: toMonthStart(targetDate) + } + }) + }, [clampEndDate, clampStartDate]) + + const applyPreset = useCallback((preset: Exclude) => { + if (preset === 'all') { + const previewRange = bounds + ? { start: bounds.minDate, end: bounds.maxDate } + : createDefaultDateRange() + setDraft(prev => ({ + ...prev, + preset, + useAllTime: true, + dateRange: previewRange, + panelMonth: toMonthStart(previewRange.start) + })) + setActiveBoundary('start') + return + } + + const range = clampSelectionToBounds({ + preset, + useAllTime: false, + dateRange: createDateRangeByPreset(preset) + }, minDate, maxDate).dateRange + setDraft(prev => ({ + ...prev, + preset, + useAllTime: false, + dateRange: range, + panelMonth: toMonthStart(range.start) + })) + setActiveBoundary('start') + }, [bounds, maxDate, minDate]) + + const commitStartFromInput = useCallback(() => { + const parsed = parseDateInputValue(dateInput.start) + if (!parsed) { + setDateInputError(prev => ({ ...prev, start: true })) + return + } + setDateInputError(prev => ({ ...prev, start: false })) + setRangeStart(parsed) + }, [dateInput.start, setRangeStart]) + + const commitEndFromInput = useCallback(() => { + const parsed = parseDateInputValue(dateInput.end) + if (!parsed) { + setDateInputError(prev => ({ ...prev, end: true })) + return + } + setDateInputError(prev => ({ ...prev, end: false })) + setRangeEnd(parsed) + }, [dateInput.end, setRangeEnd]) + + const shiftPanelMonth = useCallback((delta: number) => { + setDraft(prev => ({ + ...prev, + panelMonth: addMonths(prev.panelMonth, delta) + })) + }, []) + + const handleCalendarSelect = useCallback((targetDate: Date) => { + if (activeBoundary === 'start') { + setRangeStart(targetDate) + setActiveBoundary('end') + return + } + + setDraft(prev => { + const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start + const pickedStart = startOfDay(targetDate) + const nextStart = pickedStart <= start ? pickedStart : start + const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate) + return { + ...prev, + preset: 'custom', + useAllTime: false, + dateRange: { + start: nextStart, + end: nextEnd + }, + panelMonth: toMonthStart(targetDate) + } + }) + setActiveBoundary('start') + }, [activeBoundary, setRangeEnd, setRangeStart]) + + const isRangeModeActive = !draft.useAllTime + const modeText = isRangeModeActive + ? '当前导出模式:按时间范围导出' + : '当前导出模式:全部时间导出,选择下方日期会切换为自定义时间范围' + + const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => { + if (preset === 'all') return draft.useAllTime + return !draft.useAllTime && draft.preset === preset + }, [draft]) + + const calendarCells = useMemo(() => buildCalendarCells(draft.panelMonth), [draft.panelMonth]) + const minPanelMonth = bounds ? toMonthStart(bounds.minDate) : null + const maxPanelMonth = bounds ? toMonthStart(bounds.maxDate) : null + const canShiftPrev = !minPanelMonth || draft.panelMonth.getTime() > minPanelMonth.getTime() + const canShiftNext = !maxPanelMonth || draft.panelMonth.getTime() < maxPanelMonth.getTime() + + const isStartSelected = useCallback((date: Date) => ( + !draft.useAllTime && isSameDay(date, draft.dateRange.start) + ), [draft]) + + const isEndSelected = useCallback((date: Date) => ( + !draft.useAllTime && isSameDay(date, draft.dateRange.end) + ), [draft]) + + const isDateInRange = useCallback((date: Date) => ( + !draft.useAllTime && + startOfDay(date).getTime() >= startOfDay(draft.dateRange.start).getTime() && + startOfDay(date).getTime() <= startOfDay(draft.dateRange.end).getTime() + ), [draft]) + + const isDateSelectable = useCallback((date: Date) => { + if (!bounds) return true + const target = startOfDay(date).getTime() + return target >= startOfDay(bounds.minDate).getTime() && target <= startOfDay(bounds.maxDate).getTime() + }, [bounds]) + + const hintText = draft.useAllTime + ? '选择开始或结束日期后,会自动切换为自定义时间范围' + : (activeBoundary === 'start' ? '下一次点击将设置开始日期' : '下一次点击将设置结束日期') + + if (!open) return null + + return createPortal( +
+
event.stopPropagation()}> +
+

{title}

+ +
+ +
+ {EXPORT_DATE_RANGE_PRESETS.map((preset) => { + const active = isPresetActive(preset.value) + return ( + + ) + })} +
+ +
+ {modeText} +
+ +
+
setActiveBoundary('start')} + > + 开始 + { + const nextValue = event.target.value + setDateInput(prev => ({ ...prev, start: nextValue })) + if (dateInputError.start) { + setDateInputError(prev => ({ ...prev, start: false })) + } + }} + onFocus={() => setActiveBoundary('start')} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key !== 'Enter') return + event.preventDefault() + commitStartFromInput() + }} + onBlur={commitStartFromInput} + /> +
+
setActiveBoundary('end')} + > + 结束 + { + const nextValue = event.target.value + setDateInput(prev => ({ ...prev, end: nextValue })) + if (dateInputError.end) { + setDateInputError(prev => ({ ...prev, end: false })) + } + }} + onFocus={() => setActiveBoundary('end')} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key !== 'Enter') return + event.preventDefault() + commitEndFromInput() + }} + onBlur={commitEndFromInput} + /> +
+
+ +
{hintText}
+ +
+
+
+ 选择日期范围 + {formatCalendarMonthTitle(draft.panelMonth)} +
+
+ + +
+
+
+ {WEEKDAY_SHORT_LABELS.map(label => ( + {label} + ))} +
+
+ {calendarCells.map((cell) => { + const startSelected = isStartSelected(cell.date) + const endSelected = isEndSelected(cell.date) + const inRange = isDateInRange(cell.date) + const selectable = isDateSelectable(cell.date) + return ( + + ) + })} +
+
+ +
+ + +
+
+
, + document.body + ) +} diff --git a/src/components/Export/ExportDefaultsSettingsForm.scss b/src/components/Export/ExportDefaultsSettingsForm.scss new file mode 100644 index 0000000..c24b44f --- /dev/null +++ b/src/components/Export/ExportDefaultsSettingsForm.scss @@ -0,0 +1,459 @@ +.export-defaults-settings-form { + .form-group { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + } + + label { + display: block; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 2px; + } + + .form-hint { + display: block; + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 8px; + } + + .select-field { + position: relative; + margin-bottom: 10px; + } + + .select-trigger { + width: 100%; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--text-tertiary); + } + + &.open { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); + } + } + + .select-value { + flex: 1; + min-width: 0; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .select-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 6px; + box-shadow: var(--shadow-md); + z-index: 120; + max-height: 320px; + overflow-y: auto; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + } + + .select-option { + width: 100%; + text-align: left; + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border: none; + border-radius: 10px; + background: transparent; + cursor: pointer; + transition: all 0.15s; + color: var(--text-primary); + font-size: 14px; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + } + } + + .option-label { + font-weight: 500; + } + + .option-desc { + font-size: 12px; + color: var(--text-tertiary); + } + + .format-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(156px, 1fr)); + gap: 6px; + width: 100%; + margin-bottom: 10px; + } + + .format-card { + width: 100%; + min-height: 0; + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 8px 10px; + text-align: left; + background: var(--bg-primary); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + transition: border-color 0.2s ease, background 0.2s ease; + + &:hover { + border-color: var(--text-tertiary); + } + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + } + } + + .format-label { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.35; + } + + .format-desc { + margin-top: 1px; + font-size: 11px; + color: var(--text-tertiary); + line-height: 1.35; + } + + .select-option.active .option-desc { + color: var(--primary); + } + + .settings-time-range-field { + margin-bottom: 10px; + } + + .settings-time-range-trigger { + width: 100%; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: rgba(var(--primary-rgb), 0.45); + color: var(--primary); + } + + &.open { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); + } + } + + .settings-time-range-value { + flex: 1; + min-width: 0; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .settings-time-range-arrow { + color: var(--text-tertiary); + font-weight: 700; + line-height: 1; + } + + .log-toggle-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; + padding: 10px 14px; + border: 1px solid var(--border-color); + border-radius: 14px; + background: var(--bg-primary); + } + + .media-default-grid { + width: 100%; + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 12px; + margin-bottom: 10px; + + label { + display: inline-flex; + align-items: center; + gap: 5px; + margin-bottom: 0; + font-size: 13px; + line-height: 1; + font-weight: 500; + color: var(--text-primary); + cursor: pointer; + white-space: nowrap; + } + + input[type='checkbox'] { + margin: 0; + accent-color: var(--primary); + } + } + + .log-status { + font-size: 13px; + color: var(--text-secondary); + } + + .concurrency-inline-options { + width: 100%; + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 6px; + margin-bottom: 10px; + } + + .concurrency-option { + border: 1px solid var(--border-color); + border-radius: 10px; + min-height: 38px; + padding: 0; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease; + + &:hover { + border-color: var(--text-tertiary); + } + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + color: var(--primary); + } + } + + .switch { + position: relative; + display: inline-flex; + width: 48px; + height: 28px; + cursor: pointer; + flex-shrink: 0; + } + + .switch-input { + opacity: 0; + width: 0; + height: 0; + position: absolute; + + &:checked + .switch-slider { + background: var(--primary); + } + + &:checked + .switch-slider::before { + transform: translateX(20px); + } + + &:focus + .switch-slider { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); + } + } + + .switch-slider { + position: absolute; + inset: 0; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 999px; + transition: all 0.2s ease; + + &::before { + content: ''; + position: absolute; + width: 20px; + height: 20px; + left: 3px; + top: 3px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); + transition: transform 0.2s ease; + } + } + + &.layout-split { + .form-group { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); + gap: 18px; + align-items: center; + padding: 14px 0; + margin-bottom: 0; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); + } + + .form-group:last-child { + border-bottom: none; + padding-bottom: 0; + } + + .form-group:first-child { + padding-top: 0; + } + + .form-copy { + min-width: 0; + } + + .form-control { + min-width: 0; + display: flex; + justify-content: flex-end; + } + + .form-hint { + margin-bottom: 0; + line-height: 1.5; + } + + .select-field, + .settings-time-range-field { + width: 100%; + max-width: 360px; + margin-bottom: 0; + } + + .log-toggle-line { + width: 100%; + max-width: 360px; + margin-bottom: 0; + } + + .media-default-grid { + max-width: 360px; + margin-bottom: 0; + } + + .concurrency-inline-options { + max-width: 360px; + margin-bottom: 0; + } + + .format-setting-group { + grid-template-columns: 1fr; + gap: 10px; + align-items: stretch; + } + + .format-setting-group .form-control { + justify-content: flex-start; + } + + .format-grid { + max-width: none; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 0; + } + } +} + +@media (max-width: 1024px) { + .export-defaults-settings-form.layout-split { + .media-setting-group { + grid-template-columns: 1fr; + gap: 10px; + align-items: stretch; + } + + .media-setting-group .form-control { + justify-content: flex-start; + } + + .media-default-grid { + max-width: none; + flex-wrap: wrap; + } + } +} + +@media (max-width: 760px) { + .export-defaults-settings-form.layout-split { + .form-group { + grid-template-columns: 1fr; + gap: 10px; + } + + .form-control { + justify-content: flex-start; + } + + .select-field, + .settings-time-range-field, + .log-toggle-line, + .media-default-grid, + .concurrency-inline-options, + .format-grid { + max-width: none; + } + + .media-default-grid { + flex-wrap: wrap; + } + + .format-grid { + grid-template-columns: repeat(auto-fit, minmax(156px, 1fr)); + } + } +} diff --git a/src/components/Export/ExportDefaultsSettingsForm.tsx b/src/components/Export/ExportDefaultsSettingsForm.tsx new file mode 100644 index 0000000..17090e2 --- /dev/null +++ b/src/components/Export/ExportDefaultsSettingsForm.tsx @@ -0,0 +1,389 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import * as configService from '../../services/config' +import { ExportDateRangeDialog } from './ExportDateRangeDialog' +import { + createDefaultExportDateRangeSelection, + getExportDateRangeLabel, + resolveExportDateRangeConfig, + serializeExportDateRangeConfig, + type ExportDateRangeSelection +} from '../../utils/exportDateRange' +import './ExportDefaultsSettingsForm.scss' + +export interface ExportDefaultsSettingsPatch { + format?: string + avatars?: boolean + dateRange?: ExportDateRangeSelection + media?: configService.ExportDefaultMediaConfig + voiceAsText?: boolean + excelCompactColumns?: boolean + concurrency?: number +} + +interface ExportDefaultsSettingsFormProps { + onNotify?: (text: string, success: boolean) => void + onDefaultsChanged?: (patch: ExportDefaultsSettingsPatch) => void + layout?: 'stacked' | 'split' +} + +const exportFormatOptions = [ + { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' }, + { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, + { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, + { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, + { value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' }, + { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, + { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, + { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }, + { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } +] as const + +const exportExcelColumnOptions = [ + { value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' }, + { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' } +] as const + +const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const + +const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => { + return options.find((option) => option.value === value)?.label ?? value +} + +export function ExportDefaultsSettingsForm({ + onNotify, + onDefaultsChanged, + layout = 'stacked' +}: ExportDefaultsSettingsFormProps) { + const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) + const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) + const exportExcelColumnsDropdownRef = useRef(null) + + const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') + const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true) + const [exportDefaultDateRange, setExportDefaultDateRange] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultMedia, setExportDefaultMedia] = useState({ + images: true, + videos: true, + voices: true, + emojis: true + }) + const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) + const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) + const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) + + useEffect(() => { + let cancelled = false + void (async () => { + const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([ + configService.getExportDefaultFormat(), + configService.getExportDefaultAvatars(), + configService.getExportDefaultDateRange(), + configService.getExportDefaultMedia(), + configService.getExportDefaultVoiceAsText(), + configService.getExportDefaultExcelCompactColumns(), + configService.getExportDefaultConcurrency() + ]) + + if (cancelled) return + + setExportDefaultFormat(savedFormat || 'excel') + setExportDefaultAvatars(savedAvatars ?? true) + setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange)) + setExportDefaultMedia(savedMedia ?? { + images: true, + videos: true, + voices: true, + emojis: true + }) + setExportDefaultVoiceAsText(savedVoiceAsText ?? false) + setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) + setExportDefaultConcurrency(savedConcurrency ?? 2) + })() + + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node + if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { + setShowExportExcelColumnsSelect(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showExportExcelColumnsSelect]) + + const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' + const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange]) + const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue]) + + const notify = (text: string, success = true) => { + onNotify?.(text, success) + } + + return ( +
+
+
+ + 导出多个会话时的最大并发(1~6) +
+
+
+ {exportConcurrencyOptions.map((option) => ( + + ))} +
+
+
+ +
+
+ + 导出页面默认选中的格式 +
+
+
+ {exportFormatOptions.map((option) => ( + + ))} +
+
+
+ +
+
+ + 开启后导出的聊天消息对应的文件中会带头像信息。 +
+
+
+ {exportDefaultAvatars ? '已开启' : '已关闭'} + +
+
+
+ +
+
+ + 控制导出页面的默认时间选择 +
+
+
+ +
+
+
+ + setIsExportDateRangeDialogOpen(false)} + onConfirm={async (nextSelection) => { + setExportDefaultDateRange(nextSelection) + await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection)) + onDefaultsChanged?.({ dateRange: nextSelection }) + notify('已更新默认导出时间范围', true) + setIsExportDateRangeDialogOpen(false) + }} + /> + +
+
+ + 控制 Excel 导出的列字段 +
+
+
+ + {showExportExcelColumnsSelect && ( +
+ {exportExcelColumnOptions.map((option) => ( + + ))} +
+ )} +
+
+
+ +
+
+ + 控制图片、视频、语音、表情包的默认导出开关 +
+
+
+ + + + +
+
+
+ +
+
+ + 导出时默认将语音转写为文字 +
+
+
+ {exportDefaultVoiceAsText ? '已开启' : '已关闭'} + +
+
+
+ +
+ ) +} diff --git a/src/components/GlobalSessionMonitor.tsx b/src/components/GlobalSessionMonitor.tsx index d0cce60..a8f65b0 100644 --- a/src/components/GlobalSessionMonitor.tsx +++ b/src/components/GlobalSessionMonitor.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import { useChatStore } from '../stores/chatStore' -import type { ChatSession } from '../types/models' +import type { ChatSession, Message } from '../types/models' import { useNavigate } from 'react-router-dom' export function GlobalSessionMonitor() { @@ -20,9 +20,9 @@ export function GlobalSessionMonitor() { }, [sessions]) // 去重辅助函数:获取消息 key - const getMessageKey = (msg: any) => { - if (msg.localId && msg.localId > 0) return `l:${msg.localId}` - return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}` + const getMessageKey = (msg: Message) => { + if (msg.messageKey) return msg.messageKey + return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}` } // 处理数据库变更 @@ -46,7 +46,6 @@ export function GlobalSessionMonitor() { return () => { removeListener() } - } else { } return () => { } }, []) @@ -198,11 +197,12 @@ export function GlobalSessionMonitor() { // 尝试丰富或获取联系人详情 const contact = await window.electronAPI.chat.getContact(newSession.username) if (contact) { - if (contact.remark || contact.nickname) { - title = contact.remark || contact.nickname + if (contact.remark || contact.nickName) { + title = contact.remark || contact.nickName } - if (contact.avatarUrl) { - avatarUrl = contact.avatarUrl + const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username) + if (avatarResult?.avatarUrl) { + avatarUrl = avatarResult.avatarUrl } } else { // 如果不在缓存/数据库中 @@ -222,8 +222,11 @@ export function GlobalSessionMonitor() { if (title === newSession.username || title.startsWith('wxid_')) { const retried = await window.electronAPI.chat.getContact(newSession.username) if (retried) { - title = retried.remark || retried.nickname || title - avatarUrl = retried.avatarUrl || avatarUrl + title = retried.remark || retried.nickName || title + const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username) + if (retriedAvatar?.avatarUrl) { + avatarUrl = retriedAvatar.avatarUrl + } } } } @@ -264,7 +267,12 @@ export function GlobalSessionMonitor() { try { const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime) if (result.success && result.messages && result.messages.length > 0) { - appendMessages(result.messages, false) // 追加到末尾 + const latestMessages = useChatStore.getState().messages || [] + const existingKeys = new Set(latestMessages.map(getMessageKey)) + const newMessages = result.messages.filter((msg: Message) => !existingKeys.has(getMessageKey(msg))) + if (newMessages.length > 0) { + appendMessages(newMessages, false) + } } } catch (e) { console.warn('后台活跃会话刷新失败:', e) diff --git a/src/components/JumpToDatePopover.scss b/src/components/JumpToDatePopover.scss new file mode 100644 index 0000000..f9839a6 --- /dev/null +++ b/src/components/JumpToDatePopover.scss @@ -0,0 +1,170 @@ +.jump-date-popover { + position: absolute; + top: calc(100% + 10px); + right: 0; + width: 312px; + border-radius: 14px; + border: 1px solid var(--border-color); + background: none; + background-color: var(--bg-secondary-solid, #ffffff) !important; + opacity: 1; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + mix-blend-mode: normal; + isolation: isolate; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2); + padding: 12px; + z-index: 1600; +} + +.jump-date-popover .calendar-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.jump-date-popover .current-month { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.jump-date-popover .nav-btn { + width: 28px; + height: 28px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: none; + background-color: var(--bg-secondary-solid, #ffffff) !important; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.18s ease; +} + +.jump-date-popover .nav-btn:hover { + border-color: var(--primary); + color: var(--primary); + background: var(--bg-hover); +} + +.jump-date-popover .status-line { + min-height: 16px; + margin-bottom: 6px; +} + +.jump-date-popover .status-item { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--text-tertiary); + font-size: 11px; +} + +.jump-date-popover .calendar-grid .weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + margin-bottom: 6px; +} + +.jump-date-popover .calendar-grid .weekday { + text-align: center; + font-size: 11px; + color: var(--text-tertiary); +} + +.jump-date-popover .calendar-grid .days { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-template-rows: repeat(6, 36px); + gap: 4px; +} + +.jump-date-popover .day-cell { + position: relative; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; + padding: 0; + font-size: 13px; + transition: all 0.18s ease; +} + +.jump-date-popover .day-cell .day-number { + position: relative; + z-index: 1; + font-size: 12px; + line-height: 1; + font-weight: 500; +} + +.jump-date-popover .day-cell.empty { + cursor: default; + background: transparent; +} + +.jump-date-popover .day-cell:not(.empty):not(.no-message):hover { + background: var(--bg-hover); +} + +.jump-date-popover .day-cell.today { + border-color: var(--primary-light); + color: var(--primary); +} + +.jump-date-popover .day-cell.selected { + background: var(--primary); + color: #fff; +} + +.jump-date-popover .day-cell.no-message { + opacity: 0.5; + cursor: default; +} + +.jump-date-popover .day-count { + position: static; + margin-top: 1px; + font-size: 13px; + line-height: 1; + color: var(--primary, #07c160); + font-weight: 700; +} + +.jump-date-popover .day-cell.selected .day-count { + color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%); +} + +.jump-date-popover .day-count-loading { + position: static; + margin-top: 1px; + color: var(--primary, #07c160); +} + +.jump-date-popover .day-cell.selected .day-count-loading { + color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%); +} + +.jump-date-popover .spin { + animation: jump-date-spin 1s linear infinite; +} + +@keyframes jump-date-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} diff --git a/src/components/JumpToDatePopover.tsx b/src/components/JumpToDatePopover.tsx new file mode 100644 index 0000000..ef3c807 --- /dev/null +++ b/src/components/JumpToDatePopover.tsx @@ -0,0 +1,191 @@ +import React, { useEffect, useState } from 'react' +import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react' +import './JumpToDatePopover.scss' + +interface JumpToDatePopoverProps { + isOpen: boolean + onClose: () => void + onSelect: (date: Date) => void + onMonthChange?: (date: Date) => void + className?: string + style?: React.CSSProperties + currentDate?: Date + messageDates?: Set + hasLoadedMessageDates?: boolean + messageDateCounts?: Record + loadingDates?: boolean + loadingDateCounts?: boolean +} + +const JumpToDatePopover: React.FC = ({ + isOpen, + onClose, + onSelect, + onMonthChange, + className, + style, + currentDate = new Date(), + messageDates, + hasLoadedMessageDates = false, + messageDateCounts, + loadingDates = false, + loadingDateCounts = false +}) => { + const [calendarDate, setCalendarDate] = useState(new Date(currentDate)) + const [selectedDate, setSelectedDate] = useState(new Date(currentDate)) + + useEffect(() => { + if (!isOpen) return + const normalized = new Date(currentDate) + setCalendarDate(normalized) + setSelectedDate(normalized) + }, [isOpen, currentDate]) + + if (!isOpen) return null + + const getDaysInMonth = (date: Date): number => { + const year = date.getFullYear() + const month = date.getMonth() + return new Date(year, month + 1, 0).getDate() + } + + const getFirstDayOfMonth = (date: Date): number => { + const year = date.getFullYear() + const month = date.getMonth() + return new Date(year, month, 1).getDay() + } + + const toDateKey = (day: number): string => { + const year = calendarDate.getFullYear() + const month = calendarDate.getMonth() + 1 + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` + } + + const hasMessage = (day: number): boolean => { + if (!hasLoadedMessageDates) return true + if (!messageDates || messageDates.size === 0) return false + return messageDates.has(toDateKey(day)) + } + + const isToday = (day: number): boolean => { + const today = new Date() + return day === today.getDate() + && calendarDate.getMonth() === today.getMonth() + && calendarDate.getFullYear() === today.getFullYear() + } + + const isSelected = (day: number): boolean => { + return day === selectedDate.getDate() + && calendarDate.getMonth() === selectedDate.getMonth() + && calendarDate.getFullYear() === selectedDate.getFullYear() + } + + const generateCalendar = (): Array => { + const daysInMonth = getDaysInMonth(calendarDate) + const firstDay = getFirstDayOfMonth(calendarDate) + const days: Array = [] + + for (let i = 0; i < firstDay; i++) { + days.push(null) + } + for (let i = 1; i <= daysInMonth; i++) { + days.push(i) + } + return days + } + + const handleDateClick = (day: number) => { + if (hasLoadedMessageDates && !hasMessage(day)) return + const targetDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day) + setSelectedDate(targetDate) + onSelect(targetDate) + onClose() + } + + const getDayClassName = (day: number | null): string => { + if (day === null) return 'day-cell empty' + const classes = ['day-cell'] + if (isToday(day)) classes.push('today') + if (isSelected(day)) classes.push('selected') + if (hasLoadedMessageDates && !hasMessage(day)) classes.push('no-message') + return classes.join(' ') + } + + const weekdays = ['日', '一', '二', '三', '四', '五', '六'] + const days = generateCalendar() + const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim() + const updateCalendarDate = (nextDate: Date) => { + setCalendarDate(nextDate) + onMonthChange?.(nextDate) + } + + return ( +
+
+ + {calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月 + +
+ +
+ {loadingDates && ( + + + 日期加载中 + + )} + {!loadingDates && loadingDateCounts && ( + + + 条数加载中 + + )} +
+ +
+
+ {weekdays.map(day => ( +
{day}
+ ))} +
+
+ {days.map((day, index) => { + if (day === null) return
+ const dateKey = toDateKey(day) + const hasMessageOnDay = hasMessage(day) + const count = Number(messageDateCounts?.[dateKey] || 0) + const showCount = count > 0 + const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount + return ( + + ) + })} +
+
+
+ ) +} + +export default JumpToDatePopover diff --git a/src/components/NotificationToast.scss b/src/components/NotificationToast.scss index a01ab73..57dc558 100644 --- a/src/components/NotificationToast.scss +++ b/src/components/NotificationToast.scss @@ -134,6 +134,25 @@ } } + &.top-center { + top: 24px; + left: 50%; + transform: translate(-50%, -20px) scale(0.95); + + &.visible { + transform: translate(-50%, 0) scale(1); + } + + // 灵动岛样式 + border-radius: 40px !important; + padding: 12px 16px; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2); + + &.static { + border-radius: 40px !important; + } + } + &:hover { box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important; } diff --git a/src/components/NotificationToast.tsx b/src/components/NotificationToast.tsx index 886a878..f394f6c 100644 --- a/src/components/NotificationToast.tsx +++ b/src/components/NotificationToast.tsx @@ -18,7 +18,7 @@ interface NotificationToastProps { onClose: () => void onClick: (sessionId: string) => void duration?: number - position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' + position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' isStatic?: boolean initialVisible?: boolean } diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index d2a1b7f..31d4725 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -10,6 +10,19 @@ &.collapsed { width: 64px; + .sidebar-user-card-wrap { + margin: 0 8px 8px; + } + + .sidebar-user-card { + padding: 8px 0; + justify-content: center; + + .user-meta { + display: none; + } + } + .nav-menu, .sidebar-footer { padding: 0 8px; @@ -27,6 +40,150 @@ } } +.sidebar-user-card-wrap { + position: relative; + margin: 0 12px 10px; + --sidebar-user-menu-width: 172px; +} + +.sidebar-user-menu { + position: absolute; + left: 0; + right: auto; + bottom: calc(100% + 8px); + width: max(100%, var(--sidebar-user-menu-width)); + z-index: 12; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-secondary-solid, var(--bg-primary)); + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12); + opacity: 0; + transform: translateY(8px) scale(0.95); + pointer-events: none; + transition: opacity 0.2s ease, transform 0.2s ease; + + &.open { + opacity: 1; + transform: translateY(0) scale(1); + pointer-events: auto; + } +} + +.sidebar-user-menu-item { + width: 100%; + border: none; + border-radius: 10px; + background: transparent; + color: var(--text-primary); + padding: 9px 10px; + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + text-align: left; + transition: background 0.2s ease, color 0.2s ease; + + &:hover { + background: var(--bg-tertiary); + } + + &.danger { + color: #d93025; + + &:hover { + background: rgba(255, 59, 48, 0.08); + } + } +} + +.sidebar-user-card { + width: 100%; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-secondary); + display: flex; + align-items: center; + gap: 10px; + min-height: 56px; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; + + &:hover { + border-color: rgba(99, 102, 241, 0.32); + background: var(--bg-tertiary); + } + + &.menu-open { + border-color: rgba(99, 102, 241, 0.44); + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.12); + } + + .user-avatar { + width: 36px; + height: 36px; + border-radius: 10px; + overflow: hidden; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: var(--on-primary); + font-size: 14px; + font-weight: 600; + } + } + + .user-meta { + min-width: 0; + flex: 1; + } + + .user-name { + font-size: 13px; + color: var(--text-primary); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .user-wxid { + margin-top: 2px; + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .user-menu-caret { + color: var(--text-tertiary); + display: inline-flex; + transition: transform 0.2s ease, color 0.2s ease; + + &.open { + transform: rotate(180deg); + color: var(--text-secondary); + } + } +} + .nav-menu { flex: 1; display: flex; @@ -57,7 +214,7 @@ &.active { background: var(--primary); - color: white; + color: var(--on-primary); } } @@ -70,11 +227,44 @@ flex-shrink: 0; } +.nav-icon-with-badge { + position: relative; +} + .nav-label { font-size: 14px; font-weight: 500; } +.nav-badge { + margin-left: auto; + min-width: 20px; + height: 20px; + border-radius: 999px; + padding: 0 6px; + background: #ff3b30; + color: #ffffff; + font-size: 11px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + box-shadow: 0 0 0 2px rgba(255, 59, 48, 0.18); +} + +.nav-badge.icon-badge { + position: absolute; + top: -7px; + right: -10px; + margin-left: 0; + min-width: 16px; + height: 16px; + padding: 0 4px; + font-size: 10px; + box-shadow: 0 0 0 2px var(--bg-secondary); +} + .sidebar-footer { padding: 0 12px; border-top: 1px solid var(--border-color); @@ -85,24 +275,261 @@ gap: 4px; } -.collapse-btn { +.sidebar-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.3); display: flex; align-items: center; justify-content: center; - width: 100%; - padding: 8px; - border: none; - background: transparent; - color: var(--text-tertiary); - cursor: pointer; - border-radius: 9999px; - transition: all 0.2s ease; - margin-top: 4px; + z-index: 1100; + padding: 20px; + animation: fadeIn 0.2s ease; +} - &:hover { - background: var(--bg-tertiary); +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.sidebar-dialog { + width: min(420px, 100%); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 16px; + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24); + padding: 18px 18px 16px; + animation: slideUp 0.25s ease; + + h3 { + margin: 0; + font-size: 16px; color: var(--text-primary); } + + p { + margin: 10px 0 0; + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.sidebar-wxid-list { + margin-top: 14px; + display: flex; + flex-direction: column; + gap: 8px; + max-height: 300px; + overflow-y: auto; +} + +.sidebar-wxid-item { + width: 100%; + padding: 12px 14px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + gap: 12px; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + border-color: rgba(99, 102, 241, 0.32); + background: var(--bg-tertiary); + } + + &.current { + border-color: rgba(99, 102, 241, 0.5); + background: var(--bg-tertiary); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + .wxid-avatar { + width: 40px; + height: 40px; + border-radius: 10px; + overflow: hidden; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: var(--on-primary); + font-size: 16px; + font-weight: 600; + } + } + + .wxid-info { + flex: 1; + min-width: 0; + text-align: left; + } + + .wxid-name { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .wxid-id { + margin-top: 2px; + font-size: 12px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .current-badge { + padding: 4px 10px; + border-radius: 6px; + background: var(--primary); + color: var(--on-primary); + font-size: 11px; + font-weight: 600; + flex-shrink: 0; + } +} + +.sidebar-dialog-actions { + margin-top: 18px; + display: flex; + justify-content: flex-end; + gap: 10px; + + button { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 8px 14px; + font-size: 13px; + cursor: pointer; + background: var(--bg-secondary); + color: var(--text-primary); + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background: var(--bg-tertiary); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + } +} + +.sidebar-clear-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.3); + display: flex; + align-items: center; + justify-content: center; + z-index: 1100; + padding: 20px; + animation: fadeIn 0.2s ease; +} + +.sidebar-clear-dialog { + width: min(460px, 100%); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 16px; + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24); + padding: 18px 18px 16px; + animation: slideUp 0.25s ease; + + h3 { + margin: 0; + font-size: 16px; + color: var(--text-primary); + } + + p { + margin: 10px 0 0; + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); + } +} + +.sidebar-clear-options { + margin-top: 14px; + display: flex; + gap: 14px; + flex-wrap: wrap; + + label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-primary); + } +} + +.sidebar-clear-actions { + margin-top: 18px; + display: flex; + justify-content: flex-end; + gap: 10px; + + button { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 8px 14px; + font-size: 13px; + cursor: pointer; + background: var(--bg-secondary); + color: var(--text-primary); + } + + button:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + .danger { + border-color: #ef4444; + background: #ef4444; + color: #fff; + } } // 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色 @@ -130,4 +557,4 @@ background: rgba(209, 158, 187, 0.15); color: #D19EBB; border: 1px solid rgba(209, 158, 187, 0.2); -} \ No newline at end of file +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 0085b6d..6b14cb4 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,142 +1,585 @@ -import { useState, useEffect } from 'react' -import { NavLink, useLocation } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react' +import { useState, useEffect, useRef } from 'react' +import { NavLink, useLocation, useNavigate } from 'react-router-dom' +import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw } from 'lucide-react' import { useAppStore } from '../stores/appStore' +import { useChatStore } from '../stores/chatStore' +import { useAnalyticsStore } from '../stores/analyticsStore' +import * as configService from '../services/config' +import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' +import { UserRound } from 'lucide-react' import './Sidebar.scss' -function Sidebar() { +interface SidebarUserProfile { + wxid: string + displayName: string + alias?: string + avatarUrl?: string +} + +const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' +const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1' + +interface SidebarUserProfileCache extends SidebarUserProfile { + updatedAt: number +} + +interface AccountProfilesCache { + [wxid: string]: { + displayName: string + avatarUrl?: string + alias?: string + updatedAt: number + } +} + +interface WxidOption { + wxid: string + modifiedTime: number + nickname?: string + displayName?: string + avatarUrl?: string +} + +const readSidebarUserProfileCache = (): SidebarUserProfile | null => { + try { + const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as SidebarUserProfileCache + if (!parsed || typeof parsed !== 'object') return null + if (!parsed.wxid || !parsed.displayName) return null + return { + wxid: parsed.wxid, + displayName: parsed.displayName, + alias: parsed.alias, + avatarUrl: parsed.avatarUrl + } + } catch { + return null + } +} + +const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => { + if (!profile.wxid || !profile.displayName) return + try { + const payload: SidebarUserProfileCache = { + ...profile, + updatedAt: Date.now() + } + window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload)) + + // 同时写入账号缓存池 + const accountsCache = readAccountProfilesCache() + accountsCache[profile.wxid] = { + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + alias: profile.alias, + updatedAt: Date.now() + } + window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountsCache)) + } catch { + // 忽略本地缓存失败,不影响主流程 + } +} + +const readAccountProfilesCache = (): AccountProfilesCache => { + try { + const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) + return typeof parsed === 'object' && parsed ? parsed : {} + } catch { + return {} + } +} + +const normalizeAccountId = (value?: string | null): string => { + const trimmed = String(value || '').trim() + if (!trimmed) return '' + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + return match?.[1] || trimmed + } + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed +} + +interface SidebarProps { + collapsed: boolean +} + +function Sidebar({ collapsed }: SidebarProps) { const location = useLocation() - const [collapsed, setCollapsed] = useState(false) + const navigate = useNavigate() const [authEnabled, setAuthEnabled] = useState(false) + const [activeExportTaskCount, setActiveExportTaskCount] = useState(0) + const [userProfile, setUserProfile] = useState({ + wxid: '', + displayName: '未识别用户' + }) + const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) + const [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false) + const [wxidOptions, setWxidOptions] = useState([]) + const [isSwitchingAccount, setIsSwitchingAccount] = useState(false) + const accountCardWrapRef = useRef(null) const setLocked = useAppStore(state => state.setLocked) + const isDbConnected = useAppStore(state => state.isDbConnected) + const resetChatStore = useChatStore(state => state.reset) + const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache) useEffect(() => { window.electronAPI.auth.verifyEnabled().then(setAuthEnabled) }, []) + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (!isAccountMenuOpen) return + const target = event.target as Node | null + if (accountCardWrapRef.current && target && !accountCardWrapRef.current.contains(target)) { + setIsAccountMenuOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [isAccountMenuOpen]) + + useEffect(() => { + const unsubscribe = onExportSessionStatus((payload) => { + const countFromPayload = typeof payload?.activeTaskCount === 'number' + ? payload.activeTaskCount + : Array.isArray(payload?.inProgressSessionIds) + ? payload.inProgressSessionIds.length + : 0 + const normalized = Math.max(0, Math.floor(countFromPayload)) + setActiveExportTaskCount(normalized) + }) + + requestExportSessionStatus() + const timer = window.setTimeout(() => requestExportSessionStatus(), 120) + + return () => { + unsubscribe() + window.clearTimeout(timer) + } + }, []) + + useEffect(() => { + const loadCurrentUser = async () => { + const patchUserProfile = (patch: Partial, expectedWxid?: string) => { + setUserProfile(prev => { + if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) { + return prev + } + const next: SidebarUserProfile = { + ...prev, + ...patch + } + if (!next.displayName) { + next.displayName = next.wxid || '未识别用户' + } + writeSidebarUserProfileCache(next) + return next + }) + } + + try { + const wxid = await configService.getMyWxid() + const resolvedWxidRaw = String(wxid || '').trim() + const cleanedWxid = normalizeAccountId(resolvedWxidRaw) + const resolvedWxid = cleanedWxid || resolvedWxidRaw + + if (!resolvedWxidRaw && !resolvedWxid) return + + const wxidCandidates = new Set([ + resolvedWxidRaw.toLowerCase(), + resolvedWxid.trim().toLowerCase(), + cleanedWxid.trim().toLowerCase() + ].filter(Boolean)) + + const normalizeName = (value?: string | null): string | undefined => { + if (!value) return undefined + const trimmed = value.trim() + if (!trimmed) return undefined + const lowered = trimmed.toLowerCase() + if (lowered === 'self') return undefined + if (lowered.startsWith('wxid_')) return undefined + if (wxidCandidates.has(lowered)) return undefined + return trimmed + } + + const pickFirstValidName = (...candidates: Array): string | undefined => { + for (const candidate of candidates) { + const normalized = normalizeName(candidate) + if (normalized) return normalized + } + return undefined + } + + // 并行获取名称和头像 + const [contactResult, avatarResult] = await Promise.allSettled([ + (async () => { + const candidates = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean))) + for (const candidate of candidates) { + const contact = await window.electronAPI.chat.getContact(candidate) + if (contact?.remark || contact?.nickName || contact?.alias) { + return contact + } + } + return null + })(), + window.electronAPI.chat.getMyAvatarUrl() + ]) + + const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null + const displayName = pickFirstValidName( + myContact?.remark, + myContact?.nickName, + myContact?.alias + ) || resolvedWxid || '未识别用户' + + patchUserProfile({ + wxid: resolvedWxid, + displayName, + alias: myContact?.alias, + avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success + ? avatarResult.value.avatarUrl + : undefined + }) + } catch (error) { + console.error('加载侧边栏用户信息失败:', error) + } + } + + const cachedProfile = readSidebarUserProfileCache() + if (cachedProfile) { + setUserProfile(cachedProfile) + } + + void loadCurrentUser() + const onWxidChanged = () => { void loadCurrentUser() } + window.addEventListener('wxid-changed', onWxidChanged as EventListener) + return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener) + }, []) + + const getAvatarLetter = (name: string): string => { + if (!name) return '?' + return [...name][0] || '?' + } + + const openSwitchAccountDialog = async () => { + setIsAccountMenuOpen(false) + if (!isDbConnected) { + window.alert('数据库未连接,无法切换账号') + return + } + const dbPath = await configService.getDbPath() + if (!dbPath) { + window.alert('请先在设置中配置数据库路径') + return + } + try { + const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) + const accountsCache = readAccountProfilesCache() + console.log('[切换账号] 账号缓存:', accountsCache) + + const enrichedWxids = wxids.map((option: WxidOption) => { + const normalizedWxid = normalizeAccountId(option.wxid) + const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid] + + let displayName = option.nickname || option.wxid + let avatarUrl = option.avatarUrl + + if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) { + displayName = userProfile.displayName || displayName + avatarUrl = userProfile.avatarUrl || avatarUrl + } + + else if (cached) { + displayName = cached.displayName || displayName + avatarUrl = cached.avatarUrl || avatarUrl + } + + return { + ...option, + displayName, + avatarUrl + } + }) + + setWxidOptions(enrichedWxids) + setShowSwitchAccountDialog(true) + } catch (error) { + console.error('扫描账号失败:', error) + window.alert('扫描账号失败,请稍后重试') + } + } + + const handleSwitchAccount = async (selectedWxid: string) => { + if (!selectedWxid || isSwitchingAccount) return + setIsSwitchingAccount(true) + try { + console.log('[切换账号] 开始切换到:', selectedWxid) + const currentWxid = userProfile.wxid + if (currentWxid === selectedWxid) { + console.log('[切换账号] 已经是当前账号,跳过') + setShowSwitchAccountDialog(false) + setIsSwitchingAccount(false) + return + } + + console.log('[切换账号] 设置新 wxid') + await configService.setMyWxid(selectedWxid) + + console.log('[切换账号] 获取账号配置') + const wxidConfig = await configService.getWxidConfig(selectedWxid) + console.log('[切换账号] 配置内容:', wxidConfig) + if (wxidConfig?.decryptKey) { + console.log('[切换账号] 设置 decryptKey') + await configService.setDecryptKey(wxidConfig.decryptKey) + } + if (typeof wxidConfig?.imageXorKey === 'number') { + console.log('[切换账号] 设置 imageXorKey:', wxidConfig.imageXorKey) + await configService.setImageXorKey(wxidConfig.imageXorKey) + } + if (wxidConfig?.imageAesKey) { + console.log('[切换账号] 设置 imageAesKey') + await configService.setImageAesKey(wxidConfig.imageAesKey) + } + + console.log('[切换账号] 检查数据库连接状态') + console.log('[切换账号] 数据库连接状态:', isDbConnected) + if (isDbConnected) { + console.log('[切换账号] 关闭数据库连接') + await window.electronAPI.chat.close() + } + + console.log('[切换账号] 清除缓存') + window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY) + clearAnalyticsStoreCache() + resetChatStore() + + console.log('[切换账号] 触发 wxid-changed 事件') + window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } })) + + console.log('[切换账号] 切换成功') + setShowSwitchAccountDialog(false) + } catch (error) { + console.error('[切换账号] 失败:', error) + window.alert('切换账号失败,请稍后重试') + } finally { + setIsSwitchingAccount(false) + } + } + + const openSettingsFromAccountMenu = () => { + setIsAccountMenuOpen(false) + navigate('/settings', { + state: { + backgroundLocation: location + } + }) + } + const isActive = (path: string) => { return location.pathname === path || location.pathname.startsWith(`${path}/`) } + const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}` return ( - - -
- + {showSwitchAccountDialog && ( +
!isSwitchingAccount && setShowSwitchAccountDialog(false)}> +
event.stopPropagation()}> +

切换账号

+

选择要切换的微信账号

+
+ {wxidOptions.map((option) => ( + + ))} +
+
+ +
+
+
+ )} + ) } diff --git a/src/components/Sns/ContactSnsTimelineDialog.scss b/src/components/Sns/ContactSnsTimelineDialog.scss new file mode 100644 index 0000000..dd45cd0 --- /dev/null +++ b/src/components/Sns/ContactSnsTimelineDialog.scss @@ -0,0 +1,329 @@ +.contact-sns-dialog-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; + background: rgba(15, 23, 42, 0.38); +} + +.contact-sns-dialog { + width: min(760px, 100%); + max-height: min(86vh, 860px); + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-secondary-solid, #ffffff); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24); + display: flex; + flex-direction: column; + overflow: hidden; + + .spin { + animation: contactSnsDialogSpin 1s linear infinite; + } + + .contact-sns-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-color); + } + + .contact-sns-dialog-header-main { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + } + + .contact-sns-dialog-avatar { + width: 42px; + height: 42px; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + overflow: hidden; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 14px; + font-weight: 600; + } + } + + .contact-sns-dialog-meta { + min-width: 0; + + h4 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .contact-sns-dialog-username { + margin-top: 2px; + font-size: 12px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .contact-sns-dialog-stats { + margin-top: 4px; + font-size: 12px; + color: var(--text-secondary); + } + + .contact-sns-dialog-header-actions { + display: flex; + align-items: flex-start; + gap: 8px; + flex-shrink: 0; + } + + .contact-sns-dialog-rank-switch { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + } + + .contact-sns-dialog-rank-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-secondary); + height: 28px; + padding: 0 10px; + font-size: 12px; + line-height: 1; + cursor: pointer; + white-space: nowrap; + + &:hover { + color: var(--text-primary); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + } + + &.active { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 52%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + } + } + + .contact-sns-dialog-rank-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 248px; + max-height: calc((28px * 15) + 16px); + overflow-y: auto; + border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color)); + border-radius: 10px; + background: var(--bg-primary); + box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18); + padding: 8px; + z-index: 12; + } + + .contact-sns-dialog-rank-empty { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; + text-align: center; + padding: 6px 0; + } + + .contact-sns-dialog-rank-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 28px; + padding: 4px 0 8px; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + } + + .contact-sns-dialog-rank-row { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 0 4px; + border-radius: 7px; + + &:hover { + background: var(--bg-hover); + } + } + + .contact-sns-dialog-rank-index { + font-size: 12px; + color: var(--text-tertiary); + text-align: right; + font-variant-numeric: tabular-nums; + } + + .contact-sns-dialog-rank-name { + font-size: 12px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .contact-sns-dialog-rank-count { + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .contact-sns-dialog-close-btn { + border: none; + background: transparent; + color: var(--text-secondary); + width: 28px; + height: 28px; + border-radius: 7px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .contact-sns-dialog-tip { + padding: 10px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent); + background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary)); + font-size: 12px; + line-height: 1.6; + color: var(--text-secondary); + word-break: break-word; + } + + .contact-sns-dialog-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px 16px 14px; + } + + .contact-sns-dialog-posts-list { + display: flex; + flex-direction: column; + gap: 14px; + } + + .contact-sns-dialog-posts-list .post-header-actions { + display: none; + } + + .contact-sns-dialog-status { + padding: 20px 12px; + text-align: center; + font-size: 13px; + color: var(--text-secondary); + + &.empty { + color: var(--text-tertiary); + } + } + + .contact-sns-dialog-load-more { + display: block; + margin: 12px auto 0; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + border-radius: 10px; + padding: 9px 18px; + font-size: 13px; + cursor: pointer; + + &:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + color: var(--primary); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.72; + } + } +} + +@media (max-width: 768px) { + .contact-sns-dialog-overlay { + padding: 12px 8px; + } + + .contact-sns-dialog { + width: min(100vw - 16px, 760px); + max-height: calc(100vh - 24px); + + .contact-sns-dialog-header { + padding: 12px; + } + + .contact-sns-dialog-header-actions { + gap: 6px; + } + + .contact-sns-dialog-rank-btn { + height: 26px; + padding: 0 8px; + font-size: 11px; + } + + .contact-sns-dialog-rank-panel { + width: min(78vw, 232px); + } + + .contact-sns-dialog-tip { + padding: 10px 12px; + line-height: 1.55; + } + + .contact-sns-dialog-body { + padding: 10px 10px 12px; + } + } +} + +@keyframes contactSnsDialogSpin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} diff --git a/src/components/Sns/ContactSnsTimelineDialog.tsx b/src/components/Sns/ContactSnsTimelineDialog.tsx new file mode 100644 index 0000000..3547954 --- /dev/null +++ b/src/components/Sns/ContactSnsTimelineDialog.tsx @@ -0,0 +1,593 @@ +import { createPortal } from 'react-dom' +import { Loader2, X } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { SnsPostItem } from './SnsPostItem' +import type { SnsPost } from '../../types/sns' +import { + type ContactSnsRankItem, + type ContactSnsRankMode, + type ContactSnsTimelineTarget, + getAvatarLetter +} from './contactSnsTimeline' +import './ContactSnsTimelineDialog.scss' + +const TIMELINE_PAGE_SIZE = 20 +const SNS_RANK_PAGE_SIZE = 50 +const SNS_RANK_DISPLAY_LIMIT = 15 + +interface ContactSnsRankCacheEntry { + likes: ContactSnsRankItem[] + comments: ContactSnsRankItem[] + totalPosts: number +} + +interface ContactSnsTimelineDialogProps { + target: ContactSnsTimelineTarget | null + onClose: () => void + initialTotalPosts?: number | null + initialTotalPostsLoading?: boolean + isProtected?: boolean + onDeletePost?: (postId: string, username: string) => void +} + +const normalizeTotalPosts = (value?: number | null): number | null => { + if (!Number.isFinite(value)) return null + return Math.max(0, Math.floor(Number(value))) +} + +const formatYmdDateFromSeconds = (timestamp?: number): string => { + if (!timestamp || !Number.isFinite(timestamp)) return '—' + const date = new Date(timestamp * 1000) + const year = date.getFullYear() + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + return `${year}-${month}-${day}` +} + +const buildContactSnsRankings = (posts: SnsPost[]): { likes: ContactSnsRankItem[]; comments: ContactSnsRankItem[] } => { + const likeMap = new Map() + const commentMap = new Map() + + for (const post of posts) { + const createTime = Number(post?.createTime) || 0 + const likes = Array.isArray(post?.likes) ? post.likes : [] + const comments = Array.isArray(post?.comments) ? post.comments : [] + + for (const likeNameRaw of likes) { + const name = String(likeNameRaw || '').trim() || '未知用户' + const current = likeMap.get(name) + if (current) { + current.count += 1 + if (createTime > current.latestTime) current.latestTime = createTime + continue + } + likeMap.set(name, { name, count: 1, latestTime: createTime }) + } + + for (const comment of comments) { + const name = String(comment?.nickname || '').trim() || '未知用户' + const current = commentMap.get(name) + if (current) { + current.count += 1 + if (createTime > current.latestTime) current.latestTime = createTime + continue + } + commentMap.set(name, { name, count: 1, latestTime: createTime }) + } + } + + const sorter = (left: ContactSnsRankItem, right: ContactSnsRankItem): number => { + if (right.count !== left.count) return right.count - left.count + if (right.latestTime !== left.latestTime) return right.latestTime - left.latestTime + return left.name.localeCompare(right.name, 'zh-CN') + } + + return { + likes: [...likeMap.values()].sort(sorter), + comments: [...commentMap.values()].sort(sorter) + } +} + +export function ContactSnsTimelineDialog({ + target, + onClose, + initialTotalPosts = null, + initialTotalPostsLoading = false, + isProtected = false, + onDeletePost +}: ContactSnsTimelineDialogProps) { + const [timelinePosts, setTimelinePosts] = useState([]) + const [timelineLoading, setTimelineLoading] = useState(false) + const [timelineLoadingMore, setTimelineLoadingMore] = useState(false) + const [timelineHasMore, setTimelineHasMore] = useState(false) + const [timelineTotalPosts, setTimelineTotalPosts] = useState(null) + const [timelineStatsLoading, setTimelineStatsLoading] = useState(false) + const [rankMode, setRankMode] = useState(null) + const [likeRankings, setLikeRankings] = useState([]) + const [commentRankings, setCommentRankings] = useState([]) + const [rankLoading, setRankLoading] = useState(false) + const [rankError, setRankError] = useState(null) + const [rankLoadedPosts, setRankLoadedPosts] = useState(0) + const [rankTotalPosts, setRankTotalPosts] = useState(null) + + const timelinePostsRef = useRef([]) + const timelineLoadingRef = useRef(false) + const timelineRequestTokenRef = useRef(0) + const totalPostsRequestTokenRef = useRef(0) + const rankRequestTokenRef = useRef(0) + const rankLoadingRef = useRef(false) + const rankCacheRef = useRef>({}) + + const targetUsername = String(target?.username || '').trim() + const targetDisplayName = target?.displayName || targetUsername + const targetAvatarUrl = target?.avatarUrl + + useEffect(() => { + timelinePostsRef.current = timelinePosts + }, [timelinePosts]) + + const loadTimelinePosts = useCallback(async (nextTarget: ContactSnsTimelineTarget, options?: { reset?: boolean }) => { + const reset = Boolean(options?.reset) + if (timelineLoadingRef.current) return + + timelineLoadingRef.current = true + if (reset) { + setTimelineLoading(true) + setTimelineLoadingMore(false) + setTimelineHasMore(false) + } else { + setTimelineLoadingMore(true) + } + + const requestToken = ++timelineRequestTokenRef.current + + try { + let endTime: number | undefined + if (!reset && timelinePostsRef.current.length > 0) { + endTime = timelinePostsRef.current[timelinePostsRef.current.length - 1].createTime - 1 + } + + const result = await window.electronAPI.sns.getTimeline( + TIMELINE_PAGE_SIZE, + 0, + [nextTarget.username], + '', + undefined, + endTime + ) + if (requestToken !== timelineRequestTokenRef.current) return + + if (!result.success || !Array.isArray(result.timeline)) { + if (reset) { + setTimelinePosts([]) + setTimelineHasMore(false) + } + return + } + + const timeline = [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime) + if (reset) { + setTimelinePosts(timeline) + setTimelineHasMore(timeline.length >= TIMELINE_PAGE_SIZE) + return + } + + const existingIds = new Set(timelinePostsRef.current.map((post) => post.id)) + const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id)) + if (uniqueOlder.length > 0) { + const merged = [...timelinePostsRef.current, ...uniqueOlder].sort((left, right) => right.createTime - left.createTime) + setTimelinePosts(merged) + } + if (timeline.length < TIMELINE_PAGE_SIZE) { + setTimelineHasMore(false) + } + } catch (error) { + console.error('加载联系人朋友圈失败:', error) + if (requestToken === timelineRequestTokenRef.current && reset) { + setTimelinePosts([]) + setTimelineHasMore(false) + } + } finally { + if (requestToken === timelineRequestTokenRef.current) { + timelineLoadingRef.current = false + setTimelineLoading(false) + setTimelineLoadingMore(false) + } + } + }, []) + + const loadTimelineTotalPosts = useCallback(async (nextTarget: ContactSnsTimelineTarget) => { + const requestToken = ++totalPostsRequestTokenRef.current + setTimelineStatsLoading(true) + + try { + const result = await window.electronAPI.sns.getUserPostCounts() + if (requestToken !== totalPostsRequestTokenRef.current) return + + if (!result.success || !result.counts) { + setTimelineTotalPosts(null) + setRankTotalPosts(null) + return + } + + const rawCount = Number(result.counts[nextTarget.username] || 0) + const normalized = Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0 + setTimelineTotalPosts(normalized) + setRankTotalPosts(normalized) + } catch (error) { + console.error('加载联系人朋友圈条数失败:', error) + if (requestToken !== totalPostsRequestTokenRef.current) return + setTimelineTotalPosts(null) + setRankTotalPosts(null) + } finally { + if (requestToken === totalPostsRequestTokenRef.current) { + setTimelineStatsLoading(false) + } + } + }, []) + + const loadRankings = useCallback(async (nextTarget: ContactSnsTimelineTarget) => { + const normalizedUsername = String(nextTarget?.username || '').trim() + if (!normalizedUsername || rankLoadingRef.current) return + + const normalizedKnownTotal = normalizeTotalPosts(timelineTotalPosts) + const cached = rankCacheRef.current[normalizedUsername] + + if (cached && (normalizedKnownTotal === null || cached.totalPosts === normalizedKnownTotal)) { + setLikeRankings(cached.likes) + setCommentRankings(cached.comments) + setRankLoadedPosts(cached.totalPosts) + setRankTotalPosts(cached.totalPosts) + setRankError(null) + setRankLoading(false) + return + } + + rankLoadingRef.current = true + const requestToken = ++rankRequestTokenRef.current + setRankLoading(true) + setRankError(null) + setRankLoadedPosts(0) + setRankTotalPosts(normalizedKnownTotal) + + try { + const allPosts: SnsPost[] = [] + let endTime: number | undefined + let hasMore = true + + while (hasMore) { + const result = await window.electronAPI.sns.getTimeline( + SNS_RANK_PAGE_SIZE, + 0, + [normalizedUsername], + '', + undefined, + endTime + ) + if (requestToken !== rankRequestTokenRef.current) return + + if (!result.success) { + throw new Error(result.error || '加载朋友圈排行失败') + } + + const pagePosts = Array.isArray(result.timeline) + ? [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime) + : [] + if (pagePosts.length === 0) { + hasMore = false + break + } + + allPosts.push(...pagePosts) + setRankLoadedPosts(allPosts.length) + if (normalizedKnownTotal === null) { + setRankTotalPosts(allPosts.length) + } + + endTime = pagePosts[pagePosts.length - 1].createTime - 1 + hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE + } + + if (requestToken !== rankRequestTokenRef.current) return + + const rankings = buildContactSnsRankings(allPosts) + const totalPosts = allPosts.length + rankCacheRef.current[normalizedUsername] = { + likes: rankings.likes, + comments: rankings.comments, + totalPosts + } + setLikeRankings(rankings.likes) + setCommentRankings(rankings.comments) + setRankLoadedPosts(totalPosts) + setRankTotalPosts(totalPosts) + setRankError(null) + } catch (error) { + if (requestToken !== rankRequestTokenRef.current) return + const message = error instanceof Error ? error.message : String(error) + setLikeRankings([]) + setCommentRankings([]) + setRankError(message || '加载朋友圈排行失败') + } finally { + if (requestToken === rankRequestTokenRef.current) { + rankLoadingRef.current = false + setRankLoading(false) + } + } + }, [timelineTotalPosts]) + + useEffect(() => { + if (!targetUsername) return + + totalPostsRequestTokenRef.current += 1 + rankRequestTokenRef.current += 1 + rankLoadingRef.current = false + setRankMode(null) + setLikeRankings([]) + setCommentRankings([]) + setRankLoading(false) + setRankError(null) + setRankLoadedPosts(0) + setRankTotalPosts(null) + setTimelinePosts([]) + setTimelineTotalPosts(null) + setTimelineStatsLoading(false) + setTimelineHasMore(false) + setTimelineLoadingMore(false) + setTimelineLoading(false) + + void loadTimelinePosts({ + username: targetUsername, + displayName: targetDisplayName, + avatarUrl: targetAvatarUrl + }, { reset: true }) + }, [loadTimelinePosts, targetAvatarUrl, targetDisplayName, targetUsername]) + + useEffect(() => { + if (!targetUsername) return + + const normalizedTotal = normalizeTotalPosts(initialTotalPosts) + if (normalizedTotal !== null) { + setTimelineTotalPosts(normalizedTotal) + setRankTotalPosts(normalizedTotal) + setTimelineStatsLoading(false) + return + } + + if (initialTotalPostsLoading) { + setTimelineTotalPosts(null) + setRankTotalPosts(null) + setTimelineStatsLoading(true) + return + } + + void loadTimelineTotalPosts({ + username: targetUsername, + displayName: targetDisplayName, + avatarUrl: targetAvatarUrl + }) + }, [ + initialTotalPosts, + initialTotalPostsLoading, + loadTimelineTotalPosts, + targetAvatarUrl, + targetDisplayName, + targetUsername + ]) + + useEffect(() => { + if (timelineTotalPosts === null) return + if (timelinePosts.length >= timelineTotalPosts) { + setTimelineHasMore(false) + } + }, [timelinePosts.length, timelineTotalPosts]) + + useEffect(() => { + if (!rankMode || !targetUsername) return + void loadRankings({ + username: targetUsername, + displayName: targetDisplayName, + avatarUrl: targetAvatarUrl + }) + }, [loadRankings, rankMode, targetAvatarUrl, targetDisplayName, targetUsername]) + + useEffect(() => { + if (!targetUsername) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [onClose, targetUsername]) + + const timelineStatsText = useMemo(() => { + const loadedCount = timelinePosts.length + const loadPart = timelineStatsLoading + ? `已加载 ${loadedCount} / 总数统计中...` + : timelineTotalPosts === null + ? `已加载 ${loadedCount} 条` + : `已加载 ${loadedCount} / 共 ${timelineTotalPosts} 条` + + if (timelineLoading && loadedCount === 0) return `${loadPart} | 加载中...` + if (loadedCount === 0) return loadPart + + const latest = timelinePosts[0]?.createTime + const earliest = timelinePosts[timelinePosts.length - 1]?.createTime + return `${loadPart} | ${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}` + }, [timelineLoading, timelinePosts, timelineStatsLoading, timelineTotalPosts]) + + const activeRankings = useMemo(() => { + if (rankMode === 'likes') return likeRankings + if (rankMode === 'comments') return commentRankings + return [] + }, [commentRankings, likeRankings, rankMode]) + + const loadMore = useCallback(() => { + if (!targetUsername || timelineLoading || timelineLoadingMore || !timelineHasMore) return + void loadTimelinePosts({ + username: targetUsername, + displayName: targetDisplayName, + avatarUrl: targetAvatarUrl + }, { reset: false }) + }, [ + loadTimelinePosts, + targetAvatarUrl, + targetDisplayName, + targetUsername, + timelineHasMore, + timelineLoading, + timelineLoadingMore + ]) + + const handleBodyScroll = useCallback((event: React.UIEvent) => { + const element = event.currentTarget + const remaining = element.scrollHeight - element.scrollTop - element.clientHeight + if (remaining <= 160) { + loadMore() + } + }, [loadMore]) + + const toggleRankMode = useCallback((mode: ContactSnsRankMode) => { + setRankMode((previous) => (previous === mode ? null : mode)) + }, []) + + if (!target) return null + + return createPortal( +
+
event.stopPropagation()} + > +
+
+
+ {targetAvatarUrl ? ( + + ) : ( + {getAvatarLetter(targetDisplayName)} + )} +
+
+

{targetDisplayName}

+
@{targetUsername}
+
{timelineStatsText}
+
+
+
+
+ + + {rankMode && ( +
+ {rankLoading && ( +
+ + + {rankTotalPosts !== null && rankTotalPosts > 0 + ? `统计中,已加载 ${rankLoadedPosts} / ${rankTotalPosts} 条` + : `统计中,已加载 ${rankLoadedPosts} 条`} + +
+ )} + {!rankLoading && rankError ? ( +
{rankError}
+ ) : !rankLoading && activeRankings.length === 0 ? ( +
+ {rankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'} +
+ ) : ( + activeRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => ( +
+ {index + 1} + {item.name} + + {item.count.toLocaleString('zh-CN')} + {rankMode === 'likes' ? '次' : '条'} + +
+ )) + )} +
+ )} +
+ +
+
+ +
+ 在微信桌面客户端中打开这个人的朋友圈浏览,可快速把其朋友圈同步到这里。若你在乎这个人,一定要试试~ +
+ +
+ {timelinePosts.length > 0 && ( +
+ {timelinePosts.map((post) => ( + { + if (isVideo) { + void window.electronAPI.window.openVideoPlayerWindow(src) + } else { + void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) + } + }} + onDebug={() => {}} + onDelete={onDeletePost} + hideAuthorMeta + /> + ))} +
+ )} + + {timelineLoading && ( +
正在加载该联系人的朋友圈...
+ )} + + {!timelineLoading && timelinePosts.length === 0 && ( +
该联系人暂无朋友圈
+ )} + + {!timelineLoading && timelineHasMore && ( + + )} +
+
+
, + document.body + ) +} diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index 9894689..654d543 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -1,67 +1,83 @@ -import React, { useState } from 'react' -import { Search, Calendar, User, X, Filter, Check } from 'lucide-react' +import React from 'react' +import { Search, User, X, Loader2, CheckSquare, Square, Download } from 'lucide-react' import { Avatar } from '../Avatar' -// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved interface Contact { username: string displayName: string avatarUrl?: string + postCount?: number + postCountStatus?: 'idle' | 'loading' | 'ready' +} + +interface ContactsCountProgress { + resolved: number + total: number + running: boolean } interface SnsFilterPanelProps { searchKeyword: string setSearchKeyword: (val: string) => void - jumpTargetDate?: Date - setJumpTargetDate: (date?: Date) => void - onOpenJumpDialog: () => void - selectedUsernames: string[] - setSelectedUsernames: (val: string[]) => void + totalFriendsLabel?: string contacts: Contact[] contactSearch: string setContactSearch: (val: string) => void loading?: boolean + contactsCountProgress?: ContactsCountProgress + selectedContactUsernames: string[] + activeContactUsername?: string + onOpenContactTimeline: (contact: Contact) => void + onToggleContactSelected: (contact: Contact) => void + onClearSelectedContacts: () => void + onExportSelectedContacts: () => void } export const SnsFilterPanel: React.FC = ({ searchKeyword, setSearchKeyword, - jumpTargetDate, - setJumpTargetDate, - onOpenJumpDialog, - selectedUsernames, - setSelectedUsernames, + totalFriendsLabel, contacts, contactSearch, setContactSearch, - loading + loading, + contactsCountProgress, + selectedContactUsernames, + activeContactUsername, + onOpenContactTimeline, + onToggleContactSelected, + onClearSelectedContacts, + onExportSelectedContacts }) => { - const filteredContacts = contacts.filter(c => - c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) || + (c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) || c.username.toLowerCase().includes(contactSearch.toLowerCase()) ) - - const toggleUserSelection = (username: string) => { - if (selectedUsernames.includes(username)) { - setSelectedUsernames(selectedUsernames.filter(u => u !== username)) - } else { - setJumpTargetDate(undefined) // Reset date jump when selecting user - setSelectedUsernames([...selectedUsernames, username]) - } - } + const selectedContactLookup = React.useMemo( + () => new Set(selectedContactUsernames), + [selectedContactUsernames] + ) const clearFilters = () => { setSearchKeyword('') - setSelectedUsernames([]) - setJumpTargetDate(undefined) + setContactSearch('') + } + + const getEmptyStateText = () => { + if (loading && contacts.length === 0) { + return '正在加载联系人...' + } + if (contacts.length === 0) { + return '暂无好友或曾经的好友' + } + return '没有找到联系人' } return (
- - {/* Date Widget */} -
-
- - 时间跳转 -
- -
- {/* Contact Widget */}
联系人 - {selectedUsernames.length > 0 && ( - {selectedUsernames.length} + {totalFriendsLabel && ( + {totalFriendsLabel} )}
@@ -142,21 +128,77 @@ export const SnsFilterPanel: React.FC = ({ )}
+ {contactsCountProgress && contactsCountProgress.total > 0 && ( +
+ {contactsCountProgress.running + ? `朋友圈条数统计中 ${contactsCountProgress.resolved}/${contactsCountProgress.total}` + : `朋友圈条数已统计 ${contactsCountProgress.total}/${contactsCountProgress.total}`} +
+ )} + +
+ 点左侧可多选下载,点右侧可查看单人详情 +
+
- {filteredContacts.map(contact => ( -
toggleUserSelection(contact.username)} - > - - {contact.displayName} -
- ))} + {filteredContacts.map(contact => { + const isPostCountReady = contact.postCountStatus === 'ready' + const isSelected = selectedContactLookup.has(contact.username) + const isActive = activeContactUsername === contact.username + return ( +
+ + +
+ ) + })} {filteredContacts.length === 0 && ( -
没有找到联系人
+
{getEmptyStateText()}
)}
+ + {selectedContactUsernames.length > 0 && ( +
+ 已选 {selectedContactUsernames.length} 人 + + +
+ )}
diff --git a/src/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx index 76972fb..9a7ee16 100644 --- a/src/components/Sns/SnsPostItem.tsx +++ b/src/components/Sns/SnsPostItem.tsx @@ -1,7 +1,7 @@ import React, { useState, useMemo, useEffect } from 'react' import { createPortal } from 'react-dom' -import { Heart, ChevronRight, ImageIcon, Code, Trash2 } from 'lucide-react' -import { SnsPost, SnsLinkCardData } from '../../types/sns' +import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react' +import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns' import { Avatar } from '../Avatar' import { SnsMediaGrid } from './SnsMediaGrid' import { getEmojiPath } from 'wechat-emojis' @@ -134,6 +134,30 @@ const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => { } } +const buildLocationText = (location?: SnsLocation): string => { + if (!location) return '' + + const normalize = (value?: string): string => ( + decodeHtmlEntities(String(value || '')).replace(/\s+/g, ' ').trim() + ) + + const primary = [ + normalize(location.poiName), + normalize(location.poiAddressName), + normalize(location.label), + normalize(location.poiAddress) + ].find(Boolean) || '' + + const region = [normalize(location.country), normalize(location.city)] + .filter(Boolean) + .join(' ') + + if (primary && region && !primary.includes(region)) { + return `${primary} · ${region}` + } + return primary || region +} + const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => { const [thumbFailed, setThumbFailed] = useState(false) const hostname = useMemo(() => { @@ -243,15 +267,18 @@ interface SnsPostItemProps { post: SnsPost onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void onDebug: (post: SnsPost) => void - onDelete?: (postId: string) => void + onDelete?: (postId: string, username: string) => void + onOpenAuthorPosts?: (post: SnsPost) => void + hideAuthorMeta?: boolean } -export const SnsPostItem: React.FC = ({ post, onPreview, onDebug, onDelete }) => { +export const SnsPostItem: React.FC = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts, hideAuthorMeta = false }) => { const [mediaDeleted, setMediaDeleted] = useState(false) const [dbDeleted, setDbDeleted] = useState(false) const [deleting, setDeleting] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const linkCard = buildLinkCardData(post) + const locationText = useMemo(() => buildLocationText(post.location), [post.location]) const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url)) const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia const showMediaGrid = post.media.length > 0 && !showLinkCard @@ -299,31 +326,56 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id) if (r.success) { setDbDeleted(true) - onDelete?.(post.id) + onDelete?.(post.id, post.username) } } finally { setDeleting(false) } } + const handleOpenAuthorPosts = (e: React.MouseEvent) => { + e.stopPropagation() + onOpenAuthorPosts?.(post) + } + return ( <>
-
- -
+ {!hideAuthorMeta && ( +
+ +
+ )}
-
- {decodeHtmlEntities(post.nickname)} - {formatTime(post.createTime)} -
+ {hideAuthorMeta ? ( + {formatTime(post.createTime)} + ) : ( +
+ + {formatTime(post.createTime)} +
+ )}
{(mediaDeleted || dbDeleted) && ( @@ -352,6 +404,13 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb
{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}
)} + {locationText && ( +
+ + {locationText} +
+ )} + {showLinkCard && linkCard && ( )} diff --git a/src/components/Sns/contactSnsTimeline.ts b/src/components/Sns/contactSnsTimeline.ts new file mode 100644 index 0000000..0ec6eab --- /dev/null +++ b/src/components/Sns/contactSnsTimeline.ts @@ -0,0 +1,26 @@ +export interface ContactSnsTimelineTarget { + username: string + displayName: string + avatarUrl?: string +} + +export interface ContactSnsRankItem { + name: string + count: number + latestTime: number +} + +export type ContactSnsRankMode = 'likes' | 'comments' + +export const isSingleContactSession = (sessionId: string): boolean => { + const normalized = String(sessionId || '').trim() + if (!normalized) return false + if (normalized.includes('@chatroom')) return false + if (normalized.startsWith('gh_')) return false + return true +} + +export const getAvatarLetter = (name: string): string => { + if (!name) return '?' + return [...name][0] || '?' +} diff --git a/src/components/TitleBar.scss b/src/components/TitleBar.scss index 9c18972..8c3c9b8 100644 --- a/src/components/TitleBar.scss +++ b/src/components/TitleBar.scss @@ -3,11 +3,15 @@ background: var(--bg-secondary); display: flex; align-items: center; + justify-content: space-between; padding-left: 16px; + padding-right: 16px; border-bottom: 1px solid var(--border-color); -webkit-app-region: drag; flex-shrink: 0; gap: 8px; + position: relative; + z-index: 2101; } // 繁花如梦:标题栏毛玻璃 @@ -16,6 +20,12 @@ -webkit-backdrop-filter: blur(20px); } +.title-brand { + display: inline-flex; + align-items: center; + gap: 8px; +} + .title-logo { width: 20px; height: 20px; @@ -26,4 +36,111 @@ font-size: 15px; font-weight: 500; color: var(--text-secondary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.title-sidebar-toggle { + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-tertiary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease; + -webkit-app-region: no-drag; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } +} + +.title-window-controls { + display: inline-flex; + align-items: center; + gap: 6px; + -webkit-app-region: no-drag; +} + +.title-window-control-btn { + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-tertiary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + + &.is-close:hover { + background: #e5484d; + color: #fff; + } +} + +.image-controls { + display: flex; + align-items: center; + gap: 8px; + margin-right: auto; + padding-left: 16px; + -webkit-app-region: no-drag; + + button { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 6px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + + &:disabled { + cursor: default; + opacity: 1; + } + + &.live-play-btn.active { + background: rgba(var(--primary-rgb, 76, 132, 255), 0.16); + color: var(--primary, #4c84ff); + } + } + + .scale-text { + min-width: 50px; + text-align: center; + color: var(--text-secondary); + font-size: 12px; + font-variant-numeric: tabular-nums; + } + + .divider { + width: 1px; + height: 14px; + background: var(--border-color); + margin: 0 4px; + } } diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 570e6e9..491a7ea 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -1,14 +1,87 @@ +import { useEffect, useState } from 'react' +import { Copy, Minus, PanelLeftClose, PanelLeftOpen, Square, X } from 'lucide-react' import './TitleBar.scss' interface TitleBarProps { title?: string + sidebarCollapsed?: boolean + onToggleSidebar?: () => void + showWindowControls?: boolean + customControls?: React.ReactNode + showLogo?: boolean } -function TitleBar({ title }: TitleBarProps = {}) { +function TitleBar({ + title, + sidebarCollapsed = false, + onToggleSidebar, + showWindowControls = true, + customControls, + showLogo = true +}: TitleBarProps = {}) { + const [isMaximized, setIsMaximized] = useState(false) + + useEffect(() => { + if (!showWindowControls) return + + void window.electronAPI.window.isMaximized().then(setIsMaximized).catch(() => { + setIsMaximized(false) + }) + + return window.electronAPI.window.onMaximizeStateChanged((maximized) => { + setIsMaximized(maximized) + }) + }, [showWindowControls]) + return (
- WeFlow - {title || 'WeFlow'} +
+ {showLogo && WeFlow} + {title || 'WeFlow'} + {onToggleSidebar ? ( + + ) : null} +
+ {customControls} + {showWindowControls ? ( +
+ + + +
+ ) : null}
) } diff --git a/src/components/UpdateDialog.scss b/src/components/UpdateDialog.scss index f12a6d8..a1a4e39 100644 --- a/src/components/UpdateDialog.scss +++ b/src/components/UpdateDialog.scss @@ -14,7 +14,7 @@ .update-dialog { width: 680px; - background: #f5f5f5; + background: var(--bg-secondary, #f5f5f5); border-radius: 24px; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); overflow: hidden; @@ -25,7 +25,7 @@ /* Top Section (White/Gradient) */ .dialog-header { - background: #ffffff; + background: var(--bg-primary, #ffffff); padding: 40px 20px 30px; display: flex; flex-direction: column; @@ -41,14 +41,14 @@ left: -50px; width: 200px; height: 200px; - background: radial-gradient(circle, rgba(255, 235, 220, 0.4) 0%, rgba(255, 255, 255, 0) 70%); - opacity: 0.8; + background: radial-gradient(circle, rgba(255, 235, 220, 0.15) 0%, rgba(255, 255, 255, 0) 70%); + opacity: 0.5; pointer-events: none; } .version-tag { - background: #f0eee9; - color: #8c7b6e; + background: var(--bg-tertiary, #f0eee9); + color: var(--text-tertiary, #8c7b6e); padding: 4px 16px; border-radius: 12px; font-size: 13px; @@ -60,21 +60,21 @@ h2 { font-size: 32px; font-weight: 800; - color: #333333; + color: var(--text-primary, #333333); margin: 0 0 12px; letter-spacing: -0.5px; } .subtitle { font-size: 15px; - color: #999999; + color: var(--text-secondary, #999999); font-weight: 400; } } /* Content Section (Light Gray) */ .dialog-content { - background: #f2f2f2; + background: var(--bg-tertiary, #f2f2f2); padding: 24px 40px 40px; flex: 1; display: flex; @@ -87,7 +87,7 @@ margin-bottom: 30px; .icon-box { - background: #fbfbfb; // Beige-ish white + background: var(--bg-primary, #fbfbfb); width: 48px; height: 48px; border-radius: 16px; @@ -96,7 +96,7 @@ justify-content: center; margin-right: 20px; flex-shrink: 0; - color: #8c7b6e; + color: var(--text-tertiary, #8c7b6e); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03); svg { @@ -107,27 +107,38 @@ .text-box { flex: 1; - h3 { - font-size: 18px; + h1, h2, h3, h4, h5, h6 { + color: var(--text-primary, #333333); font-weight: 700; - color: #333333; - margin: 0 0 8px; + margin: 16px 0 8px; + + &:first-child { + margin-top: 0; + } + } + + h2 { + font-size: 16px; + } + + h3 { + font-size: 15px; } p { font-size: 14px; - color: #666666; + color: var(--text-secondary, #666666); line-height: 1.6; - margin: 0; + margin: 4px 0; } ul { - margin: 8px 0 0 18px; + margin: 4px 0 0 18px; padding: 0; li { font-size: 14px; - color: #666666; + color: var(--text-secondary, #666666); line-height: 1.6; } } @@ -142,19 +153,19 @@ justify-content: space-between; margin-bottom: 8px; font-size: 12px; - color: #888; + color: var(--text-secondary, #888); font-weight: 500; } .progress-bar-bg { height: 6px; - background: #e0e0e0; + background: var(--border-color, #e0e0e0); border-radius: 3px; overflow: hidden; .progress-bar-fill { height: 100%; - background: #000000; + background: var(--text-primary, #000000); border-radius: 3px; transition: width 0.3s ease; } @@ -164,7 +175,7 @@ text-align: center; margin-top: 12px; font-size: 13px; - color: #666; + color: var(--text-secondary, #666); } } @@ -175,8 +186,8 @@ .btn-ignore { background: transparent; - color: #666666; - border: 1px solid #d0d0d0; + color: var(--text-secondary, #666666); + border: 1px solid var(--border-color, #d0d0d0); padding: 16px 32px; border-radius: 20px; font-size: 16px; @@ -185,9 +196,9 @@ transition: all 0.2s; &:hover { - background: #f5f5f5; - border-color: #999999; - color: #333333; + background: var(--bg-hover, #f5f5f5); + border-color: var(--text-secondary, #999999); + color: var(--text-primary, #333333); } &:active { @@ -196,11 +207,11 @@ } .btn-update { - background: #000000; - color: #ffffff; + background: var(--text-primary, #000000); + color: var(--bg-primary, #ffffff); border: none; padding: 16px 48px; - border-radius: 20px; // Pill shape + border-radius: 20px; font-size: 16px; font-weight: 600; cursor: pointer; @@ -231,7 +242,7 @@ right: 16px; background: rgba(0, 0, 0, 0.05); border: none; - color: #999; + color: var(--text-secondary, #999); cursor: pointer; width: 32px; height: 32px; @@ -244,7 +255,7 @@ &:hover { background: rgba(0, 0, 0, 0.1); - color: #333; + color: var(--text-primary, #333); transform: rotate(90deg); } } diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx index bafdb18..0dce27d 100644 --- a/src/components/UpdateDialog.tsx +++ b/src/components/UpdateDialog.tsx @@ -89,7 +89,6 @@ const UpdateDialog: React.FC = ({
-

优化

{updateInfo.releaseNotes ? (
) : ( diff --git a/src/components/WindowCloseDialog.scss b/src/components/WindowCloseDialog.scss new file mode 100644 index 0000000..ecc6907 --- /dev/null +++ b/src/components/WindowCloseDialog.scss @@ -0,0 +1,306 @@ +.window-close-dialog-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: + radial-gradient(circle at top, rgba(36, 42, 54, 0.18), transparent 48%), + rgba(7, 10, 18, 0.56); + backdrop-filter: blur(10px); + z-index: 3000; + animation: windowCloseDialogFadeIn 0.2s ease-out; +} + +.window-close-dialog { + width: min(560px, 100%); + border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent); + border-radius: 24px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 94%, white 6%) 0%, var(--bg-primary) 100%); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32); + overflow: hidden; + position: relative; + animation: windowCloseDialogSlideUp 0.24s cubic-bezier(0.16, 1, 0.3, 1); +} + +.window-close-dialog-header { + padding: 28px 30px 18px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + + .window-close-dialog-kicker { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + h2 { + margin: 14px 0 8px; + font-size: 26px; + line-height: 1.1; + color: var(--text-primary); + } + + p { + margin: 0; + font-size: 14px; + line-height: 1.7; + color: var(--text-secondary); + } +} + +.window-close-dialog-body { + padding: 20px 24px 10px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.window-close-dialog-option { + width: 100%; + display: flex; + align-items: flex-start; + gap: 14px; + padding: 18px 18px 18px 16px; + border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + border-radius: 18px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 86%, white 14%) 0%, var(--bg-secondary) 100%); + color: inherit; + cursor: pointer; + text-align: left; + transition: + transform 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + background 0.18s ease; + + &:hover { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color)); + box-shadow: 0 14px 28px rgba(0, 0, 0, 0.1); + } + + &:active { + transform: translateY(0); + } + + &.is-danger:hover { + border-color: rgba(205, 73, 73, 0.42); + } +} + +.window-close-dialog-option-icon { + width: 42px; + height: 42px; + flex: 0 0 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 14px; + background: color-mix(in srgb, var(--primary) 14%, transparent); + color: var(--primary); +} + +.window-close-dialog-option.is-danger .window-close-dialog-option-icon { + background: rgba(205, 73, 73, 0.12); + color: #cd4949; +} + +.window-close-dialog-option-text { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + + strong { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + span { + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); + } +} + +.window-close-dialog-actions { + padding: 8px 24px 24px; + display: flex; + justify-content: flex-end; +} + +.window-close-dialog-remember { + display: flex; + align-items: center; + gap: 10px; + margin: 4px 24px 0; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + border-radius: 16px; + background: color-mix(in srgb, var(--bg-secondary) 76%, transparent); + cursor: pointer; + user-select: none; + + input { + position: absolute; + opacity: 0; + pointer-events: none; + } +} + +.window-close-dialog-checkbox { + width: 18px; + height: 18px; + flex: 0 0 18px; + border: 1.5px solid color-mix(in srgb, var(--border-color) 88%, transparent); + border-radius: 6px; + background: var(--bg-primary); + position: relative; + transition: + border-color 0.18s ease, + background 0.18s ease, + box-shadow 0.18s ease; + + &::after { + content: ''; + position: absolute; + left: 5px; + top: 1px; + width: 5px; + height: 10px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg) scale(0.7); + opacity: 0; + transition: + opacity 0.18s ease, + transform 0.18s ease; + } +} + +.window-close-dialog-remember input:checked + .window-close-dialog-checkbox { + background: var(--primary); + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent); +} + +.window-close-dialog-remember input:checked + .window-close-dialog-checkbox::after { + opacity: 1; + transform: rotate(45deg) scale(1); +} + +.window-close-dialog-remember-text { + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); +} + +.window-close-dialog-cancel { + min-width: 112px; + padding: 12px 18px; + border: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent); + border-radius: 999px; + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: pointer; + transition: + background 0.18s ease, + color 0.18s ease, + border-color 0.18s ease; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: color-mix(in srgb, var(--primary) 24%, var(--border-color)); + } +} + +.window-close-dialog-close { + position: absolute; + top: 18px; + right: 18px; + width: 34px; + height: 34px; + border: none; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--bg-secondary) 84%, transparent); + color: var(--text-secondary); + cursor: pointer; + transition: + background 0.18s ease, + color 0.18s ease, + transform 0.18s ease; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + transform: rotate(90deg); + } +} + +@media (max-width: 640px) { + .window-close-dialog-overlay { + padding: 16px; + align-items: flex-end; + } + + .window-close-dialog { + border-radius: 24px 24px 18px 18px; + } + + .window-close-dialog-header { + padding: 24px 22px 16px; + + h2 { + font-size: 22px; + } + } + + .window-close-dialog-body { + padding: 18px 18px 10px; + } + + .window-close-dialog-actions { + padding: 8px 18px 18px; + } + + .window-close-dialog-cancel { + width: 100%; + } +} + +@keyframes windowCloseDialogFadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes windowCloseDialogSlideUp { + from { + transform: translateY(24px) scale(0.98); + opacity: 0; + } + + to { + transform: translateY(0) scale(1); + opacity: 1; + } +} diff --git a/src/components/WindowCloseDialog.tsx b/src/components/WindowCloseDialog.tsx new file mode 100644 index 0000000..ea838ea --- /dev/null +++ b/src/components/WindowCloseDialog.tsx @@ -0,0 +1,115 @@ +import { Minimize2, Power, X } from 'lucide-react' +import { useEffect, useState } from 'react' +import './WindowCloseDialog.scss' + +interface WindowCloseDialogProps { + open: boolean + canMinimizeToTray: boolean + onSelect: (action: 'tray' | 'quit', rememberChoice: boolean) => void + onCancel: () => void +} + +export default function WindowCloseDialog({ + open, + canMinimizeToTray, + onSelect, + onCancel +}: WindowCloseDialogProps) { + const [rememberChoice, setRememberChoice] = useState(false) + + useEffect(() => { + if (!open) return + setRememberChoice(false) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + onCancel() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [open, onCancel]) + + if (!open) return null + + return ( +
+
event.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="window-close-dialog-title" + > + + +
+ 退出行为 +

关闭 WeFlow

+

+ {canMinimizeToTray + ? '你可以保留后台进程与本地 API,或者直接完全退出应用。' + : '当前系统托盘不可用,本次只能完全退出应用。'} +

+
+ +
+ {canMinimizeToTray && ( + + )} + + +
+ + + +
+ +
+
+
+ ) +} diff --git a/src/pages/AnalyticsPage.scss b/src/pages/AnalyticsPage.scss index 73d7ca9..f905f4d 100644 --- a/src/pages/AnalyticsPage.scss +++ b/src/pages/AnalyticsPage.scss @@ -1,3 +1,15 @@ +.analytics-page-shell { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 100%; + + .loading-container, + .error-container { + flex: 1; + } +} + // 加载和错误状态 .loading-container, .error-container { @@ -53,24 +65,6 @@ } } -.page-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 16px; - - h1 { - margin: 0; - } - - .header-actions { - display: flex; - align-items: center; - gap: 8px; - } -} - @keyframes spin { from { transform: rotate(0deg); diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 1557679..a689089 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -1,11 +1,18 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, type ReactNode } from 'react' import { useLocation } from 'react-router-dom' import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, Medal, UserMinus, Search, X } from 'lucide-react' import ReactECharts from 'echarts-for-react' import { useAnalyticsStore } from '../stores/analyticsStore' import { useThemeStore } from '../stores/themeStore' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import './AnalyticsPage.scss' import { Avatar } from '../components/Avatar' +import ChatAnalysisHeader from '../components/ChatAnalysisHeader' interface ExcludeCandidate { username: string @@ -48,6 +55,13 @@ function AnalyticsPage() { const loadData = useCallback(async (forceRefresh = false) => { if (isLoaded && !forceRefresh) return + const taskId = registerBackgroundTask({ + sourcePage: 'analytics', + title: forceRefresh ? '刷新分析看板' : '加载分析看板', + detail: '准备读取整体统计数据', + progressText: '整体统计', + cancelable: true + }) setIsLoading(true) setError(null) setProgress(0) @@ -60,27 +74,70 @@ function AnalyticsPage() { try { setLoadingStatus('正在统计消息数据...') + updateBackgroundTask(taskId, { + detail: '正在统计消息数据', + progressText: '整体统计' + }) const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前页面分析流程已结束' + }) + setIsLoading(false) + return + } if (statsResult.success && statsResult.data) { setStatistics(statsResult.data) } else { setError(statsResult.error || '加载统计数据失败') + finishBackgroundTask(taskId, 'failed', { + detail: statsResult.error || '加载统计数据失败' + }) setIsLoading(false) return } setLoadingStatus('正在分析联系人排名...') + updateBackgroundTask(taskId, { + detail: '正在分析联系人排名', + progressText: '联系人排名' + }) const rankingsResult = await window.electronAPI.analytics.getContactRankings(20) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,联系人排名后续步骤未继续' + }) + setIsLoading(false) + return + } if (rankingsResult.success && rankingsResult.data) { setRankings(rankingsResult.data) } setLoadingStatus('正在计算时间分布...') + updateBackgroundTask(taskId, { + detail: '正在计算时间分布', + progressText: '时间分布' + }) const timeResult = await window.electronAPI.analytics.getTimeDistribution() + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,时间分布结果未继续写入' + }) + setIsLoading(false) + return + } if (timeResult.success && timeResult.data) { setTimeDistribution(timeResult.data) } markLoaded() + finishBackgroundTask(taskId, 'completed', { + detail: '分析看板数据加载完成', + progressText: '已完成' + }) } catch (e) { setError(String(e)) + finishBackgroundTask(taskId, 'failed', { + detail: String(e) + }) } finally { setIsLoading(false) if (removeListener) removeListener() @@ -360,8 +417,28 @@ function AnalyticsPage() { } } + const renderPageShell = (content: ReactNode) => ( +
+ + {content} +
+ ) + + const analyticsHeaderActions = ( + <> + + + + ) + if (isLoading && !isLoaded) { - return ( + return renderPageShell(

{loadingStatus}

@@ -374,7 +451,7 @@ function AnalyticsPage() { } if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) { - return ( + return renderPageShell(

{error}

@@ -390,25 +467,18 @@ function AnalyticsPage() { } if (error && !isLoaded) { - return (

{error}

) + return renderPageShell( +
+

{error}

+ +
+ ) } return ( - <> -
-

私聊分析

-
- - -
-
+
+
@@ -556,7 +626,7 @@ function AnalyticsPage() {
)} - +
) } diff --git a/src/pages/AnalyticsWelcomePage.scss b/src/pages/AnalyticsWelcomePage.scss index 7a61efb..0e698bc 100644 --- a/src/pages/AnalyticsWelcomePage.scss +++ b/src/pages/AnalyticsWelcomePage.scss @@ -1,13 +1,30 @@ +.analytics-entry-page { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 100%; +} + .analytics-welcome-container { display: flex; flex-direction: column; + flex: 1; align-items: center; justify-content: center; - height: 100%; + min-height: 0; padding: 40px; background: var(--bg-primary); color: var(--text-primary); animation: fadeIn 0.4s ease-out; + overflow-y: auto; + + &.analytics-welcome-container--mode { + border-radius: 20px; + border: 1px solid var(--border-color); + background: + radial-gradient(circle at top, rgba(7, 193, 96, 0.06), transparent 48%), + var(--bg-primary); + } .welcome-content { text-align: center; @@ -106,6 +123,18 @@ } } +@media (max-width: 768px) { + .analytics-welcome-container { + padding: 28px 18px; + + .welcome-content { + .action-cards { + grid-template-columns: 1fr; + } + } + } +} + @keyframes fadeIn { from { opacity: 0; @@ -116,4 +145,4 @@ opacity: 1; transform: translateY(0); } -} \ No newline at end of file +} diff --git a/src/pages/AnalyticsWelcomePage.tsx b/src/pages/AnalyticsWelcomePage.tsx index 38a5f9f..e5344ae 100644 --- a/src/pages/AnalyticsWelcomePage.tsx +++ b/src/pages/AnalyticsWelcomePage.tsx @@ -1,6 +1,7 @@ import { useNavigate } from 'react-router-dom' import { BarChart2, History, RefreshCcw } from 'lucide-react' import { useAnalyticsStore } from '../stores/analyticsStore' +import ChatAnalysisHeader from '../components/ChatAnalysisHeader' import './AnalyticsWelcomePage.scss' function AnalyticsWelcomePage() { @@ -14,11 +15,11 @@ function AnalyticsWelcomePage() { const { lastLoadTime } = useAnalyticsStore() const handleLoadCache = () => { - navigate('/analytics/view') + navigate('/analytics/private/view') } const handleNewAnalysis = () => { - navigate('/analytics/view', { state: { forceRefresh: true } }) + navigate('/analytics/private/view', { state: { forceRefresh: true } }) } const formatLastTime = (ts: number | null) => { @@ -27,33 +28,37 @@ function AnalyticsWelcomePage() { } return ( -
-
-
- -
-

私聊数据分析

-

- WeFlow 可以分析你的聊天记录,生成详细的统计报表。
- 你可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。 -

+
+ -
- +
+
+
+ +
+

私聊数据分析

+

+ WeFlow 可以分析你的好友聊天记录,生成详细的统计报表。
+ 你可以选择加载上次的分析结果,或者重新开始一次新的私聊分析。 +

- +
+ + + +
diff --git a/src/pages/AnnualReportPage.scss b/src/pages/AnnualReportPage.scss index 5f58d7f..3e7beab 100644 --- a/src/pages/AnnualReportPage.scss +++ b/src/pages/AnnualReportPage.scss @@ -26,6 +26,48 @@ margin: 0 0 48px; } +.page-desc.load-summary { + margin: 0 0 28px; +} + +.page-desc.load-summary.complete { + color: var(--text-secondary); +} + +.load-telemetry { + width: min(760px, 100%); + padding: 12px 14px; + margin: 0 0 28px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + background: color-mix(in srgb, var(--card-bg) 92%, transparent); + text-align: left; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + + p { + margin: 4px 0; + } + + .label { + color: var(--text-tertiary); + } +} + +.load-telemetry.loading { + border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color)); +} + +.load-telemetry.complete { + border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color)); +} + +.load-telemetry.compact { + margin: 12px 0 0; + width: min(560px, 100%); +} + .report-sections { display: flex; flex-direction: column; @@ -83,6 +125,14 @@ color: var(--text-tertiary); } +.year-grid-with-status { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} + .year-grid { display: flex; flex-wrap: wrap; @@ -95,7 +145,39 @@ .report-section .year-grid { justify-content: flex-start; max-width: none; - margin-bottom: 24px; + margin-bottom: 0; +} + +.year-grid-with-status .year-grid { + flex: 1; +} + +.year-load-status { + display: inline-flex; + align-items: center; + font-size: 12px; + color: var(--text-tertiary); + white-space: nowrap; + margin-top: 6px; + flex-shrink: 0; +} + +.year-load-status.complete { + color: color-mix(in srgb, var(--primary) 80%, var(--text-secondary)); +} + +.dot-ellipsis { + display: inline-block; + width: 0; + overflow: hidden; + vertical-align: bottom; + animation: dot-ellipsis 1.2s steps(4, end) infinite; +} + +.year-load-status.complete .dot-ellipsis, +.page-desc.load-summary.complete .dot-ellipsis { + animation: none; + width: 0; } .year-card { @@ -185,3 +267,7 @@ from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + +@keyframes dot-ellipsis { + to { width: 1.4em; } +} diff --git a/src/pages/AnnualReportPage.tsx b/src/pages/AnnualReportPage.tsx index 626f315..88f77d0 100644 --- a/src/pages/AnnualReportPage.tsx +++ b/src/pages/AnnualReportPage.tsx @@ -1,9 +1,37 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { Calendar, Loader2, Sparkles, Users } from 'lucide-react' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import './AnnualReportPage.scss' type YearOption = number | 'all' +type YearsLoadPayload = { + years?: number[] + done: boolean + error?: string + canceled?: boolean + strategy?: 'cache' | 'native' | 'hybrid' + phase?: 'cache' | 'native' | 'scan' | 'done' + statusText?: string + nativeElapsedMs?: number + scanElapsedMs?: number + totalElapsedMs?: number + switched?: boolean + nativeTimedOut?: boolean +} + +const formatLoadElapsed = (ms: number) => { + const totalSeconds = Math.max(0, ms) / 1000 + if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s` + const minutes = Math.floor(totalSeconds / 60) + const seconds = Math.floor(totalSeconds % 60) + return `${minutes}m ${String(seconds).padStart(2, '0')}s` +} function AnnualReportPage() { const navigate = useNavigate() @@ -11,32 +39,152 @@ function AnnualReportPage() { const [selectedYear, setSelectedYear] = useState(null) const [selectedPairYear, setSelectedPairYear] = useState(null) const [isLoading, setIsLoading] = useState(true) + const [isLoadingMoreYears, setIsLoadingMoreYears] = useState(false) + const [hasYearsLoadFinished, setHasYearsLoadFinished] = useState(false) + const [loadStrategy, setLoadStrategy] = useState<'cache' | 'native' | 'hybrid'>('native') + const [loadPhase, setLoadPhase] = useState<'cache' | 'native' | 'scan' | 'done'>('native') + const [loadStatusText, setLoadStatusText] = useState('准备加载年份数据...') + const [nativeElapsedMs, setNativeElapsedMs] = useState(0) + const [scanElapsedMs, setScanElapsedMs] = useState(0) + const [totalElapsedMs, setTotalElapsedMs] = useState(0) + const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false) + const [nativeTimedOut, setNativeTimedOut] = useState(false) const [isGenerating, setIsGenerating] = useState(false) const [loadError, setLoadError] = useState(null) useEffect(() => { - loadAvailableYears() - }, []) + let disposed = false + let taskId = '' + let uiTaskId = '' - const loadAvailableYears = async () => { - setIsLoading(true) - setLoadError(null) - try { - const result = await window.electronAPI.annualReport.getAvailableYears() - if (result.success && result.data && result.data.length > 0) { - setAvailableYears(result.data) - setSelectedYear((prev) => prev ?? result.data[0]) - setSelectedPairYear((prev) => prev ?? result.data[0]) - } else if (!result.success) { - setLoadError(result.error || '加载年度数据失败') + const applyLoadPayload = (payload: YearsLoadPayload) => { + if (uiTaskId) { + updateBackgroundTask(uiTaskId, { + detail: payload.statusText || '正在加载可用年份', + progressText: payload.done + ? '已完成' + : `${Array.isArray(payload.years) ? payload.years.length : 0} 个年份` + }) + } + if (payload.strategy) setLoadStrategy(payload.strategy) + if (payload.phase) setLoadPhase(payload.phase) + if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText) + if (typeof payload.nativeElapsedMs === 'number' && Number.isFinite(payload.nativeElapsedMs)) { + setNativeElapsedMs(Math.max(0, payload.nativeElapsedMs)) + } + if (typeof payload.scanElapsedMs === 'number' && Number.isFinite(payload.scanElapsedMs)) { + setScanElapsedMs(Math.max(0, payload.scanElapsedMs)) + } + if (typeof payload.totalElapsedMs === 'number' && Number.isFinite(payload.totalElapsedMs)) { + setTotalElapsedMs(Math.max(0, payload.totalElapsedMs)) + } + if (typeof payload.switched === 'boolean') setHasSwitchedStrategy(payload.switched) + if (typeof payload.nativeTimedOut === 'boolean') setNativeTimedOut(payload.nativeTimedOut) + + const years = Array.isArray(payload.years) ? payload.years : [] + if (years.length > 0) { + setAvailableYears(years) + setSelectedYear((prev) => { + if (prev === 'all') return prev + if (typeof prev === 'number' && years.includes(prev)) return prev + return years[0] + }) + setSelectedPairYear((prev) => { + if (prev === 'all') return prev + if (typeof prev === 'number' && years.includes(prev)) return prev + return years[0] + }) + setIsLoading(false) + } + + if (payload.error && !payload.canceled) { + setLoadError(payload.error || '加载年度数据失败') + } + + if (payload.done) { + setIsLoading(false) + setIsLoadingMoreYears(false) + setHasYearsLoadFinished(true) + setLoadPhase('done') + if (uiTaskId) { + finishBackgroundTask(uiTaskId, payload.canceled ? 'canceled' : 'completed', { + detail: payload.canceled + ? '年度报告年份加载已停止' + : `年度报告年份加载完成,共 ${years.length} 个年份`, + progressText: payload.canceled ? '已停止' : `${years.length} 个年份` + }) + } + } else { + setIsLoadingMoreYears(true) + setHasYearsLoadFinished(false) } - } catch (e) { - console.error(e) - setLoadError(String(e)) - } finally { - setIsLoading(false) } - } + + const stopListen = window.electronAPI.annualReport.onAvailableYearsProgress((payload) => { + if (disposed) return + if (taskId && payload.taskId !== taskId) return + if (!taskId) taskId = payload.taskId + applyLoadPayload(payload) + }) + + const startLoad = async () => { + uiTaskId = registerBackgroundTask({ + sourcePage: 'annualReport', + title: '年度报告年份加载', + detail: '准备使用原生快速模式加载年份', + progressText: '初始化', + cancelable: true, + onCancel: async () => { + if (taskId) { + await window.electronAPI.annualReport.cancelAvailableYearsLoad(taskId) + } + } + }) + setIsLoading(true) + setIsLoadingMoreYears(true) + setHasYearsLoadFinished(false) + setLoadStrategy('native') + setLoadPhase('native') + setLoadStatusText('准备使用原生快速模式加载年份...') + setNativeElapsedMs(0) + setScanElapsedMs(0) + setTotalElapsedMs(0) + setHasSwitchedStrategy(false) + setNativeTimedOut(false) + setLoadError(null) + try { + const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad() + if (!startResult.success || !startResult.taskId) { + finishBackgroundTask(uiTaskId, 'failed', { + detail: startResult.error || '加载年度数据失败' + }) + setLoadError(startResult.error || '加载年度数据失败') + setIsLoading(false) + setIsLoadingMoreYears(false) + return + } + taskId = startResult.taskId + if (startResult.snapshot) { + applyLoadPayload(startResult.snapshot) + } + } catch (e) { + console.error(e) + finishBackgroundTask(uiTaskId, 'failed', { + detail: String(e) + }) + setLoadError(String(e)) + setIsLoading(false) + setIsLoadingMoreYears(false) + } + } + + void startLoad() + + return () => { + disposed = true + stopListen() + } + }, []) const handleGenerateReport = async () => { if (selectedYear === null) return @@ -57,16 +205,16 @@ function AnnualReportPage() { navigate(`/dual-report?year=${yearParam}`) } - if (isLoading) { + if (isLoading && availableYears.length === 0) { return (
-

正在加载年份数据...

+

正在准备年度报告...

) } - if (availableYears.length === 0) { + if (availableYears.length === 0 && !isLoadingMoreYears) { return (
@@ -87,6 +235,21 @@ function AnnualReportPage() { return value === 'all' ? '全部时间' : `${value} 年` } + const loadedYearCount = availableYears.length + const isYearStatusComplete = hasYearsLoadFinished + const strategyLabel = getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut }) + const renderYearLoadStatus = () => ( +
+ {isYearStatusComplete ? ( + <>全部年份已加载完毕 + ) : ( + <> + 更多年份加载中 + + )} +
+ ) + return (
@@ -102,17 +265,19 @@ function AnnualReportPage() {
-
- {yearOptions.map(option => ( -
setSelectedYear(option)} - > - {option === 'all' ? '全部' : option} - {option === 'all' ? '时间' : '年'} -
- ))} +
+
+ {yearOptions.map(option => ( +
setSelectedYear(option)} + > + {option === 'all' ? '全部' : option} + {option === 'all' ? '时间' : '年'} +
+ ))} +
-
- {yearOptions.map(option => ( -
setSelectedPairYear(option)} - > - {option === 'all' ? '全部' : option} - {option === 'all' ? '时间' : '年'} -
- ))} +
+
+ {yearOptions.map(option => ( +
setSelectedPairYear(option)} + > + {option === 'all' ? '全部' : option} + {option === 'all' ? '时间' : '年'} +
+ ))} +
+ + +
+
+
+ ) +} + +export default ChatAnalyticsHubPage diff --git a/src/pages/ChatHistoryPage.scss b/src/pages/ChatHistoryPage.scss index 74c2af6..7465fae 100644 --- a/src/pages/ChatHistoryPage.scss +++ b/src/pages/ChatHistoryPage.scss @@ -2,15 +2,16 @@ display: flex; flex-direction: column; height: 100vh; - background: var(--bg-primary); + background: + linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 96%, white) 0%, var(--bg-primary) 100%); .history-list { flex: 1; overflow-y: auto; - padding: 16px; + padding: 18px 18px 28px; display: flex; flex-direction: column; - gap: 12px; + gap: 0; .status-msg { text-align: center; @@ -30,68 +31,84 @@ .history-item { display: flex; - gap: 12px; + gap: 14px; align-items: flex-start; + padding: 14px 0 0; - .avatar { - width: 40px; - height: 40px; - border-radius: 4px; + &.error-item { + padding: 12px; + background: var(--bg-secondary); + border-radius: 8px; + color: var(--text-tertiary); + font-size: 13px; + text-align: center; + justify-content: center; + } + + .history-avatar { + width: 36px; + height: 36px; + border-radius: 8px; overflow: hidden; flex-shrink: 0; - background: var(--bg-tertiary); + border: none; + box-shadow: none; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; - img { + .avatar-component.avatar-inner { width: 100%; height: 100%; - object-fit: cover; - } + border-radius: inherit; + background: transparent; - .avatar-placeholder { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-tertiary); - font-size: 16px; - font-weight: 500; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; + img.avatar-image { + // Forwarded record head images may include a light matte edge. + // Slightly zoom in to crop that edge and align with normal chat avatars. + transform: scale(1.12); + transform-origin: center; + } } } .content-wrapper { flex: 1; min-width: 0; + padding-bottom: 18px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); .header { display: flex; justify-content: space-between; - align-items: center; - margin-bottom: 6px; + align-items: flex-start; + gap: 12px; + margin-bottom: 4px; .sender { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); + font-size: 13px; + font-weight: 400; + color: color-mix(in srgb, var(--text-secondary) 82%, transparent); + line-height: 1.3; } .time { font-size: 12px; - color: var(--text-tertiary); + color: color-mix(in srgb, var(--text-tertiary) 92%, transparent); flex-shrink: 0; margin-left: 8px; + line-height: 1.3; } } .bubble { - background: var(--bg-secondary); - padding: 10px 14px; - border-radius: 18px 18px 18px 4px; + background: transparent; + padding: 0; + border-radius: 0; word-wrap: break-word; max-width: 100%; - display: inline-block; + display: block; &.image-bubble { padding: 0; @@ -99,8 +116,8 @@ } .text-content { - font-size: 14px; - line-height: 1.6; + font-size: 15px; + line-height: 1.7; color: var(--text-primary); white-space: pre-wrap; word-break: break-word; @@ -108,23 +125,84 @@ .media-content { img { - max-width: 100%; - max-height: 300px; - border-radius: 8px; + max-width: min(100%, 420px); + max-height: 320px; + border-radius: 12px; display: block; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); + background: color-mix(in srgb, var(--bg-secondary) 88%, transparent); } .media-tip { - padding: 8px 12px; + padding: 6px 0; color: var(--text-tertiary); font-size: 13px; } } .media-placeholder { - font-size: 14px; + font-size: 13px; color: var(--text-secondary); - padding: 4px 0; + padding: 4px 0 0; + } + + .nested-chat-record-card { + min-width: 220px; + max-width: 320px; + background: color-mix(in srgb, var(--bg-secondary) 97%, #f5f7fb); + border: 1px solid var(--border-color); + border-radius: 14px; + overflow: hidden; + padding: 0; + text-align: left; + cursor: default; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04); + + &.clickable { + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08); + border-color: color-mix(in srgb, var(--primary) 28%, var(--border-color)); + } + } + + &:disabled { + border: 1px solid var(--border-color); + opacity: 1; + } + } + + .nested-chat-record-title { + padding: 13px 15px 9px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + .nested-chat-record-list { + padding: 0 15px 11px; + display: flex; + flex-direction: column; + gap: 4px; + border-bottom: 1px solid var(--border-color); + } + + .nested-chat-record-line { + font-size: 13px; + line-height: 1.45; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .nested-chat-record-footer { + padding: 8px 15px 11px; + font-size: 12px; + color: var(--text-tertiary); } } } diff --git a/src/pages/ChatHistoryPage.tsx b/src/pages/ChatHistoryPage.tsx index 45404e8..830b389 100644 --- a/src/pages/ChatHistoryPage.tsx +++ b/src/pages/ChatHistoryPage.tsx @@ -2,10 +2,14 @@ import { useEffect, useState } from 'react' import { useParams, useLocation } from 'react-router-dom' import { ChatRecordItem } from '../types/models' import TitleBar from '../components/TitleBar' +import { ErrorBoundary } from '../components/ErrorBoundary' +import { Avatar } from '../components/Avatar' import './ChatHistoryPage.scss' +const forwardedImageCache = new Map() + export default function ChatHistoryPage() { - const params = useParams<{ sessionId: string; messageId: string }>() + const params = useParams<{ sessionId: string; messageId: string; payloadId: string }>() const location = useLocation() const [recordList, setRecordList] = useState([]) const [loading, setLoading] = useState(true) @@ -29,64 +33,212 @@ export default function ChatHistoryPage() { .replace(/'/g, "'") } + const extractTopLevelXmlElements = (source: string, tagName: string): Array<{ attrs: string; inner: string }> => { + const xml = source || '' + if (!xml) return [] + + const pattern = new RegExp(`<(/?)${tagName}\\b([^>]*)>`, 'gi') + const result: Array<{ attrs: string; inner: string }> = [] + let match: RegExpExecArray | null + let depth = 0 + let openEnd = -1 + let openStart = -1 + let openAttrs = '' + + while ((match = pattern.exec(xml)) !== null) { + const isClosing = match[1] === '/' + const attrs = match[2] || '' + const rawTag = match[0] || '' + const selfClosing = !isClosing && /\/\s*>$/.test(rawTag) + + if (!isClosing) { + if (depth === 0) { + openStart = match.index + openEnd = pattern.lastIndex + openAttrs = attrs + } + if (!selfClosing) { + depth += 1 + } else if (depth === 0 && openEnd >= 0) { + result.push({ attrs: openAttrs, inner: '' }) + openStart = -1 + openEnd = -1 + openAttrs = '' + } + continue + } + + if (depth <= 0) continue + depth -= 1 + if (depth === 0 && openEnd >= 0 && openStart >= 0) { + result.push({ + attrs: openAttrs, + inner: xml.slice(openEnd, match.index) + }) + openStart = -1 + openEnd = -1 + openAttrs = '' + } + } + + return result + } + + const parseChatRecordDataItem = (body: string, attrs = ''): ChatRecordItem | null => { + const datatypeMatch = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '') + const datatype = datatypeMatch ? parseInt(datatypeMatch[1], 10) : parseInt(extractXmlValue(body, 'datatype') || '0', 10) + + const sourcename = decodeHtmlEntities(extractXmlValue(body, 'sourcename')) || '' + const sourcetime = extractXmlValue(body, 'sourcetime') || '' + const sourceheadurl = extractXmlValue(body, 'sourceheadurl') || undefined + const datadesc = decodeHtmlEntities(extractXmlValue(body, 'datadesc') || extractXmlValue(body, 'content')) || undefined + const datatitle = decodeHtmlEntities(extractXmlValue(body, 'datatitle')) || undefined + const fileext = extractXmlValue(body, 'fileext') || undefined + const datasize = parseInt(extractXmlValue(body, 'datasize') || '0', 10) || undefined + const messageuuid = extractXmlValue(body, 'messageuuid') || undefined + + const dataurl = decodeHtmlEntities(extractXmlValue(body, 'dataurl')) || undefined + const datathumburl = decodeHtmlEntities( + extractXmlValue(body, 'datathumburl') || + extractXmlValue(body, 'thumburl') || + extractXmlValue(body, 'cdnthumburl') + ) || undefined + const datacdnurl = decodeHtmlEntities( + extractXmlValue(body, 'datacdnurl') || + extractXmlValue(body, 'cdnurl') || + extractXmlValue(body, 'cdndataurl') + ) || undefined + const cdndatakey = decodeHtmlEntities(extractXmlValue(body, 'cdndatakey')) || undefined + const cdnthumbkey = decodeHtmlEntities(extractXmlValue(body, 'cdnthumbkey')) || undefined + const aeskey = decodeHtmlEntities(extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')) || undefined + const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5') || undefined + const fullmd5 = extractXmlValue(body, 'fullmd5') || undefined + const thumbfullmd5 = extractXmlValue(body, 'thumbfullmd5') || undefined + const srcMsgLocalid = parseInt(extractXmlValue(body, 'srcMsgLocalid') || '0', 10) || undefined + const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0', 10) || undefined + const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0', 10) || undefined + const duration = parseInt(extractXmlValue(body, 'duration') || '0', 10) || undefined + const nestedRecordXml = extractXmlValue(body, 'recordxml') || undefined + const chatRecordTitle = decodeHtmlEntities( + (nestedRecordXml && extractXmlValue(nestedRecordXml, 'title')) || + datatitle || + '' + ) || undefined + const chatRecordDesc = decodeHtmlEntities( + (nestedRecordXml && extractXmlValue(nestedRecordXml, 'desc')) || + datadesc || + '' + ) || undefined + const chatRecordList = + datatype === 17 && nestedRecordXml + ? parseChatRecordContainer(nestedRecordXml) + : undefined + + if (!(datatype || sourcename || datadesc || datatitle || messageuuid || srcMsgLocalid)) return null + + return { + datatype: Number.isFinite(datatype) ? datatype : 0, + sourcename, + sourcetime, + sourceheadurl, + datadesc, + datatitle, + fileext, + datasize, + messageuuid, + dataurl, + datathumburl, + datacdnurl, + cdndatakey, + cdnthumbkey, + aeskey, + md5, + fullmd5, + thumbfullmd5, + srcMsgLocalid, + imgheight, + imgwidth, + duration, + chatRecordTitle, + chatRecordDesc, + chatRecordList + } + } + + const parseChatRecordContainer = (containerXml: string): ChatRecordItem[] => { + const source = containerXml || '' + if (!source) return [] + + const segments: string[] = [source] + const decodedContainer = decodeHtmlEntities(source) + if (decodedContainer && decodedContainer !== source) { + segments.push(decodedContainer) + } + + const cdataRegex = //g + let cdataMatch: RegExpExecArray | null + while ((cdataMatch = cdataRegex.exec(source)) !== null) { + const cdataInner = cdataMatch[1] || '' + if (!cdataInner) continue + segments.push(cdataInner) + const decodedInner = decodeHtmlEntities(cdataInner) + if (decodedInner && decodedInner !== cdataInner) { + segments.push(decodedInner) + } + } + + const items: ChatRecordItem[] = [] + const dedupe = new Set() + for (const segment of segments) { + if (!segment) continue + const dataItems = extractTopLevelXmlElements(segment, 'dataitem') + for (const dataItem of dataItems) { + const item = parseChatRecordDataItem(dataItem.inner || '', dataItem.attrs || '') + if (!item) continue + const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}` + if (!dedupe.has(key)) { + dedupe.add(key) + items.push(item) + } + } + } + + if (items.length > 0) return items + const fallback = parseChatRecordDataItem(source, '') + return fallback ? [fallback] : [] + } + // 前端兜底解析合并转发聊天记录 const parseChatHistory = (content: string): ChatRecordItem[] | undefined => { try { - const type = extractXmlValue(content, 'type') - if (type !== '19') return undefined + const decodedContent = decodeHtmlEntities(content) || content + const type = extractXmlValue(decodedContent, 'type') + if (type !== '19' && !decodedContent.includes('[\s\S]*?[\s\S]*?<\/recorditem>/.exec(content) - if (!match) return undefined - - const innerXml = match[1] const items: ChatRecordItem[] = [] - const itemRegex = /([\s\S]*?)<\/dataitem>/g - let itemMatch: RegExpExecArray | null + const dedupe = new Set() + const recordItemRegex = /([\s\S]*?)<\/recorditem>/gi + let recordItemMatch: RegExpExecArray | null + while ((recordItemMatch = recordItemRegex.exec(decodedContent)) !== null) { + const parsedItems = parseChatRecordContainer(recordItemMatch[1] || '') + for (const item of parsedItems) { + const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}` + if (!dedupe.has(key)) { + dedupe.add(key) + items.push(item) + } + } + } - while ((itemMatch = itemRegex.exec(innerXml)) !== null) { - const attrs = itemMatch[1] - const body = itemMatch[2] - - const datatypeMatch = /datatype="(\d+)"/.exec(attrs) - const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0 - - const sourcename = extractXmlValue(body, 'sourcename') - const sourcetime = extractXmlValue(body, 'sourcetime') - const sourceheadurl = extractXmlValue(body, 'sourceheadurl') - const datadesc = extractXmlValue(body, 'datadesc') - const datatitle = extractXmlValue(body, 'datatitle') - const fileext = extractXmlValue(body, 'fileext') - const datasize = parseInt(extractXmlValue(body, 'datasize') || '0') - const messageuuid = extractXmlValue(body, 'messageuuid') - - const dataurl = extractXmlValue(body, 'dataurl') - const datathumburl = extractXmlValue(body, 'datathumburl') || extractXmlValue(body, 'thumburl') - const datacdnurl = extractXmlValue(body, 'datacdnurl') || extractXmlValue(body, 'cdnurl') - const aeskey = extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey') - const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5') - const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0') - const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0') - const duration = parseInt(extractXmlValue(body, 'duration') || '0') - - items.push({ - datatype, - sourcename, - sourcetime, - sourceheadurl, - datadesc: decodeHtmlEntities(datadesc), - datatitle: decodeHtmlEntities(datatitle), - fileext, - datasize, - messageuuid, - dataurl: decodeHtmlEntities(dataurl), - datathumburl: decodeHtmlEntities(datathumburl), - datacdnurl: decodeHtmlEntities(datacdnurl), - aeskey: decodeHtmlEntities(aeskey), - md5, - imgheight, - imgwidth, - duration - }) + if (items.length === 0 && decodedContent.includes(' 0 ? items : undefined @@ -114,9 +266,34 @@ export default function ChatHistoryPage() { return { sid: '', mid: '' } } + const ids = getIds() + const payloadId = params.payloadId || (() => { + const match = /^\/chat-history-inline\/([^/]+)/.exec(location.pathname) + return match ? match[1] : '' + })() + useEffect(() => { const loadData = async () => { - const { sid, mid } = getIds() + if (payloadId) { + try { + const result = await window.electronAPI.window.getChatHistoryPayload(payloadId) + if (result.success && result.payload) { + setRecordList(Array.isArray(result.payload.recordList) ? result.payload.recordList : []) + setTitle(result.payload.title || '聊天记录') + setError('') + } else { + setError(result.error || '聊天记录载荷不存在') + } + } catch (e) { + console.error(e) + setError('加载详情失败') + } finally { + setLoading(false) + } + return + } + + const { sid, mid } = ids if (!sid || !mid) { setError('无效的聊天记录链接') setLoading(false) @@ -152,7 +329,7 @@ export default function ChatHistoryPage() { } } loadData() - }, [params.sessionId, params.messageId, location.pathname]) + }, [ids.mid, ids.sid, location.pathname, payloadId]) return (
@@ -166,7 +343,9 @@ export default function ChatHistoryPage() {
暂无可显示的聊天记录
) : ( recordList.map((item, i) => ( - + 消息解析失败
}> + + )) )}
@@ -174,7 +353,198 @@ export default function ChatHistoryPage() { ) } -function HistoryItem({ item }: { item: ChatRecordItem }) { +function detectImageMimeFromBase64(base64: string): string { + try { + const head = window.atob(base64.slice(0, 48)) + const bytes = new Uint8Array(head.length) + for (let i = 0; i < head.length; i++) { + bytes[i] = head.charCodeAt(i) + } + if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif' + if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'image/png' + if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'image/jpeg' + if ( + bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && + bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 + ) { + return 'image/webp' + } + } catch { } + return 'image/jpeg' +} + +function normalizeChatRecordText(value?: string): string { + return String(value || '') + .replace(/\u00a0/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function getChatRecordPreviewText(item: ChatRecordItem): string { + const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle) + if (item.datatype === 17) { + return normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录' + } + if (item.datatype === 2 || item.datatype === 3) return '[图片]' + if (item.datatype === 43) return '[视频]' + if (item.datatype === 34) return '[语音]' + if (item.datatype === 47) return '[表情]' + return text || '[媒体消息]' +} + +function ForwardedImage({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) { + const cacheKey = + item.thumbfullmd5 || + item.fullmd5 || + item.md5 || + item.messageuuid || + item.datathumburl || + item.datacdnurl || + item.dataurl || + `local:${item.srcMsgLocalid || 0}` + const [localPath, setLocalPath] = useState(() => forwardedImageCache.get(cacheKey)) + const [loading, setLoading] = useState(!forwardedImageCache.has(cacheKey)) + const [error, setError] = useState(false) + + useEffect(() => { + if (localPath || error) return + + let cancelled = false + const candidateMd5s = Array.from(new Set([ + item.thumbfullmd5, + item.fullmd5, + item.md5 + ].filter(Boolean) as string[])) + + const load = async () => { + setLoading(true) + + for (const imageMd5 of candidateMd5s) { + const cached = await window.electronAPI.image.resolveCache({ imageMd5 }) + if (cached.success && cached.localPath) { + if (!cancelled) { + forwardedImageCache.set(cacheKey, cached.localPath) + setLocalPath(cached.localPath) + setLoading(false) + } + return + } + } + + for (const imageMd5 of candidateMd5s) { + const decrypted = await window.electronAPI.image.decrypt({ imageMd5 }) + if (decrypted.success && decrypted.localPath) { + if (!cancelled) { + forwardedImageCache.set(cacheKey, decrypted.localPath) + setLocalPath(decrypted.localPath) + setLoading(false) + } + return + } + } + + if (sessionId && item.srcMsgLocalid) { + const fallback = await window.electronAPI.chat.getImageData(sessionId, String(item.srcMsgLocalid)) + if (fallback.success && fallback.data) { + const dataUrl = `data:${detectImageMimeFromBase64(fallback.data)};base64,${fallback.data}` + if (!cancelled) { + forwardedImageCache.set(cacheKey, dataUrl) + setLocalPath(dataUrl) + setLoading(false) + } + return + } + } + + const remoteSrc = item.dataurl || item.datathumburl || item.datacdnurl + if (remoteSrc && /^https?:\/\//i.test(remoteSrc)) { + if (!cancelled) { + setLocalPath(remoteSrc) + setLoading(false) + } + return + } + + if (!cancelled) { + setError(true) + setLoading(false) + } + } + + load().catch(() => { + if (!cancelled) { + setError(true) + setLoading(false) + } + }) + + return () => { + cancelled = true + } + }, [cacheKey, error, item.dataurl, item.datacdnurl, item.datathumburl, item.fullmd5, item.md5, item.messageuuid, item.srcMsgLocalid, item.thumbfullmd5, localPath, sessionId]) + + if (localPath) { + return ( +
+ 图片 +
+ ) + } + + if (loading) { + return
图片加载中...
+ } + + if (error) { + return
图片未索引到本地缓存
+ } + + return
[图片]
+} + +function NestedChatRecordCard({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) { + const previewItems = (item.chatRecordList || []).slice(0, 3) + const title = normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录' + const description = normalizeChatRecordText(item.chatRecordDesc) || normalizeChatRecordText(item.datadesc) + const canOpen = Boolean(sessionId && item.chatRecordList && item.chatRecordList.length > 0) + + const handleOpen = () => { + if (!canOpen) return + window.electronAPI.window.openChatHistoryPayloadWindow({ + sessionId, + title, + recordList: item.chatRecordList || [] + }).catch(() => { }) + } + + return ( + + ) +} + +function HistoryItem({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) { // sourcetime 在合并转发里有两种格式: // 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46" let time = '' @@ -186,34 +556,18 @@ function HistoryItem({ item }: { item: ChatRecordItem }) { } } + const senderDisplayName = item.sourcename ?? '未知发送者' + const renderContent = () => { if (item.datatype === 1) { // 文本消息 return
{item.datadesc || ''}
} - if (item.datatype === 3) { - // 图片 - const src = item.datathumburl || item.datacdnurl - if (src) { - return ( -
- 图片 { - const target = e.target as HTMLImageElement - target.style.display = 'none' - const placeholder = document.createElement('div') - placeholder.className = 'media-tip' - placeholder.textContent = '图片无法加载' - target.parentElement?.appendChild(placeholder) - }} - /> -
- ) - } - return
[图片]
+ if (item.datatype === 2 || item.datatype === 3) { + return + } + if (item.datatype === 17) { + return } if (item.datatype === 43) { return
[视频] {item.datatitle}
@@ -227,21 +581,20 @@ function HistoryItem({ item }: { item: ChatRecordItem }) { return (
-
- {item.sourceheadurl ? ( - - ) : ( -
- {item.sourcename?.slice(0, 1)} -
- )} +
+
- {item.sourcename || '未知发送者'} + {senderDisplayName} {time}
-
+
{renderContent()}
diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 953341b..89049bf 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -490,6 +490,18 @@ gap: 8px; -webkit-app-region: no-drag; + .jump-calendar-anchor { + position: relative; + display: flex; + align-items: center; + isolation: isolate; + z-index: 20; + + .jump-date-popover { + z-index: 2600; + } + } + .icon-btn { width: 34px; height: 34px; @@ -534,11 +546,28 @@ overflow: hidden; } + .export-prepare-hint { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 24px; + font-size: 12px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + -webkit-app-region: no-drag; + + .spin { + animation: spin 1s linear infinite; + } + } + .message-list { flex: 1; background: var(--chat-pattern); background-color: var(--bg-secondary); - padding: 20px 24px; + padding: 20px 24px 112px; + padding-bottom: calc(112px + env(safe-area-inset-bottom)); &::-webkit-scrollbar { width: 6px; @@ -572,7 +601,8 @@ } .message-wrapper { - margin-bottom: 16px; + box-sizing: border-box; + padding-bottom: 16px; } .message-bubble { @@ -815,6 +845,24 @@ min-width: 0; } + .session-sync-indicator { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 999px; + background: var(--bg-primary); + color: var(--text-tertiary); + font-size: 11px; + white-space: nowrap; + border: 1px solid var(--border-color); + flex-shrink: 0; + + .spin { + animation: spin 0.9s linear infinite; + } + } + .search-box { flex: 1; display: flex; @@ -1083,8 +1131,12 @@ color: var(--text-secondary); white-space: nowrap; overflow: hidden; - text-overflow: ellipsis; flex: 1; + + .highlight { + color: var(--primary); + font-weight: 500; + } } .unread-badge { @@ -1559,6 +1611,7 @@ align-items: center; gap: 12px; border-bottom: 1px solid var(--border-color); + -webkit-app-region: drag; .session-avatar { width: 40px; @@ -1592,6 +1645,14 @@ display: flex; align-items: center; gap: 8px; + -webkit-app-region: no-drag; + + .jump-calendar-anchor { + position: relative; + display: flex; + align-items: center; + isolation: isolate; + } } .icon-btn { @@ -1624,6 +1685,10 @@ opacity: 0.5; cursor: not-allowed; } + + .spin { + animation: spin 1s linear infinite; + } } } @@ -1651,6 +1716,33 @@ opacity: 0; pointer-events: none; } + + &.switching .message-list { + opacity: 0.42; + transform: scale(0.995); + filter: saturate(0.72) blur(1px); + pointer-events: none; + } + + &.switching .loading-overlay { + background: rgba(127, 127, 127, 0.18); + backdrop-filter: blur(4px); + } +} + +.export-prepare-hint { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 20px; + font-size: 12px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + + .spin { + animation: spin 1s linear infinite; + } } .message-list { @@ -1658,7 +1750,8 @@ overflow-y: auto; overflow-x: hidden; min-height: 0; - padding: 20px 24px; + padding: 20px 24px 112px; + padding-bottom: calc(112px + env(safe-area-inset-bottom)); display: flex; flex-direction: column; gap: 16px; @@ -1666,7 +1759,7 @@ background-color: var(--bg-tertiary); position: relative; -webkit-app-region: no-drag !important; - transition: opacity 240ms ease, transform 240ms ease; + transition: opacity 240ms ease, transform 240ms ease, filter 220ms ease; // 滚动条样式 &::-webkit-scrollbar { @@ -1687,6 +1780,10 @@ } } +.message-virtuoso { + width: 100%; +} + .loading-messages.loading-overlay { position: absolute; inset: 0; @@ -1699,6 +1796,30 @@ z-index: 2; } +.standalone-phase-overlay { + position: absolute; + inset: 0; + z-index: 3; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + background: color-mix(in srgb, var(--bg-tertiary) 82%, transparent); + color: var(--text-secondary); + font-size: 14px; + pointer-events: none; + + .spin { + animation: spin 1s linear infinite; + } + + small { + color: var(--text-tertiary); + font-size: 12px; + } +} + .empty-chat-inline { display: flex; flex-direction: column; @@ -1720,9 +1841,9 @@ // 回到底部按钮 .scroll-to-bottom { - position: sticky; + position: absolute; bottom: 20px; - align-self: center; + left: 50%; padding: 8px 16px; border-radius: 20px; background: var(--bg-secondary); @@ -1737,13 +1858,13 @@ font-size: 13px; z-index: 10; opacity: 0; - transform: translateY(20px); + transform: translate(-50%, 20px); pointer-events: none; transition: all 0.3s ease; &.show { opacity: 1; - transform: translateY(0); + transform: translate(-50%, 0); pointer-events: auto; } @@ -1780,6 +1901,8 @@ .message-wrapper { display: flex; flex-direction: column; + box-sizing: border-box; + padding-bottom: 16px; -webkit-app-region: no-drag; &.sent { @@ -1946,6 +2069,10 @@ object-fit: contain; } +.emoji-message-wrapper { + display: inline-block; +} + .emoji-loading { width: 90px; height: 90px; @@ -2293,7 +2420,6 @@ background: rgba(0, 0, 0, 0.04); border-left: 2px solid var(--primary); padding: 6px 10px; - margin-bottom: 8px; border-radius: 4px; font-size: 13px; @@ -2355,6 +2481,14 @@ .bubble-content { -webkit-app-region: no-drag; + + &.quote-layout-top .quoted-message { + margin-bottom: 8px; + } + + &.quote-layout-bottom .quoted-message { + margin-top: 8px; + } } // 时间分隔 @@ -2651,7 +2785,7 @@ display: flex; align-items: center; gap: 6px; - font-size: 12px; + font-size: 14px; font-weight: 600; color: var(--text-secondary); margin-bottom: 12px; @@ -2662,6 +2796,13 @@ opacity: 0.7; } } + + .detail-stats-meta { + margin-top: -6px; + margin-bottom: 10px; + font-size: 12px; + color: var(--text-tertiary); + } } .detail-item { @@ -2699,6 +2840,26 @@ } } + .detail-inline-btn { + border: none; + background: var(--bg-secondary); + color: var(--primary); + border-radius: 6px; + padding: 4px 8px; + font-size: 12px; + line-height: 1; + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 0.7; + } + + &:hover:not(:disabled) { + background: var(--bg-hover); + } + } + .copy-btn { display: flex; align-items: center; @@ -2736,6 +2897,14 @@ gap: 8px; } + .detail-table-placeholder { + padding: 10px 12px; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 12px; + color: var(--text-secondary); + } + .table-item { display: flex; align-items: center; @@ -2757,6 +2926,190 @@ } } +.group-members-panel { + .group-members-toolbar { + padding: 12px 16px 10px; + border-bottom: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 8px; + } + + .group-members-count { + font-size: 12px; + color: var(--text-secondary); + } + + .group-members-search { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); + + svg { + color: var(--text-tertiary); + flex-shrink: 0; + } + + input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--text-primary); + font-size: 13px; + + &::placeholder { + color: var(--text-tertiary); + } + } + } + + .group-members-status { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 16px; + font-size: 12px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); + + .spin { + animation: spin 1s linear infinite; + } + + &.warning { + color: #b45309; + background: color-mix(in srgb, #f59e0b 10%, transparent); + } + } + + .group-members-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 4px 0; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 2px; + } + } + + .group-member-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--border-color); + + &:last-child { + border-bottom: none; + } + } + + .group-member-main { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + flex: 1; + } + + .group-member-avatar { + flex-shrink: 0; + } + + .group-member-meta { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + .group-member-name-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + } + + .group-member-name { + font-size: 13px; + color: var(--text-primary); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .group-member-id { + font-size: 11px; + color: var(--text-tertiary); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .group-member-badges { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + + .member-flag { + height: 18px; + padding: 0 6px; + border-radius: 9999px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-color); + font-size: 11px; + white-space: nowrap; + + &.owner { + color: #f59e0b; + background: color-mix(in srgb, #f59e0b 16%, transparent); + border-color: color-mix(in srgb, #f59e0b 35%, var(--border-color)); + } + + &.friend { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 14%, transparent); + border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color)); + } + } + + .group-member-count { + flex-shrink: 0; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + + &.loading { + color: var(--text-tertiary); + font-weight: 500; + } + + &.failed { + color: #b45309; + font-weight: 600; + } + } +} + @keyframes slideInRight { from { opacity: 0; @@ -2961,13 +3314,89 @@ // 聊天记录消息 (合并转发) .chat-record-message { - background: var(--card-inner-bg) !important; - border: 1px solid var(--border-color) !important; - transition: opacity 0.2s ease; + width: 300px; + min-width: 240px; + max-width: 336px; + background: color-mix(in srgb, var(--bg-secondary) 97%, #f5f7fb); + border: 1px solid var(--border-color); + border-radius: 16px; + overflow: hidden; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; cursor: pointer; + padding: 0; &:hover { - opacity: 0.85; + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08); + border-color: color-mix(in srgb, var(--primary) 28%, var(--border-color)); + } + + .chat-record-title { + padding: 13px 16px 6px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.45; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .chat-record-meta-line { + padding: 0 16px 10px; + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .chat-record-list { + padding: 0 16px 11px; + display: flex; + flex-direction: column; + gap: 4px; + max-height: 92px; + overflow: hidden; + border-bottom: 1px solid var(--border-color); + } + + .chat-record-item { + font-size: 12px; + line-height: 1.45; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .source-name { + color: currentColor; + opacity: 0.92; + font-weight: 500; + margin-right: 4px; + } + + .chat-record-more { + font-size: 11px; + color: var(--text-tertiary); + } + + .chat-record-desc { + padding: 0 16px 11px; + font-size: 12px; + line-height: 1.45; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + } + + .chat-record-footer { + padding: 8px 16px 10px; + font-size: 11px; + color: var(--text-tertiary); } } @@ -3041,75 +3470,6 @@ } } -// 聊天记录消息 - 复用 link-message 基础样式 -.chat-record-message { - cursor: pointer; - - .link-header { - padding-bottom: 4px; - } - - .chat-record-preview { - display: flex; - flex-direction: column; - gap: 4px; - flex: 1; - min-width: 0; - } - - .chat-record-meta-line { - font-size: 11px; - color: var(--text-tertiary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .chat-record-list { - display: flex; - flex-direction: column; - gap: 2px; - max-height: 70px; - overflow: hidden; - } - - .chat-record-item { - font-size: 12px; - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .source-name { - color: var(--text-primary); - font-weight: 500; - margin-right: 4px; - } - - .chat-record-more { - font-size: 12px; - color: var(--primary); - } - - .chat-record-desc { - font-size: 12px; - color: var(--text-secondary); - } - - .chat-record-icon { - width: 40px; - height: 40px; - border-radius: 10px; - background: var(--primary-gradient); - display: flex; - align-items: center; - justify-content: center; - color: #fff; - flex-shrink: 0; - } -} - // 小程序消息 .miniapp-message { display: flex; @@ -3206,23 +3566,18 @@ .message-bubble.sent { .card-message, - .chat-record-message, .miniapp-message, .appmsg-rich-card { background: var(--sent-card-bg); .card-name, .miniapp-title, - .source-name, .link-title { color: white; } .card-label, .miniapp-label, - .chat-record-item, - .chat-record-meta-line, - .chat-record-desc, .link-desc, .appmsg-url-line { color: rgba(255, 255, 255, 0.8); @@ -3230,14 +3585,10 @@ .card-icon, .miniapp-icon, - .chat-record-icon { + .link-thumb-placeholder { color: white; } - .chat-record-more { - color: rgba(255, 255, 255, 0.9); - } - .appmsg-meta-badge { color: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.12); @@ -3318,11 +3669,11 @@ // 批量转写按钮 .batch-transcribe-btn { &:hover:not(:disabled) { - color: var(--primary-color); + color: var(--primary); } &.transcribing { - color: var(--primary-color); + color: var(--primary); cursor: pointer; opacity: 1 !important; } @@ -3346,7 +3697,7 @@ border-bottom: 1px solid var(--border-color); svg { - color: var(--primary-color); + color: var(--primary); } h3 { @@ -3367,6 +3718,36 @@ line-height: 1.6; } + .batch-task-switch { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + margin-bottom: 1rem; + + .batch-task-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + border-radius: 8px; + padding: 0.55rem 0.75rem; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 50%, var(--border-color)); + color: var(--text-primary); + } + + &.active { + border-color: var(--primary); + color: var(--primary); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 25%, transparent); + } + } + } + .batch-dates-list-wrap { margin-bottom: 1rem; background: var(--bg-tertiary); @@ -3384,7 +3765,7 @@ .batch-dates-btn { padding: 0.35rem 0.75rem; font-size: 12px; - color: var(--primary-color); + color: var(--primary); background: transparent; border: 1px solid var(--border-color); border-radius: 6px; @@ -3393,7 +3774,7 @@ &:hover { background: var(--bg-hover); - border-color: var(--primary-color); + border-color: var(--primary); } } } @@ -3426,9 +3807,14 @@ } input[type="checkbox"] { - accent-color: var(--primary-color); + accent-color: var(--primary); cursor: pointer; flex-shrink: 0; + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 45%, transparent); + outline-offset: 1px; + } } .batch-date-label { @@ -3471,7 +3857,7 @@ .value { font-size: 14px; font-weight: 600; - color: var(--primary-color); + color: var(--primary); } .batch-concurrency-field { @@ -3597,7 +3983,7 @@ &.btn-primary, &.batch-transcribe-start-btn { - background: var(--primary-color); + background: var(--primary); color: #000; &:hover { @@ -3844,43 +4230,6 @@ } } -// 聊天记录消息外观 -.chat-record-message { - background: var(--card-inner-bg) !important; - border: 1px solid var(--border-color); - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - - &:hover { - background: var(--bg-hover) !important; - } - - .chat-record-list { - font-size: 13px; - color: var(--text-tertiary); - line-height: 1.6; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--border-color); - - .chat-record-item { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - .source-name { - color: var(--text-secondary); - } - } - } - - .chat-record-more { - font-size: 12px; - color: var(--text-tertiary); - margin-top: 4px; - } -} - // 公众号文章图文消息外观 (大图模式) .official-message { display: flex; @@ -4115,21 +4464,448 @@ // 折叠群入口样式 .session-item.fold-entry { - background: var(--card-inner-bg, rgba(0,0,0,0.03)); + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background: var(--hover-bg, rgba(0,0,0,0.05)); + } .fold-entry-avatar { width: 48px; height: 48px; border-radius: 8px; - background: var(--primary-color, #07c160); + background: #fff; display: flex; align-items: center; justify-content: center; flex-shrink: 0; - color: #fff; + color: #fa9d3b; } .session-name { font-weight: 500; } -} \ No newline at end of file +} +// 消息信息弹窗 +.message-info-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; +} + +.message-info-modal { + width: 360px; + max-width: 90vw; + max-height: 80vh; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + display: flex; + flex-direction: column; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); + overflow: hidden; + + .detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--border-color); + + h4 { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin: 0; + } + + .close-btn { + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: var(--text-secondary); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + } + + .detail-content { + flex: 1; + overflow-y: auto; + padding: 16px; + + &::-webkit-scrollbar { width: 4px; } + &::-webkit-scrollbar-thumb { background: var(--text-tertiary); border-radius: 2px; } + } + + .detail-section { + margin-bottom: 20px; + &:last-child { margin-bottom: 0; } + + .section-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 12px; + letter-spacing: 0.5px; + + svg { opacity: 0.7; } + + .copy-btn { + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + &:hover { background: var(--bg-secondary); color: var(--text-primary); } + } + } + } + + .detail-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; + border-bottom: 1px solid var(--border-color); + font-size: 13px; + + &:last-child { border-bottom: none; } + + svg { color: var(--text-tertiary); flex-shrink: 0; } + + .label { color: var(--text-secondary); flex-shrink: 0; } + + .value { + flex: 1; + text-align: right; + color: var(--text-primary); + word-break: break-all; + user-select: text; + + &.highlight { color: var(--primary); font-weight: 600; } + &.mono { font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; } + } + + .copy-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; + + &:hover { background: var(--bg-secondary); color: var(--text-primary); } + svg { color: inherit; } + } + + &:hover .copy-btn { opacity: 1; } + } + + .raw-content-box { + background: var(--bg-tertiary); + border-radius: 8px; + padding: 12px; + max-height: 200px; + overflow: auto; + + pre { + margin: 0; + white-space: pre-wrap; + word-break: break-all; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 12px; + color: var(--text-primary); + user-select: text; + } + } +} + +// 会话内搜索栏 +// 会话内搜索浮窗 +.in-session-search-popup { + position: absolute; + top: 60px; + right: 16px; + width: 360px; + max-height: 500px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + z-index: 1000; + display: flex; + flex-direction: column; + overflow: hidden; + + .in-session-search-header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; + + .search-icon { + color: var(--text-secondary); + flex-shrink: 0; + } + + .search-input { + flex: 1; + border: none; + background: transparent; + outline: none; + font-size: 14px; + color: var(--text-primary); + min-width: 0; + &::placeholder { color: var(--text-tertiary); } + } + + .spin { + animation: spin 1s linear infinite; + color: var(--primary); + flex-shrink: 0; + } + + .close-btn { + padding: 4px; + border-radius: 4px; + background: transparent; + border: none; + color: var(--text-tertiary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + flex-shrink: 0; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + } + } + + .search-result-header { + padding: 6px 16px; + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; + } + + .in-session-results { + flex: 1; + overflow-y: auto; + min-height: 0; + + .result-item { + display: flex; + align-items: flex-start; + padding: 12px 16px; + cursor: pointer; + gap: 10px; + border-bottom: 1px solid var(--border-color); + transition: background 0.15s; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: var(--bg-secondary); + } + + .result-header { + flex-shrink: 0; + + .result-info { + display: none; + } + } + + .result-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + + .result-sender { + font-size: 13px; + color: var(--text-primary); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .result-text { + font-size: 13px; + color: var(--text-secondary); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + line-height: 1.4; + } + } + + .result-time { + font-size: 11px; + color: var(--text-tertiary); + flex-shrink: 0; + } + } + } + + .no-results { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + color: var(--text-tertiary); + gap: 12px; + + p { + margin: 0; + font-size: 14px; + } + } +} + +// 搜索分类标题 +.search-section-header { + padding: 8px 16px; + font-size: 12px; + color: var(--text-tertiary); + background: var(--bg-secondary); + font-weight: 500; + display: flex; + align-items: center; + gap: 6px; + + .search-phase-hint { + color: var(--primary); + font-weight: 400; + + &.done { + color: var(--text-tertiary); + } + } +} + +// 全局消息搜索结果面板 +.global-msg-search-results { + max-height: 300px; + overflow-y: auto; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + + .search-loading, + .no-results { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px; + color: var(--text-tertiary); + font-size: 13px; + } + + .search-results-list { + .session-item { + display: flex; + padding: 12px 16px; + cursor: pointer; + border-bottom: 1px solid var(--border-color); + gap: 12px; + background: var(--bg-secondary); + + &:hover { + background: var(--bg-hover); + } + + .session-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + + .session-top { + display: flex; + justify-content: space-between; + align-items: center; + + .session-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + } + } + + .session-preview { + font-size: 13px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + .highlight { + color: var(--primary); + font-weight: 500; + } + } + + .search-count { + font-size: 12px; + color: var(--primary); + } + } + } + } +} + +.msg-search-toggle-btn.active { + color: var(--accent-color, #07c160); +} +.in-session-search-btn.active { + color: var(--accent-color, #07c160); +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index b2373dd..e9f8eda 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,17 +1,33 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' +import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' +import { useShallow } from 'zustand/react/shallow' import { useChatStore } from '../stores/chatStore' -import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' +import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batchTranscribeStore' import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore' -import type { ChatSession, Message } from '../types/models' +import type { ChatRecordItem, ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { LivePhotoIcon } from '../components/LivePhotoIcon' import { AnimatedStreamingText } from '../components/AnimatedStreamingText' -import JumpToDateDialog from '../components/JumpToDateDialog' +import JumpToDatePopover from '../components/JumpToDatePopover' +import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' +import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline' import * as configService from '../services/config' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' +import { + emitOpenSingleExport, + onExportSessionStatus, + onSingleExportDialogStatus, + requestExportSessionStatus +} from '../services/exportBridge' import './ChatPage.scss' // 系统消息类型常量 @@ -20,6 +36,293 @@ const SYSTEM_MESSAGE_TYPES = [ 266287972401, // 拍一拍 ] +interface PendingInSessionSearchPayload { + sessionId: string + keyword: string + firstMsgTime: number + results: Message[] +} + +type GlobalMsgSearchPhase = 'idle' | 'seed' | 'backfill' | 'done' +type GlobalMsgSearchResult = Message & { sessionId: string } + +interface GlobalMsgPrefixCacheEntry { + keyword: string + matchedSessionIds: Set + completed: boolean +} + +type QuoteLayout = configService.QuoteLayout + +const GLOBAL_MSG_PER_SESSION_LIMIT = 10 +const GLOBAL_MSG_SEED_LIMIT = 120 +const GLOBAL_MSG_BACKFILL_CONCURRENCY = 3 +const GLOBAL_MSG_LEGACY_CONCURRENCY = 6 +const GLOBAL_MSG_SEARCH_CANCELED_ERROR = '__WEFLOW_GLOBAL_MSG_SEARCH_CANCELED__' +const GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE = 0.2 +const GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY = 'weflow.debug.searchShadowCompare' + +function isGlobalMsgSearchCanceled(error: unknown): boolean { + return String(error || '') === GLOBAL_MSG_SEARCH_CANCELED_ERROR +} + +function normalizeGlobalMsgSearchSessionId(value: unknown): string | null { + const sessionId = String(value || '').trim() + if (!sessionId) return null + return sessionId +} + +function normalizeGlobalMsgSearchMessages( + messages: Message[] | undefined, + fallbackSessionId?: string +): GlobalMsgSearchResult[] { + if (!Array.isArray(messages) || messages.length === 0) return [] + const dedup = new Set() + const normalized: GlobalMsgSearchResult[] = [] + const normalizedFallback = normalizeGlobalMsgSearchSessionId(fallbackSessionId) + + for (const message of messages) { + const raw = message as Message & { sessionId?: string; _session_id?: string } + const sessionId = normalizeGlobalMsgSearchSessionId(raw.sessionId || raw._session_id || normalizedFallback) + if (!sessionId) continue + const uniqueKey = raw.localId > 0 + ? `${sessionId}::local:${raw.localId}` + : `${sessionId}::key:${raw.messageKey || ''}:${raw.createTime || 0}` + if (dedup.has(uniqueKey)) continue + dedup.add(uniqueKey) + normalized.push({ ...message, sessionId }) + } + + return normalized +} + +function buildGlobalMsgSearchSessionMap(messages: GlobalMsgSearchResult[]): Map { + const map = new Map() + for (const message of messages) { + if (!message.sessionId) continue + const list = map.get(message.sessionId) || [] + if (list.length >= GLOBAL_MSG_PER_SESSION_LIMIT) continue + list.push(message) + map.set(message.sessionId, list) + } + return map +} + +function flattenGlobalMsgSearchSessionMap(map: Map): GlobalMsgSearchResult[] { + const all: GlobalMsgSearchResult[] = [] + for (const list of map.values()) { + if (list.length > 0) all.push(...list) + } + return sortMessagesByCreateTimeDesc(all) +} + +function normalizeChatRecordText(value?: string): string { + return String(value || '') + .replace(/\u00a0/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function hasRenderableChatRecordName(value?: string): boolean { + return value !== undefined && value !== null && String(value).length > 0 +} + +function getChatRecordPreviewText(item: ChatRecordItem): string { + const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle) + if (item.datatype === 17) { + return normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录' + } + if (item.datatype === 2 || item.datatype === 3) return '[媒体消息]' + if (item.datatype === 43) return '[视频]' + if (item.datatype === 34) return '[语音]' + if (item.datatype === 47) return '[表情]' + return text || '[媒体消息]' +} + +function buildChatRecordPreviewItems(recordList: ChatRecordItem[], maxVisible = 3): ChatRecordItem[] { + if (recordList.length <= maxVisible) return recordList.slice(0, maxVisible) + const firstNestedIndex = recordList.findIndex(item => item.datatype === 17) + if (firstNestedIndex < 0 || firstNestedIndex < maxVisible) { + return recordList.slice(0, maxVisible) + } + if (maxVisible <= 1) { + return [recordList[firstNestedIndex]] + } + return [ + ...recordList.slice(0, maxVisible - 1), + recordList[firstNestedIndex] + ] +} + +function composeGlobalMsgSearchResults( + seedMap: Map, + authoritativeMap: Map +): GlobalMsgSearchResult[] { + const merged = new Map() + for (const [sessionId, seedRows] of seedMap.entries()) { + if (authoritativeMap.has(sessionId)) { + merged.set(sessionId, authoritativeMap.get(sessionId) || []) + } else { + merged.set(sessionId, seedRows) + } + } + for (const [sessionId, rows] of authoritativeMap.entries()) { + if (!merged.has(sessionId)) merged.set(sessionId, rows) + } + return flattenGlobalMsgSearchSessionMap(merged) +} + +function shouldRunGlobalMsgShadowCompareSample(): boolean { + if (!import.meta.env.DEV) return false + try { + const forced = window.localStorage.getItem(GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY) + if (forced === '1') return true + if (forced === '0') return false + } catch { + // ignore storage read failures + } + return Math.random() < GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE +} + +function buildGlobalMsgSearchSessionLocalIds(results: GlobalMsgSearchResult[]): Record { + const grouped = new Map() + for (const row of results) { + if (!row.sessionId || row.localId <= 0) continue + const list = grouped.get(row.sessionId) || [] + list.push(row.localId) + grouped.set(row.sessionId, list) + } + const output: Record = {} + for (const [sessionId, localIds] of grouped.entries()) { + output[sessionId] = localIds + } + return output +} + +function sortMessagesByCreateTimeDesc>(items: T[]): T[] { + return [...items].sort((a, b) => { + const timeDiff = (b.createTime || 0) - (a.createTime || 0) + if (timeDiff !== 0) return timeDiff + return (b.localId || 0) - (a.localId || 0) + }) +} + +function normalizeSearchIdentityText(value?: string | null): string | undefined { + const normalized = String(value || '').trim() + if (!normalized) return undefined + const lower = normalized.toLowerCase() + if (normalized === '未知' || lower === 'unknown' || lower === 'null' || lower === 'undefined') { + return undefined + } + if (lower.startsWith('unknown_sender_')) { + return undefined + } + return normalized +} + +function normalizeSearchAvatarUrl(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 +} + +function resolveSessionDisplayName( + displayName?: string | null, + sessionId?: string | null +): string | undefined { + const normalizedSessionId = String(sessionId || '').trim() + const normalizedDisplayName = normalizeSearchIdentityText(displayName) + if (!normalizedDisplayName) return undefined + if (normalizedSessionId && normalizedDisplayName === normalizedSessionId) return undefined + return normalizedDisplayName +} + +function isFoldPlaceholderSession(sessionId?: string | null): boolean { + return String(sessionId || '').toLowerCase().includes('placeholder_foldgroup') +} + +function isWxidLikeSearchIdentity(value?: string | null): boolean { + const normalized = String(value || '').trim().toLowerCase() + if (!normalized) return false + if (normalized.startsWith('wxid_')) return true + const suffixMatch = normalized.match(/^(.+)_([a-z0-9]{4})$/i) + return Boolean(suffixMatch && suffixMatch[1].startsWith('wxid_')) +} + +function resolveSearchSenderDisplayName( + displayName?: string | null, + senderUsername?: string | null, + sessionId?: string | null +): string | undefined { + const normalizedDisplayName = normalizeSearchIdentityText(displayName) + if (!normalizedDisplayName) return undefined + + const normalizedSenderUsername = normalizeSearchIdentityText(senderUsername) + const normalizedSessionId = normalizeSearchIdentityText(sessionId) + + if (normalizedSessionId && normalizedDisplayName === normalizedSessionId) { + return undefined + } + if (isWxidLikeSearchIdentity(normalizedDisplayName)) { + return undefined + } + if ( + normalizedSenderUsername && + normalizedDisplayName === normalizedSenderUsername && + isWxidLikeSearchIdentity(normalizedSenderUsername) + ) { + return undefined + } + + return normalizedDisplayName +} + +function resolveSearchSenderUsernameFallback(value?: string | null): string | undefined { + const normalized = normalizeSearchIdentityText(value) + if (!normalized || isWxidLikeSearchIdentity(normalized)) { + return undefined + } + return normalized +} + +function buildSearchIdentityCandidates(value?: string | null): string[] { + const normalized = normalizeSearchIdentityText(value) + if (!normalized) return [] + const lower = normalized.toLowerCase() + const candidates = new Set([lower]) + if (lower.startsWith('wxid_')) { + const match = lower.match(/^(wxid_[^_]+)/i) + if (match?.[1]) { + candidates.add(match[1]) + } + } + return [...candidates] +} + +function isCurrentUserSearchIdentity( + senderUsername?: string | null, + myWxid?: string | null +): boolean { + const senderCandidates = buildSearchIdentityCandidates(senderUsername) + const selfCandidates = buildSearchIdentityCandidates(myWxid) + if (senderCandidates.length === 0 || selfCandidates.length === 0) { + return false + } + + for (const sender of senderCandidates) { + for (const self of selfCandidates) { + if (sender === self) return true + if (sender.startsWith(self + '_')) return true + if (self.startsWith(sender + '_')) return true + } + } + return false +} + interface XmlField { key: string; value: string; @@ -142,10 +445,71 @@ function cleanMessageContent(content: string): string { return content.trim() } -interface ChatPageProps { - // 保留接口以备将来扩展 +const CHAT_SESSION_LIST_CACHE_TTL_MS = 24 * 60 * 60 * 1000 +const CHAT_SESSION_PREVIEW_CACHE_TTL_MS = 24 * 60 * 60 * 1000 +const CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION = 30 +const CHAT_SESSION_PREVIEW_MAX_SESSIONS = 18 +const CHAT_SESSION_WINDOW_CACHE_TTL_MS = 12 * 60 * 60 * 1000 +const CHAT_SESSION_WINDOW_CACHE_MAX_SESSIONS = 30 +const CHAT_SESSION_WINDOW_CACHE_MAX_MESSAGES = 300 +const GROUP_MEMBERS_PANEL_CACHE_TTL_MS = 10 * 60 * 1000 +const SESSION_CONTACT_PROFILE_RETRY_INTERVAL_MS = 15 * 1000 +const SESSION_CONTACT_PROFILE_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000 + +function buildChatSessionListCacheKey(scope: string): string { + return `weflow.chat.sessions.v1::${scope || 'default'}` } +function buildChatSessionPreviewCacheKey(scope: string): string { + return `weflow.chat.preview.v1::${scope || 'default'}` +} + +function normalizeChatCacheScope(dbPath: unknown, wxid: unknown): string { + const db = String(dbPath || '').trim() + const id = String(wxid || '').trim() + if (!db && !id) return 'default' + return `${db}::${id}` +} + +function safeParseJson(raw: string | null): T | null { + if (!raw) return null + try { + return JSON.parse(raw) as T + } catch { + return null + } +} + +function formatYmdDateFromSeconds(timestamp?: number): string { + if (!timestamp || !Number.isFinite(timestamp)) return '—' + const d = new Date(timestamp * 1000) + const y = d.getFullYear() + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + return `${y}-${m}-${day}` +} + +function formatYmdHmDateTime(timestamp?: number): string { + if (!timestamp || !Number.isFinite(timestamp)) return '—' + const d = new Date(timestamp) + const y = d.getFullYear() + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + const h = `${d.getHours()}`.padStart(2, '0') + const min = `${d.getMinutes()}`.padStart(2, '0') + return `${y}-${m}-${day} ${h}:${min}` +} + +interface ChatPageProps { + standaloneSessionWindow?: boolean + initialSessionId?: string | null + standaloneSource?: string | null + standaloneInitialDisplayName?: string | null + standaloneInitialAvatarUrl?: string | null + standaloneInitialContactType?: string | null +} + +type StandaloneLoadStage = 'idle' | 'connecting' | 'loading' | 'ready' interface SessionDetail { wxid: string @@ -155,28 +519,463 @@ interface SessionDetail { alias?: string avatarUrl?: string messageCount: number + voiceMessages?: number + imageMessages?: number + videoMessages?: number + emojiMessages?: number + transferMessages?: number + redPacketMessages?: number + callMessages?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number + relationStatsLoaded?: boolean + statsUpdatedAt?: number + statsStale?: boolean firstMessageTime?: number latestMessageTime?: number messageTables: { dbName: string; tableName: string; count: number }[] } -// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts +interface SessionExportMetric { + totalMessages: number + voiceMessages: number + imageMessages: number + videoMessages: number + emojiMessages: number + transferMessages: number + redPacketMessages: number + callMessages: number + firstTimestamp?: number + lastTimestamp?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number +} + +interface SessionExportCacheMeta { + updatedAt: number + stale: boolean + includeRelations: boolean + source: 'memory' | 'disk' | 'fresh' +} + +interface SessionContactProfile { + displayName?: string + avatarUrl?: string + alias?: string + updatedAt: number +} + +type GroupMessageCountStatus = 'loading' | 'ready' | 'failed' + +interface GroupPanelMember { + username: string + displayName: string + avatarUrl?: string + nickname?: string + alias?: string + remark?: string + groupNickname?: string + isOwner?: boolean + isFriend: boolean + messageCount: number + messageCountStatus: GroupMessageCountStatus +} + +const QUOTED_SENDER_CACHE_TTL_MS = 10 * 60 * 1000 +const quotedSenderDisplayCache = new Map() +const quotedSenderDisplayLoading = new Map>() +const quotedGroupMembersCache = new Map() +const quotedGroupMembersLoading = new Map>() + +function buildQuotedSenderCacheKey( + sessionId: string, + senderUsername: string, + isGroupChat: boolean +): string { + const normalizedSessionId = normalizeSearchIdentityText(sessionId) || String(sessionId || '').trim() + const normalizedSender = normalizeSearchIdentityText(senderUsername) || String(senderUsername || '').trim() + return `${isGroupChat ? 'group' : 'direct'}::${normalizedSessionId}::${normalizedSender}` +} + +function isSameQuotedSenderIdentity(left?: string | null, right?: string | null): boolean { + const leftCandidates = buildSearchIdentityCandidates(left) + const rightCandidates = buildSearchIdentityCandidates(right) + if (leftCandidates.length === 0 || rightCandidates.length === 0) { + return false + } + + for (const leftCandidate of leftCandidates) { + for (const rightCandidate of rightCandidates) { + if (leftCandidate === rightCandidate) return true + if (leftCandidate.startsWith(rightCandidate + '_')) return true + if (rightCandidate.startsWith(leftCandidate + '_')) return true + } + } + + return false +} + +function normalizeQuotedGroupMember(member: Partial | null | undefined): GroupPanelMember | null { + const username = String(member?.username || '').trim() + if (!username) return null + + const displayName = String(member?.displayName || '').trim() + const nickname = String(member?.nickname || '').trim() + const remark = String(member?.remark || '').trim() + const alias = String(member?.alias || '').trim() + const groupNickname = String(member?.groupNickname || '').trim() + + return { + username, + displayName: displayName || groupNickname || remark || nickname || alias || username, + avatarUrl: member?.avatarUrl, + nickname, + alias, + remark, + groupNickname, + isOwner: Boolean(member?.isOwner), + isFriend: Boolean(member?.isFriend), + messageCount: Number.isFinite(member?.messageCount) ? Math.max(0, Math.floor(member?.messageCount as number)) : 0, + messageCountStatus: 'ready' + } +} + +function resolveQuotedSenderFallbackDisplayName( + sessionId: string, + senderUsername?: string | null, + fallbackDisplayName?: string | null +): string | undefined { + const resolved = resolveSearchSenderDisplayName(fallbackDisplayName, senderUsername, sessionId) + if (resolved) return resolved + return resolveSearchSenderUsernameFallback(senderUsername) +} + +function resolveQuotedSenderUsername( + fromusr?: string | null, + chatusr?: string | null +): string { + const normalizedChatUsr = String(chatusr || '').trim() + const normalizedFromUsr = String(fromusr || '').trim() + + if (normalizedChatUsr) { + return normalizedChatUsr + } + + if (normalizedFromUsr.endsWith('@chatroom')) { + return '' + } + + return normalizedFromUsr +} + +function resolveQuotedGroupMemberDisplayName(member: GroupPanelMember): string | undefined { + const remark = normalizeSearchIdentityText(member.remark) + if (remark) return remark + + const groupNickname = normalizeSearchIdentityText(member.groupNickname) + if (groupNickname) return groupNickname + + const nickname = normalizeSearchIdentityText(member.nickname) + if (nickname) return nickname + + const displayName = resolveSearchSenderDisplayName(member.displayName, member.username) + if (displayName) return displayName + + const alias = normalizeSearchIdentityText(member.alias) + if (alias) return alias + + return resolveSearchSenderUsernameFallback(member.username) +} + +function resolveQuotedPrivateDisplayName(contact: any): string | undefined { + const remark = normalizeSearchIdentityText(contact?.remark) + if (remark) return remark + + const nickname = normalizeSearchIdentityText( + contact?.nickName || contact?.nick_name || contact?.nickname + ) + if (nickname) return nickname + + const alias = normalizeSearchIdentityText(contact?.alias) + if (alias) return alias + + return undefined +} + +async function getQuotedGroupMembers(chatroomId: string): Promise { + const normalizedChatroomId = String(chatroomId || '').trim() + if (!normalizedChatroomId || !normalizedChatroomId.includes('@chatroom')) { + return [] + } + + const cached = quotedGroupMembersCache.get(normalizedChatroomId) + if (cached && Date.now() - cached.updatedAt < QUOTED_SENDER_CACHE_TTL_MS) { + return cached.members + } + + const pending = quotedGroupMembersLoading.get(normalizedChatroomId) + if (pending) return pending + + const request = window.electronAPI.groupAnalytics.getGroupMembersPanelData( + normalizedChatroomId, + { forceRefresh: false, includeMessageCounts: false } + ).then((result) => { + const members = Array.isArray(result.data) + ? result.data + .map((member) => normalizeQuotedGroupMember(member as Partial)) + .filter((member): member is GroupPanelMember => Boolean(member)) + : [] + + if (members.length > 0) { + quotedGroupMembersCache.set(normalizedChatroomId, { + members, + updatedAt: Date.now() + }) + return members + } + + return cached?.members || [] + }).catch(() => cached?.members || []).finally(() => { + quotedGroupMembersLoading.delete(normalizedChatroomId) + }) + + quotedGroupMembersLoading.set(normalizedChatroomId, request) + return request +} + +async function resolveQuotedSenderDisplayName(options: { + sessionId: string + senderUsername?: string | null + fallbackDisplayName?: string | null + isGroupChat?: boolean + myWxid?: string | null +}): Promise { + const normalizedSessionId = String(options.sessionId || '').trim() + const normalizedSender = String(options.senderUsername || '').trim() + const fallbackDisplayName = resolveQuotedSenderFallbackDisplayName( + normalizedSessionId, + normalizedSender, + options.fallbackDisplayName + ) + + if (!normalizedSender) { + return fallbackDisplayName + } + + const cacheKey = buildQuotedSenderCacheKey(normalizedSessionId, normalizedSender, Boolean(options.isGroupChat)) + const cached = quotedSenderDisplayCache.get(cacheKey) + if (cached && Date.now() - cached.updatedAt < QUOTED_SENDER_CACHE_TTL_MS) { + return cached.displayName + } + + const pending = quotedSenderDisplayLoading.get(cacheKey) + if (pending) return pending + + const request = (async (): Promise => { + if (options.isGroupChat) { + const members = await getQuotedGroupMembers(normalizedSessionId) + const matchedMember = members.find((member) => isSameQuotedSenderIdentity(member.username, normalizedSender)) + const groupDisplayName = matchedMember ? resolveQuotedGroupMemberDisplayName(matchedMember) : undefined + if (groupDisplayName) { + quotedSenderDisplayCache.set(cacheKey, { + displayName: groupDisplayName, + updatedAt: Date.now() + }) + return groupDisplayName + } + } + + if (isCurrentUserSearchIdentity(normalizedSender, options.myWxid)) { + const selfDisplayName = fallbackDisplayName || '我' + quotedSenderDisplayCache.set(cacheKey, { + displayName: selfDisplayName, + updatedAt: Date.now() + }) + return selfDisplayName + } + + try { + const contact = await window.electronAPI.chat.getContact(normalizedSender) + const contactDisplayName = resolveQuotedPrivateDisplayName(contact) + if (contactDisplayName) { + quotedSenderDisplayCache.set(cacheKey, { + displayName: contactDisplayName, + updatedAt: Date.now() + }) + return contactDisplayName + } + } catch { + // ignore contact lookup failures and fall back below + } + + try { + const profile = await window.electronAPI.chat.getContactAvatar(normalizedSender) + const profileDisplayName = normalizeSearchIdentityText(profile?.displayName) + if (profileDisplayName && !isWxidLikeSearchIdentity(profileDisplayName)) { + quotedSenderDisplayCache.set(cacheKey, { + displayName: profileDisplayName, + updatedAt: Date.now() + }) + return profileDisplayName + } + } catch { + // ignore avatar lookup failures and keep fallback usable + } + + if (fallbackDisplayName) { + quotedSenderDisplayCache.set(cacheKey, { + displayName: fallbackDisplayName, + updatedAt: Date.now() + }) + } + + return fallbackDisplayName + })().finally(() => { + quotedSenderDisplayLoading.delete(cacheKey) + }) + + quotedSenderDisplayLoading.set(cacheKey, request) + return request +} + +interface SessionListCachePayload { + updatedAt: number + sessions: ChatSession[] +} + +interface SessionPreviewCacheEntry { + updatedAt: number + messages: Message[] +} + +interface SessionPreviewCachePayload { + updatedAt: number + entries: Record +} + +interface GroupMembersPanelCacheEntry { + updatedAt: number + members: GroupPanelMember[] + includeMessageCounts: boolean +} + +interface SessionWindowCacheEntry { + updatedAt: number + messages: Message[] + currentOffset: number + hasMoreMessages: boolean + hasMoreLater: boolean + jumpStartTime: number + jumpEndTime: number +} + +interface LoadMessagesOptions { + preferLatestPath?: boolean + deferGroupSenderWarmup?: boolean + forceInitialLimit?: number + switchRequestSeq?: number + inSessionJumpRequestSeq?: number +} + // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts import { avatarLoadQueue } from '../utils/AvatarLoadQueue' import { Avatar } from '../components/Avatar' // 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染) +// 高亮搜索关键词组件 +const HighlightText = React.memo(({ text, keyword }: { text: string; keyword: string }) => { + if (!keyword) return <>{text} + + const lowerText = text.toLowerCase() + const lowerKeyword = keyword.toLowerCase() + const matchIndex = lowerText.indexOf(lowerKeyword) + + if (matchIndex === -1) return <>{text} + + // 如果匹配位置在后面且文本过长,截断前面部分 + const maxLength = 50 + let displayText = text + + if (text.length > maxLength && matchIndex > 20) { + const start = Math.max(0, matchIndex - 15) + displayText = '...' + text.slice(start) + } + + const parts = displayText.split(new RegExp(`(${keyword})`, 'gi')) + return ( + <> + {parts.map((part, i) => + part.toLowerCase() === lowerKeyword ? + {part} : part + )} + + ) +}) + +const HighlightTextNoTruncate = React.memo(({ text, keyword }: { text: string; keyword: string }) => { + if (!keyword) return <>{text} + + const lowerText = text.toLowerCase() + const lowerKeyword = keyword.toLowerCase() + const matchIndex = lowerText.indexOf(lowerKeyword) + + if (matchIndex === -1) return <>{text} + + const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const matchEnd = matchIndex + keyword.length + const maxDisplayLength = 25 + + // 如果匹配位置不在开头,或文本过长,则居中显示 + if (matchIndex > 5 || text.length > maxDisplayLength) { + const start = Math.max(0, matchIndex - 8) + const end = Math.min(text.length, matchEnd + 15) + const prefix = start > 0 ? '...' : '' + const suffix = end < text.length ? '...' : '' + const middleText = text.slice(start, end) + + const parts = middleText.split(new RegExp(`(${escapedKeyword})`, 'gi')) + return ( + <> + {prefix} + {parts.map((part, i) => + part.toLowerCase() === lowerKeyword ? + {part} : part + )} + {suffix} + + ) + } + + const parts = text.split(new RegExp(`(${escapedKeyword})`, 'gi')) + return ( + <> + {parts.map((part, i) => + part.toLowerCase() === lowerKeyword ? + {part} : part + )} + + ) +}) + // 会话项组件(使用 memo 优化,避免不必要的重渲染) const SessionItem = React.memo(function SessionItem({ session, isActive, onSelect, - formatTime + formatTime, + searchKeyword }: { session: ChatSession isActive: boolean onSelect: (session: ChatSession) => void formatTime: (timestamp: number) => string + searchKeyword?: string }) { const timeText = useMemo(() => formatTime(session.lastTimestamp || session.sortTimestamp), @@ -189,24 +988,35 @@ const SessionItem = React.memo(function SessionItem({ if (isFoldEntry) { return (
onSelect(session)} >
- +
- 折叠的群聊 + 折叠的聊天 + {timeText}
- {session.summary || ''} + {session.summary || '暂无消息'}
) } + // 根据匹配字段显示不同的 summary + const summaryContent = useMemo(() => { + if (session.matchedField === 'wxid') { + return wxid: + } else if (session.matchedField === 'alias' && session.alias) { + return 微信号: + } + return {session.summary || '暂无消息'} + }, [session.matchedField, session.username, session.alias, session.summary, searchKeyword]) + return (
- {session.displayName || session.username} + + {(() => { + const shouldHighlight = (session.matchedField as any) === 'name' && searchKeyword + return shouldHighlight ? ( + + ) : ( + session.displayName || session.username + ) + })()} + {timeText}
- {session.summary || '暂无消息'} + {summaryContent}
{session.isMuted && } {session.unreadCount > 0 && ( @@ -243,17 +1062,34 @@ const SessionItem = React.memo(function SessionItem({ prevProps.session.displayName === nextProps.session.displayName && prevProps.session.avatarUrl === nextProps.session.avatarUrl && prevProps.session.summary === nextProps.session.summary && + prevProps.session.matchedField === nextProps.session.matchedField && + prevProps.session.alias === nextProps.session.alias && prevProps.session.unreadCount === nextProps.session.unreadCount && prevProps.session.lastTimestamp === nextProps.session.lastTimestamp && prevProps.session.sortTimestamp === nextProps.session.sortTimestamp && prevProps.session.isMuted === nextProps.session.isMuted && - prevProps.isActive === nextProps.isActive + prevProps.isActive === nextProps.isActive && + prevProps.searchKeyword === nextProps.searchKeyword ) }) -function ChatPage(_props: ChatPageProps) { +function ChatPage(props: ChatPageProps) { + const { + standaloneSessionWindow = false, + initialSessionId = null, + standaloneSource = null, + standaloneInitialDisplayName = null, + standaloneInitialAvatarUrl = null, + standaloneInitialContactType = null + } = props + const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId]) + const normalizedStandaloneSource = useMemo(() => String(standaloneSource || '').trim().toLowerCase(), [standaloneSource]) + const normalizedStandaloneInitialDisplayName = useMemo(() => String(standaloneInitialDisplayName || '').trim(), [standaloneInitialDisplayName]) + const normalizedStandaloneInitialAvatarUrl = useMemo(() => String(standaloneInitialAvatarUrl || '').trim(), [standaloneInitialAvatarUrl]) + const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType]) + const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export' const navigate = useNavigate() const { @@ -261,7 +1097,6 @@ function ChatPage(_props: ChatPageProps) { isConnecting, connectionError, sessions, - filteredSessions, currentSessionId, isLoadingSessions, messages, @@ -273,7 +1108,6 @@ function ChatPage(_props: ChatPageProps) { setConnecting, setConnectionError, setSessions, - setFilteredSessions, setCurrentSession, setLoadingSessions, setMessages, @@ -284,64 +1118,161 @@ function ChatPage(_props: ChatPageProps) { hasMoreLater, setHasMoreLater, setSearchKeyword - } = useChatStore() + } = useChatStore(useShallow((state) => ({ + isConnected: state.isConnected, + isConnecting: state.isConnecting, + connectionError: state.connectionError, + sessions: state.sessions, + currentSessionId: state.currentSessionId, + isLoadingSessions: state.isLoadingSessions, + messages: state.messages, + isLoadingMessages: state.isLoadingMessages, + isLoadingMore: state.isLoadingMore, + hasMoreMessages: state.hasMoreMessages, + searchKeyword: state.searchKeyword, + setConnected: state.setConnected, + setConnecting: state.setConnecting, + setConnectionError: state.setConnectionError, + setSessions: state.setSessions, + setCurrentSession: state.setCurrentSession, + setLoadingSessions: state.setLoadingSessions, + setMessages: state.setMessages, + appendMessages: state.appendMessages, + setLoadingMessages: state.setLoadingMessages, + setLoadingMore: state.setLoadingMore, + setHasMoreMessages: state.setHasMoreMessages, + hasMoreLater: state.hasMoreLater, + setHasMoreLater: state.setHasMoreLater, + setSearchKeyword: state.setSearchKeyword + }))) const messageListRef = useRef(null) + const [messageListScrollParent, setMessageListScrollParent] = useState(null) + const messageVirtuosoRef = useRef(null) + const visibleMessageRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: 0 }) + const topRangeLoadLockRef = useRef(false) + const bottomRangeLoadLockRef = useRef(false) + const suppressAutoLoadLaterRef = useRef(false) const searchInputRef = useRef(null) const sidebarRef = useRef(null) + const handleMessageListScrollParentRef = useCallback((node: HTMLDivElement | null) => { + messageListRef.current = node + setMessageListScrollParent(node) + }, []) const getMessageKey = useCallback((msg: Message): string => { - if (msg.localId && msg.localId > 0) return `l:${msg.localId}` - return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}` + if (msg.messageKey) return msg.messageKey + return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}` }, []) const initialRevealTimerRef = useRef(null) const sessionListRef = useRef(null) + const jumpCalendarWrapRef = useRef(null) + const jumpPopoverPortalRef = useRef(null) const [currentOffset, setCurrentOffset] = useState(0) const [jumpStartTime, setJumpStartTime] = useState(0) const [jumpEndTime, setJumpEndTime] = useState(0) - const [showJumpDialog, setShowJumpDialog] = useState(false) + const [showJumpPopover, setShowJumpPopover] = useState(false) + const [jumpPopoverDate, setJumpPopoverDate] = useState(new Date()) + const [jumpPopoverPosition, setJumpPopoverPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }) const isDateJumpRef = useRef(false) const [messageDates, setMessageDates] = useState>(new Set()) + const [hasLoadedMessageDates, setHasLoadedMessageDates] = useState(false) const [loadingDates, setLoadingDates] = useState(false) const messageDatesCache = useRef>>(new Map()) + const [messageDateCounts, setMessageDateCounts] = useState>({}) + const [loadingDateCounts, setLoadingDateCounts] = useState(false) + const messageDateCountsCache = useRef>>(new Map()) const [myAvatarUrl, setMyAvatarUrl] = useState(undefined) const [myWxid, setMyWxid] = useState(undefined) const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [sidebarWidth, setSidebarWidth] = useState(260) const [isResizing, setIsResizing] = useState(false) const [showDetailPanel, setShowDetailPanel] = useState(false) + const [showGroupMembersPanel, setShowGroupMembersPanel] = useState(false) const [sessionDetail, setSessionDetail] = useState(null) const [isLoadingDetail, setIsLoadingDetail] = useState(false) + const [isLoadingDetailExtra, setIsLoadingDetailExtra] = useState(false) + const [isRefreshingDetailStats, setIsRefreshingDetailStats] = useState(false) + const [isLoadingRelationStats, setIsLoadingRelationStats] = useState(false) + const [groupPanelMembers, setGroupPanelMembers] = useState([]) + const [isLoadingGroupMembers, setIsLoadingGroupMembers] = useState(false) + const [groupMembersError, setGroupMembersError] = useState(null) + const [groupMembersLoadingHint, setGroupMembersLoadingHint] = useState('') + const [isRefreshingGroupMembers, setIsRefreshingGroupMembers] = useState(false) + const [groupMemberSearchKeyword, setGroupMemberSearchKeyword] = useState('') const [copiedField, setCopiedField] = useState(null) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图 const [hasInitialMessages, setHasInitialMessages] = useState(false) + const [isSessionSwitching, setIsSessionSwitching] = useState(false) const [noMessageTable, setNoMessageTable] = useState(false) - const [fallbackDisplayName, setFallbackDisplayName] = useState(null) + const [fallbackDisplayName, setFallbackDisplayName] = useState(normalizedStandaloneInitialDisplayName || null) + const [fallbackAvatarUrl, setFallbackAvatarUrl] = useState(normalizedStandaloneInitialAvatarUrl || null) + const [standaloneLoadStage, setStandaloneLoadStage] = useState( + standaloneSessionWindow && normalizedInitialSessionId ? 'connecting' : 'idle' + ) + const [standaloneInitialLoadRequested, setStandaloneInitialLoadRequested] = useState(false) const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) + const [autoTranscribeVoiceEnabled, setAutoTranscribeVoiceEnabled] = useState(false) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) + const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState>(new Set()) + const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false) + const [chatSnsTimelineTarget, setChatSnsTimelineTarget] = useState(null) + const [exportPrepareHint, setExportPrepareHint] = useState('') // 消息右键菜单 const [contextMenu, setContextMenu] = useState<{ x: number, y: number, message: Message } | null>(null) + const [showMessageInfo, setShowMessageInfo] = useState(null) const [editingMessage, setEditingMessage] = useState<{ message: Message, content: string } | null>(null) // 多选模式 const [isSelectionMode, setIsSelectionMode] = useState(false) - const [selectedMessages, setSelectedMessages] = useState>(new Set()) + const [selectedMessages, setSelectedMessages] = useState>(new Set()) // 编辑消息额外状态 const [editMode, setEditMode] = useState<'raw' | 'fields'>('raw') const [tempFields, setTempFields] = useState([]) // 批量语音转文字相关状态(进度/结果 由全局 store 管理) - const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore() - const { isBatchDecrypting, progress: batchDecryptProgress, startDecrypt, updateProgress: updateDecryptProgress, finishDecrypt, setShowToast: setShowBatchDecryptToast } = useBatchImageDecryptStore() + const { + isBatchTranscribing, + runningBatchVoiceTaskType, + batchTranscribeProgress, + startTranscribe, + updateProgress, + finishTranscribe, + setShowBatchProgress + } = useBatchTranscribeStore(useShallow((state) => ({ + isBatchTranscribing: state.isBatchTranscribing, + runningBatchVoiceTaskType: state.taskType, + batchTranscribeProgress: state.progress, + startTranscribe: state.startTranscribe, + updateProgress: state.updateProgress, + finishTranscribe: state.finishTranscribe, + setShowBatchProgress: state.setShowToast + }))) + const { + isBatchDecrypting, + batchDecryptProgress, + startDecrypt, + updateDecryptProgress, + finishDecrypt, + setShowBatchDecryptToast + } = useBatchImageDecryptStore(useShallow((state) => ({ + isBatchDecrypting: state.isBatchDecrypting, + batchDecryptProgress: state.progress, + startDecrypt: state.startDecrypt, + updateDecryptProgress: state.updateProgress, + finishDecrypt: state.finishDecrypt, + setShowBatchDecryptToast: state.setShowToast + }))) const [showBatchConfirm, setShowBatchConfirm] = useState(false) const [batchVoiceCount, setBatchVoiceCount] = useState(0) const [batchVoiceMessages, setBatchVoiceMessages] = useState(null) const [batchVoiceDates, setBatchVoiceDates] = useState([]) const [batchSelectedDates, setBatchSelectedDates] = useState>(new Set()) + const [batchVoiceTaskType, setBatchVoiceTaskType] = useState('transcribe') const [showBatchDecryptConfirm, setShowBatchDecryptConfirm] = useState(false) const [batchImageMessages, setBatchImageMessages] = useState(null) const [batchImageDates, setBatchImageDates] = useState([]) @@ -353,6 +1284,28 @@ function ChatPage(_props: ChatPageProps) { const [isDeleting, setIsDeleting] = useState(false) const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0 }) const [cancelDeleteRequested, setCancelDeleteRequested] = useState(false) + // 会话内搜索 + const [showInSessionSearch, setShowInSessionSearch] = useState(false) + const [inSessionQuery, setInSessionQuery] = useState('') + const [inSessionResults, setInSessionResults] = useState([]) + const [inSessionSearching, setInSessionSearching] = useState(false) + const [inSessionEnriching, setInSessionEnriching] = useState(false) + const [inSessionSearchError, setInSessionSearchError] = useState(null) + const inSessionSearchRef = useRef(null) + const inSessionResultJumpTimerRef = useRef(null) + const inSessionResultJumpRequestSeqRef = useRef(0) + // 全局消息搜索 + const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false) + const [globalMsgQuery, setGlobalMsgQuery] = useState('') + const [globalMsgResults, setGlobalMsgResults] = useState([]) + const [globalMsgSearching, setGlobalMsgSearching] = useState(false) + const [globalMsgSearchPhase, setGlobalMsgSearchPhase] = useState('idle') + const [globalMsgIsBackfilling, setGlobalMsgIsBackfilling] = useState(false) + const [globalMsgAuthoritativeSessionCount, setGlobalMsgAuthoritativeSessionCount] = useState(0) + const [globalMsgSearchError, setGlobalMsgSearchError] = useState(null) + const pendingInSessionSearchRef = useRef(null) + const pendingGlobalMsgSearchReplayRef = useRef(null) + const globalMsgPrefixCacheRef = useRef(null) // 自定义删除确认对话框 const [deleteConfirm, setDeleteConfirm] = useState<{ @@ -367,22 +1320,297 @@ function ChatPage(_props: ChatPageProps) { const enrichCancelledRef = useRef(false) const isScrollingRef = useRef(false) const sessionScrollTimeoutRef = useRef(null) + const pendingSessionContactEnrichRef = useRef>(new Set()) + const sessionContactEnrichAttemptAtRef = useRef>(new Map()) + const sessionContactProfileCacheRef = useRef>(new Map()) const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys]) const messageKeySetRef = useRef>(new Set()) const lastMessageTimeRef = useRef(0) + const isMessageListAtBottomRef = useRef(true) + const lastObservedMessageCountRef = useRef(0) + const lastVisibleSenderWarmupAtRef = useRef(0) const sessionMapRef = useRef>(new Map()) const sessionsRef = useRef([]) const currentSessionRef = useRef(null) + const pendingSessionLoadRef = useRef(null) + const sessionSwitchRequestSeqRef = useRef(0) + const initialLoadRequestedSessionRef = useRef(null) const prevSessionRef = useRef(null) - const isLoadingMessagesRef = useRef(false) - const isLoadingMoreRef = useRef(false) const isConnectedRef = useRef(false) const isRefreshingRef = useRef(false) const searchKeywordRef = useRef('') const preloadImageKeysRef = useRef>(new Set()) const lastPreloadSessionRef = useRef(null) + const detailRequestSeqRef = useRef(0) + const groupMembersRequestSeqRef = useRef(0) + const groupMembersPanelCacheRef = useRef>(new Map()) + const hasInitializedGroupMembersRef = useRef(false) + const chatCacheScopeRef = useRef('default') + const previewCacheRef = useRef>({}) + const sessionWindowCacheRef = useRef>(new Map()) + const previewPersistTimerRef = useRef(null) + const sessionListPersistTimerRef = useRef(null) + const scrollBottomButtonArmTimerRef = useRef(null) + const suppressScrollToBottomButtonRef = useRef(false) + const pendingExportRequestIdRef = useRef(null) + const exportPrepareLongWaitTimerRef = useRef(null) + const jumpDatesRequestSeqRef = useRef(0) + const jumpDateCountsRequestSeqRef = useRef(0) + + const suppressScrollToBottomButton = useCallback((delayMs = 180) => { + suppressScrollToBottomButtonRef.current = true + if (scrollBottomButtonArmTimerRef.current !== null) { + window.clearTimeout(scrollBottomButtonArmTimerRef.current) + scrollBottomButtonArmTimerRef.current = null + } + scrollBottomButtonArmTimerRef.current = window.setTimeout(() => { + suppressScrollToBottomButtonRef.current = false + scrollBottomButtonArmTimerRef.current = null + }, delayMs) + }, []) + + const isGroupChatSession = useCallback((username: string) => { + return username.includes('@chatroom') + }, []) + + const mergeSessionContactPresentation = useCallback((session: ChatSession, previousSession?: ChatSession): ChatSession => { + const username = String(session.username || '').trim() + if (!username || isFoldPlaceholderSession(username)) { + return session + } + + const now = Date.now() + const cacheMap = sessionContactProfileCacheRef.current + const cachedProfile = cacheMap.get(username) + if (cachedProfile && now - cachedProfile.updatedAt > SESSION_CONTACT_PROFILE_CACHE_TTL_MS) { + cacheMap.delete(username) + } + const profile = cacheMap.get(username) + + const sessionDisplayName = resolveSessionDisplayName(session.displayName, username) + const previousDisplayName = resolveSessionDisplayName(previousSession?.displayName, username) + const profileDisplayName = resolveSessionDisplayName(profile?.displayName, username) + const resolvedDisplayName = sessionDisplayName || previousDisplayName || profileDisplayName || session.displayName || username + + const sessionAvatarUrl = normalizeSearchAvatarUrl(session.avatarUrl) + const previousAvatarUrl = normalizeSearchAvatarUrl(previousSession?.avatarUrl) + const profileAvatarUrl = normalizeSearchAvatarUrl(profile?.avatarUrl) + const resolvedAvatarUrl = sessionAvatarUrl || previousAvatarUrl || profileAvatarUrl + + const sessionAlias = normalizeSearchIdentityText(session.alias) + const previousAlias = normalizeSearchIdentityText(previousSession?.alias) + const profileAlias = normalizeSearchIdentityText(profile?.alias) + const resolvedAlias = sessionAlias || previousAlias || profileAlias + + if ( + resolvedDisplayName === session.displayName && + resolvedAvatarUrl === session.avatarUrl && + resolvedAlias === session.alias + ) { + return session + } + + return { + ...session, + displayName: resolvedDisplayName, + avatarUrl: resolvedAvatarUrl, + alias: resolvedAlias + } + }, []) + + const clearExportPrepareState = useCallback(() => { + pendingExportRequestIdRef.current = null + setIsPreparingExportDialog(false) + setExportPrepareHint('') + if (exportPrepareLongWaitTimerRef.current) { + window.clearTimeout(exportPrepareLongWaitTimerRef.current) + exportPrepareLongWaitTimerRef.current = null + } + }, []) + + const resolveCurrentViewDate = useCallback(() => { + if (jumpStartTime > 0) { + return new Date(jumpStartTime * 1000) + } + const fallbackMessage = messages[messages.length - 1] || messages[0] + const rawTimestamp = Number(fallbackMessage?.createTime || 0) + if (Number.isFinite(rawTimestamp) && rawTimestamp > 0) { + return new Date(rawTimestamp > 10000000000 ? rawTimestamp : rawTimestamp * 1000) + } + return new Date() + }, [jumpStartTime, messages]) + + const loadJumpCalendarData = useCallback(async (sessionId: string) => { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + + const cachedDates = messageDatesCache.current.get(normalizedSessionId) + if (cachedDates) { + setMessageDates(new Set(cachedDates)) + setHasLoadedMessageDates(true) + setLoadingDates(false) + } else { + setLoadingDates(true) + setHasLoadedMessageDates(false) + setMessageDates(new Set()) + const requestSeq = jumpDatesRequestSeqRef.current + 1 + jumpDatesRequestSeqRef.current = requestSeq + try { + const result = await window.electronAPI.chat.getMessageDates(normalizedSessionId) + if (requestSeq !== jumpDatesRequestSeqRef.current || currentSessionRef.current !== normalizedSessionId) return + if (result?.success && Array.isArray(result.dates)) { + const dateSet = new Set(result.dates) + messageDatesCache.current.set(normalizedSessionId, dateSet) + setMessageDates(new Set(dateSet)) + setHasLoadedMessageDates(true) + } + } catch (error) { + console.error('获取消息日期失败:', error) + } finally { + if (requestSeq === jumpDatesRequestSeqRef.current && currentSessionRef.current === normalizedSessionId) { + setLoadingDates(false) + } + } + } + + const cachedCounts = messageDateCountsCache.current.get(normalizedSessionId) + if (cachedCounts) { + setMessageDateCounts({ ...cachedCounts }) + setLoadingDateCounts(false) + return + } + + setLoadingDateCounts(true) + setMessageDateCounts({}) + const requestSeq = jumpDateCountsRequestSeqRef.current + 1 + jumpDateCountsRequestSeqRef.current = requestSeq + try { + const result = await window.electronAPI.chat.getMessageDateCounts(normalizedSessionId) + if (requestSeq !== jumpDateCountsRequestSeqRef.current || currentSessionRef.current !== normalizedSessionId) return + if (result?.success && result.counts) { + const normalizedCounts: Record = {} + Object.entries(result.counts).forEach(([date, value]) => { + const count = Number(value) + if (!date || !Number.isFinite(count) || count <= 0) return + normalizedCounts[date] = count + }) + messageDateCountsCache.current.set(normalizedSessionId, normalizedCounts) + setMessageDateCounts(normalizedCounts) + } + } catch (error) { + console.error('获取每日消息数失败:', error) + } finally { + if (requestSeq === jumpDateCountsRequestSeqRef.current && currentSessionRef.current === normalizedSessionId) { + setLoadingDateCounts(false) + } + } + }, []) + + const updateJumpPopoverPosition = useCallback(() => { + const anchor = jumpCalendarWrapRef.current + if (!anchor) return + + const popoverWidth = 312 + const viewportGap = 8 + const anchorRect = anchor.getBoundingClientRect() + + let left = anchorRect.right - popoverWidth + left = Math.max(viewportGap, Math.min(left, window.innerWidth - popoverWidth - viewportGap)) + + const portalHeight = jumpPopoverPortalRef.current?.offsetHeight || 0 + const belowTop = anchorRect.bottom + 10 + let top = belowTop + if (portalHeight > 0 && belowTop + portalHeight > window.innerHeight - viewportGap) { + top = Math.max(viewportGap, anchorRect.top - portalHeight - 10) + } + + setJumpPopoverPosition(prev => { + if (prev.top === top && prev.left === left) return prev + return { top, left } + }) + }, []) + + const handleToggleJumpPopover = useCallback(() => { + if (!currentSessionId) return + if (showJumpPopover) { + setShowJumpPopover(false) + return + } + setJumpPopoverDate(resolveCurrentViewDate()) + updateJumpPopoverPosition() + setShowJumpPopover(true) + requestAnimationFrame(() => updateJumpPopoverPosition()) + void loadJumpCalendarData(currentSessionId) + }, [currentSessionId, loadJumpCalendarData, resolveCurrentViewDate, showJumpPopover, updateJumpPopoverPosition]) + + useEffect(() => { + const unsubscribe = onExportSessionStatus((payload) => { + const ids = Array.isArray(payload?.inProgressSessionIds) + ? payload.inProgressSessionIds + .filter((id): id is string => typeof id === 'string') + .map(id => id.trim()) + .filter(Boolean) + : [] + setInProgressExportSessionIds(new Set(ids)) + }) + + requestExportSessionStatus() + const timer = window.setTimeout(() => { + requestExportSessionStatus() + }, 0) + return () => { + window.clearTimeout(timer) + unsubscribe() + } + }, []) + + useEffect(() => { + const unsubscribe = onSingleExportDialogStatus((payload) => { + const requestId = typeof payload?.requestId === 'string' ? payload.requestId.trim() : '' + if (!requestId || requestId !== pendingExportRequestIdRef.current) return + + if (payload.status === 'initializing') { + setExportPrepareHint('正在准备导出模块(首次会稍慢,通常 1-3 秒)') + if (exportPrepareLongWaitTimerRef.current) { + window.clearTimeout(exportPrepareLongWaitTimerRef.current) + } + exportPrepareLongWaitTimerRef.current = window.setTimeout(() => { + if (pendingExportRequestIdRef.current !== requestId) return + setExportPrepareHint('仍在准备导出模块,请稍候...') + }, 8000) + return + } + + if (payload.status === 'opened') { + clearExportPrepareState() + return + } + + if (payload.status === 'failed') { + const message = (typeof payload.message === 'string' && payload.message.trim()) + ? payload.message.trim() + : '导出模块初始化失败,请重试' + clearExportPrepareState() + window.alert(message) + } + }) + + return () => { + unsubscribe() + if (exportPrepareLongWaitTimerRef.current) { + window.clearTimeout(exportPrepareLongWaitTimerRef.current) + exportPrepareLongWaitTimerRef.current = null + } + } + }, [clearExportPrepareState]) + + useEffect(() => { + if (!isPreparingExportDialog || !currentSessionId) return + if (!inProgressExportSessionIds.has(currentSessionId)) return + clearExportPrepareState() + }, [clearExportPrepareState, currentSessionId, inProgressExportSessionIds, isPreparingExportDialog]) // 加载当前用户头像 const loadMyAvatar = useCallback(async () => { @@ -396,29 +1624,989 @@ function ChatPage(_props: ChatPageProps) { } }, []) + const resolveChatCacheScope = useCallback(async (): Promise => { + try { + const [dbPath, myWxid] = await Promise.all([ + window.electronAPI.config.get('dbPath'), + window.electronAPI.config.get('myWxid') + ]) + const scope = normalizeChatCacheScope(dbPath, myWxid) + chatCacheScopeRef.current = scope + return scope + } catch { + chatCacheScopeRef.current = 'default' + return 'default' + } + }, []) + + const loadPreviewCacheFromStorage = useCallback((scope: string): Record => { + try { + const cacheKey = buildChatSessionPreviewCacheKey(scope) + const payload = safeParseJson(window.localStorage.getItem(cacheKey)) + if (!payload || typeof payload.updatedAt !== 'number' || !payload.entries) { + return {} + } + if (Date.now() - payload.updatedAt > CHAT_SESSION_PREVIEW_CACHE_TTL_MS) { + return {} + } + return payload.entries + } catch { + return {} + } + }, []) + + const persistPreviewCacheToStorage = useCallback((scope: string, entries: Record) => { + try { + const cacheKey = buildChatSessionPreviewCacheKey(scope) + const payload: SessionPreviewCachePayload = { + updatedAt: Date.now(), + entries + } + window.localStorage.setItem(cacheKey, JSON.stringify(payload)) + } catch { + // ignore cache write failures + } + }, []) + + const persistSessionPreviewCache = useCallback((sessionId: string, previewMessages: Message[]) => { + const id = String(sessionId || '').trim() + if (!id || !Array.isArray(previewMessages) || previewMessages.length === 0) return + + const trimmed = previewMessages.slice(-CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION) + const currentEntries = { ...previewCacheRef.current } + currentEntries[id] = { + updatedAt: Date.now(), + messages: trimmed + } + + const sortedIds = Object.entries(currentEntries) + .sort((a, b) => (b[1]?.updatedAt || 0) - (a[1]?.updatedAt || 0)) + .map(([entryId]) => entryId) + + const keptIds = new Set(sortedIds.slice(0, CHAT_SESSION_PREVIEW_MAX_SESSIONS)) + const compactEntries: Record = {} + for (const [entryId, entry] of Object.entries(currentEntries)) { + if (keptIds.has(entryId)) { + compactEntries[entryId] = entry + } + } + + previewCacheRef.current = compactEntries + if (previewPersistTimerRef.current !== null) { + window.clearTimeout(previewPersistTimerRef.current) + } + previewPersistTimerRef.current = window.setTimeout(() => { + persistPreviewCacheToStorage(chatCacheScopeRef.current, previewCacheRef.current) + previewPersistTimerRef.current = null + }, 220) + }, [persistPreviewCacheToStorage]) + + const hydrateSessionPreview = useCallback(async (sessionId: string) => { + const id = String(sessionId || '').trim() + if (!id) return + + const localEntry = previewCacheRef.current[id] + if ( + localEntry && + Array.isArray(localEntry.messages) && + localEntry.messages.length > 0 && + Date.now() - localEntry.updatedAt <= CHAT_SESSION_PREVIEW_CACHE_TTL_MS + ) { + setMessages(localEntry.messages.slice()) + setHasInitialMessages(true) + return + } + + try { + const result = await window.electronAPI.chat.getCachedMessages(id) + if (!result.success || !Array.isArray(result.messages) || result.messages.length === 0) { + return + } + if (currentSessionRef.current !== id && pendingSessionLoadRef.current !== id) return + setMessages(result.messages) + setHasInitialMessages(true) + persistSessionPreviewCache(id, result.messages) + } catch { + // ignore preview cache errors + } + }, [persistSessionPreviewCache, setMessages]) + + const saveSessionWindowCache = useCallback((sessionId: string, entry: Omit) => { + const id = String(sessionId || '').trim() + if (!id || !Array.isArray(entry.messages) || entry.messages.length === 0) return + + const trimmedMessages = entry.messages.length > CHAT_SESSION_WINDOW_CACHE_MAX_MESSAGES + ? entry.messages.slice(-CHAT_SESSION_WINDOW_CACHE_MAX_MESSAGES) + : entry.messages.slice() + + const cache = sessionWindowCacheRef.current + cache.set(id, { + updatedAt: Date.now(), + ...entry, + messages: trimmedMessages, + currentOffset: trimmedMessages.length + }) + + if (cache.size <= CHAT_SESSION_WINDOW_CACHE_MAX_SESSIONS) return + + const sortedByTime = [...cache.entries()] + .sort((a, b) => (a[1].updatedAt || 0) - (b[1].updatedAt || 0)) + + for (const [key] of sortedByTime) { + if (cache.size <= CHAT_SESSION_WINDOW_CACHE_MAX_SESSIONS) break + cache.delete(key) + } + }, []) + + const restoreSessionWindowCache = useCallback((sessionId: string): boolean => { + const id = String(sessionId || '').trim() + if (!id) return false + + const cache = sessionWindowCacheRef.current + const entry = cache.get(id) + if (!entry) return false + if (Date.now() - entry.updatedAt > CHAT_SESSION_WINDOW_CACHE_TTL_MS) { + cache.delete(id) + return false + } + if (!Array.isArray(entry.messages) || entry.messages.length === 0) { + cache.delete(id) + return false + } + + // LRU: 命中后更新时间 + cache.set(id, { + ...entry, + updatedAt: Date.now(), + messages: entry.messages.slice() + }) + + setMessages(entry.messages.slice()) + setCurrentOffset(entry.messages.length) + setHasMoreMessages(entry.hasMoreMessages !== false) + setHasMoreLater(entry.hasMoreLater === true) + setJumpStartTime(entry.jumpStartTime || 0) + setJumpEndTime(entry.jumpEndTime || 0) + setNoMessageTable(false) + setHasInitialMessages(true) + return true + }, [ + setMessages, + setHasMoreMessages, + setHasMoreLater, + setCurrentOffset, + setJumpStartTime, + setJumpEndTime, + setNoMessageTable, + setHasInitialMessages + ]) + + const hydrateSessionListCache = useCallback((scope: string): boolean => { + try { + const cacheKey = buildChatSessionListCacheKey(scope) + const payload = safeParseJson(window.localStorage.getItem(cacheKey)) + if (!payload || typeof payload.updatedAt !== 'number' || !Array.isArray(payload.sessions)) { + previewCacheRef.current = loadPreviewCacheFromStorage(scope) + return false + } + previewCacheRef.current = loadPreviewCacheFromStorage(scope) + if (Date.now() - payload.updatedAt > CHAT_SESSION_LIST_CACHE_TTL_MS) { + return false + } + if (!Array.isArray(sessionsRef.current) || sessionsRef.current.length === 0) { + setSessions(payload.sessions) + sessionsRef.current = payload.sessions + return payload.sessions.length > 0 + } + return false + } catch { + previewCacheRef.current = loadPreviewCacheFromStorage(scope) + return false + } + }, [loadPreviewCacheFromStorage, setSessions]) + + const persistSessionListCache = useCallback((scope: string, nextSessions: ChatSession[]) => { + try { + const cacheKey = buildChatSessionListCacheKey(scope) + const payload: SessionListCachePayload = { + updatedAt: Date.now(), + sessions: nextSessions + } + window.localStorage.setItem(cacheKey, JSON.stringify(payload)) + } catch { + // ignore cache write failures + } + }, []) + + const applySessionDetailStats = useCallback(( + sessionId: string, + metric: SessionExportMetric, + cacheMeta?: SessionExportCacheMeta, + relationLoadedOverride?: boolean + ) => { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== sessionId) return prev + const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded) + return { + ...prev, + messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : prev.messageCount, + voiceMessages: Number.isFinite(metric.voiceMessages) ? metric.voiceMessages : prev.voiceMessages, + imageMessages: Number.isFinite(metric.imageMessages) ? metric.imageMessages : prev.imageMessages, + videoMessages: Number.isFinite(metric.videoMessages) ? metric.videoMessages : prev.videoMessages, + emojiMessages: Number.isFinite(metric.emojiMessages) ? metric.emojiMessages : prev.emojiMessages, + transferMessages: Number.isFinite(metric.transferMessages) ? metric.transferMessages : prev.transferMessages, + redPacketMessages: Number.isFinite(metric.redPacketMessages) ? metric.redPacketMessages : prev.redPacketMessages, + callMessages: Number.isFinite(metric.callMessages) ? metric.callMessages : prev.callMessages, + groupMemberCount: Number.isFinite(metric.groupMemberCount) ? metric.groupMemberCount : prev.groupMemberCount, + groupMyMessages: Number.isFinite(metric.groupMyMessages) ? metric.groupMyMessages : prev.groupMyMessages, + groupActiveSpeakers: Number.isFinite(metric.groupActiveSpeakers) ? metric.groupActiveSpeakers : prev.groupActiveSpeakers, + privateMutualGroups: relationLoaded && Number.isFinite(metric.privateMutualGroups) + ? metric.privateMutualGroups + : prev.privateMutualGroups, + groupMutualFriends: relationLoaded && Number.isFinite(metric.groupMutualFriends) + ? metric.groupMutualFriends + : prev.groupMutualFriends, + relationStatsLoaded: relationLoaded, + statsUpdatedAt: cacheMeta?.updatedAt ?? prev.statsUpdatedAt, + statsStale: typeof cacheMeta?.stale === 'boolean' ? cacheMeta.stale : prev.statsStale, + firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : prev.firstMessageTime, + latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : prev.latestMessageTime + } + }) + }, []) + // 加载会话详情 const loadSessionDetail = useCallback(async (sessionId: string) => { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + const taskId = registerBackgroundTask({ + sourcePage: 'chat', + title: '聊天页会话详情统计', + detail: `准备读取 ${sessionMapRef.current.get(normalizedSessionId)?.displayName || normalizedSessionId} 的详情`, + progressText: '基础信息', + cancelable: true + }) + + const requestSeq = ++detailRequestSeqRef.current + const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId) + const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0 + ? Math.floor(mappedSession.messageCountHint) + : undefined + + setIsRefreshingDetailStats(false) + setIsLoadingRelationStats(false) + setSessionDetail((prev) => { + const sameSession = prev?.wxid === normalizedSessionId + return { + wxid: normalizedSessionId, + displayName: mappedSession?.displayName || prev?.displayName || normalizedSessionId, + remark: sameSession ? prev?.remark : undefined, + nickName: sameSession ? prev?.nickName : undefined, + alias: sameSession ? prev?.alias : undefined, + avatarUrl: mappedSession?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined), + messageCount: hintedCount ?? (sameSession ? prev.messageCount : Number.NaN), + voiceMessages: sameSession ? prev?.voiceMessages : undefined, + imageMessages: sameSession ? prev?.imageMessages : undefined, + videoMessages: sameSession ? prev?.videoMessages : undefined, + emojiMessages: sameSession ? prev?.emojiMessages : undefined, + transferMessages: sameSession ? prev?.transferMessages : undefined, + redPacketMessages: sameSession ? prev?.redPacketMessages : undefined, + callMessages: sameSession ? prev?.callMessages : undefined, + privateMutualGroups: sameSession ? prev?.privateMutualGroups : undefined, + groupMemberCount: sameSession ? prev?.groupMemberCount : undefined, + groupMyMessages: sameSession ? prev?.groupMyMessages : undefined, + groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined, + groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined, + relationStatsLoaded: sameSession ? prev?.relationStatsLoaded : false, + statsUpdatedAt: sameSession ? prev?.statsUpdatedAt : undefined, + statsStale: sameSession ? prev?.statsStale : undefined, + firstMessageTime: sameSession ? prev?.firstMessageTime : undefined, + latestMessageTime: sameSession ? prev?.latestMessageTime : undefined, + messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : [] + } + }) setIsLoadingDetail(true) + setIsLoadingDetailExtra(true) + + if (normalizedSessionId.includes('@chatroom')) { + void (async () => { + try { + const hintResult = await window.electronAPI.chat.getGroupMyMessageCountHint(normalizedSessionId) + if (requestSeq !== detailRequestSeqRef.current) return + if (!hintResult.success || !Number.isFinite(hintResult.count)) return + const hintedMyCount = Math.max(0, Math.floor(hintResult.count as number)) + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + groupMyMessages: hintedMyCount + } + }) + } catch { + // ignore hint errors + } + })() + } + try { - const result = await window.electronAPI.chat.getSessionDetail(sessionId) + updateBackgroundTask(taskId, { + detail: '正在读取会话基础详情', + progressText: '基础信息' + }) + const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前基础查询结束后未继续补充统计' + }) + return + } + if (requestSeq !== detailRequestSeqRef.current) { + finishBackgroundTask(taskId, 'canceled', { + detail: '会话已切换,旧详情任务已停止' + }) + return + } if (result.success && result.detail) { - setSessionDetail(result.detail) + setSessionDetail((prev) => ({ + wxid: normalizedSessionId, + displayName: result.detail!.displayName || prev?.displayName || normalizedSessionId, + remark: result.detail!.remark, + nickName: result.detail!.nickName, + alias: result.detail!.alias, + avatarUrl: result.detail!.avatarUrl || prev?.avatarUrl, + messageCount: Number.isFinite(result.detail!.messageCount) ? result.detail!.messageCount : prev?.messageCount ?? Number.NaN, + voiceMessages: prev?.voiceMessages, + imageMessages: prev?.imageMessages, + videoMessages: prev?.videoMessages, + emojiMessages: prev?.emojiMessages, + transferMessages: prev?.transferMessages, + redPacketMessages: prev?.redPacketMessages, + callMessages: prev?.callMessages, + privateMutualGroups: prev?.privateMutualGroups, + groupMemberCount: prev?.groupMemberCount, + groupMyMessages: prev?.groupMyMessages, + groupActiveSpeakers: prev?.groupActiveSpeakers, + groupMutualFriends: prev?.groupMutualFriends, + relationStatsLoaded: prev?.relationStatsLoaded, + statsUpdatedAt: prev?.statsUpdatedAt, + statsStale: prev?.statsStale, + firstMessageTime: prev?.firstMessageTime, + latestMessageTime: prev?.latestMessageTime, + messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : [] + })) } } catch (e) { console.error('加载会话详情失败:', e) } finally { - setIsLoadingDetail(false) + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingDetail(false) + } + } + + try { + updateBackgroundTask(taskId, { + detail: '正在读取补充信息与导出统计', + progressText: '补充统计' + }) + const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ + window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), + window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, allowStaleCache: true, cacheOnly: true } + ) + ]) + + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,补充统计结果未继续写入' + }) + return + } + if (requestSeq !== detailRequestSeqRef.current) { + finishBackgroundTask(taskId, 'canceled', { + detail: '会话已切换,旧补充统计任务已停止' + }) + return + } + + if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) { + const detail = extraResultSettled.value.detail + if (detail) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + firstMessageTime: detail.firstMessageTime, + latestMessageTime: detail.latestMessageTime, + messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : [] + } + }) + } + } + + let refreshIncludeRelations = false + let shouldRefreshStatsInBackground = false + if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) { + const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined + const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + refreshIncludeRelations = Boolean(cacheMeta?.includeRelations) + if (metric) { + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, refreshIncludeRelations) + } else if (cacheMeta) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + relationStatsLoaded: refreshIncludeRelations || prev.relationStatsLoaded, + statsUpdatedAt: cacheMeta.updatedAt, + statsStale: cacheMeta.stale + } + }) + } + shouldRefreshStatsInBackground = !metric || Boolean(cacheMeta?.stale) + } else { + shouldRefreshStatsInBackground = true + } + finishBackgroundTask(taskId, 'completed', { + detail: '聊天页会话详情统计完成', + progressText: '已完成' + }) + + if (shouldRefreshStatsInBackground) { + setIsRefreshingDetailStats(true) + void (async () => { + try { + const freshResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, forceRefresh: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (freshResult.success && freshResult.data) { + const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined + const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (freshMetric) { + applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, false) + } else if (freshMeta) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + statsUpdatedAt: freshMeta.updatedAt, + statsStale: freshMeta.stale + } + }) + } + } + } catch (error) { + console.error('聊天页后台刷新会话统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsRefreshingDetailStats(false) + } + } + })() + } + } catch (e) { + console.error('加载会话详情补充统计失败:', e) + finishBackgroundTask(taskId, 'failed', { + detail: String(e) + }) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingDetailExtra(false) + } + } + }, [applySessionDetailStats]) + + const loadRelationStats = useCallback(async () => { + const normalizedSessionId = String(currentSessionId || '').trim() + if (!normalizedSessionId || isLoadingRelationStats) return + + const requestSeq = detailRequestSeqRef.current + const taskId = registerBackgroundTask({ + sourcePage: 'chat', + title: '聊天页关系统计补算', + detail: `正在补算 ${normalizedSessionId} 的共同好友与关联数据`, + progressText: '关系统计', + cancelable: true + }) + setIsLoadingRelationStats(true) + try { + const relationResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } + ) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前关系统计查询结束后未继续刷新' + }) + return + } + if (requestSeq !== detailRequestSeqRef.current) { + finishBackgroundTask(taskId, 'canceled', { + detail: '会话已切换,旧关系统计任务已停止' + }) + return + } + + const metric = relationResult.success && relationResult.data + ? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined + : undefined + const cacheMeta = relationResult.success + ? relationResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + : undefined + if (metric) { + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, true) + } + + const needRefresh = relationResult.success && + Array.isArray(relationResult.needsRefresh) && + relationResult.needsRefresh.includes(normalizedSessionId) + + if (needRefresh) { + setIsRefreshingDetailStats(true) + void (async () => { + try { + updateBackgroundTask(taskId, { + detail: '正在刷新关系统计结果', + progressText: '关系统计刷新' + }) + const freshResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } + ) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,刷新结果未继续写入' + }) + return + } + if (requestSeq !== detailRequestSeqRef.current) { + finishBackgroundTask(taskId, 'canceled', { + detail: '会话已切换,旧关系统计刷新任务已停止' + }) + return + } + if (freshResult.success && freshResult.data) { + const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined + const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (freshMetric) { + applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true) + } + } + finishBackgroundTask(taskId, 'completed', { + detail: '聊天页关系统计补算完成', + progressText: '已完成' + }) + } catch (error) { + console.error('刷新会话关系统计失败:', error) + finishBackgroundTask(taskId, 'failed', { + detail: String(error) + }) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsRefreshingDetailStats(false) + } + } + })() + } else { + finishBackgroundTask(taskId, 'completed', { + detail: '聊天页关系统计补算完成', + progressText: '已完成' + }) + } + } catch (error) { + console.error('加载会话关系统计失败:', error) + finishBackgroundTask(taskId, 'failed', { + detail: String(error) + }) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingRelationStats(false) + } + } + }, [applySessionDetailStats, currentSessionId, isLoadingRelationStats]) + + const normalizeGroupPanelMembers = useCallback(( + payload: GroupPanelMember[], + options?: { messageCountStatus?: GroupMessageCountStatus } + ): GroupPanelMember[] => { + const membersPayload = Array.isArray(payload) ? payload : [] + return membersPayload + .map((member: GroupPanelMember): GroupPanelMember | null => { + const username = String(member.username || '').trim() + if (!username) return null + const preferredName = String( + member.groupNickname || + member.remark || + member.displayName || + member.nickname || + username + ) + const rawStatus = member.messageCountStatus + const normalizedStatus: GroupMessageCountStatus = options?.messageCountStatus + ?? (rawStatus === 'loading' || rawStatus === 'failed' ? rawStatus : 'ready') + + return { + username, + displayName: preferredName, + avatarUrl: member.avatarUrl, + nickname: member.nickname, + alias: member.alias, + remark: member.remark, + groupNickname: member.groupNickname, + isOwner: Boolean(member.isOwner), + isFriend: Boolean(member.isFriend), + messageCount: Number.isFinite(member.messageCount) ? Math.max(0, Math.floor(member.messageCount)) : 0, + messageCountStatus: normalizedStatus + } + }) + .filter((member: GroupPanelMember | null): member is GroupPanelMember => Boolean(member)) + .sort((a: GroupPanelMember, b: GroupPanelMember) => { + const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner)) + if (ownerDiff !== 0) return ownerDiff + + const friendDiff = Number(b.isFriend) - Number(a.isFriend) + if (friendDiff !== 0) return friendDiff + + const canSortByCount = a.messageCountStatus === 'ready' && b.messageCountStatus === 'ready' + if (canSortByCount && a.messageCount !== b.messageCount) return b.messageCount - a.messageCount + return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN') + }) + }, []) + + const normalizeWxidLikeIdentity = useCallback((value?: string): string => { + const trimmed = String(value || '').trim() + if (!trimmed) return '' + const lowered = trimmed.toLowerCase() + if (lowered.startsWith('wxid_')) { + const matched = lowered.match(/^(wxid_[^_]+)/i) + return matched ? matched[1].toLowerCase() : lowered + } + const suffixMatch = lowered.match(/^(.+)_([a-z0-9]{4})$/i) + return suffixMatch ? suffixMatch[1].toLowerCase() : lowered + }, []) + + const isSelfGroupMember = useCallback((memberUsername?: string): boolean => { + const selfRaw = String(myWxid || '').trim().toLowerCase() + const selfNormalized = normalizeWxidLikeIdentity(myWxid) + if (!selfRaw && !selfNormalized) return false + const memberRaw = String(memberUsername || '').trim().toLowerCase() + const memberNormalized = normalizeWxidLikeIdentity(memberUsername) + return Boolean( + (selfRaw && memberRaw && selfRaw === memberRaw) || + (selfNormalized && memberNormalized && selfNormalized === memberNormalized) + ) + }, [myWxid, normalizeWxidLikeIdentity]) + + const resolveMyGroupMessageCountFromMembers = useCallback((members: GroupPanelMember[]): number | undefined => { + if (!myWxid) return undefined + + for (const member of members) { + if (!isSelfGroupMember(member.username)) continue + if (Number.isFinite(member.messageCount)) { + return Math.max(0, Math.floor(member.messageCount)) + } + return 0 + } + + return undefined + }, [isSelfGroupMember, myWxid]) + + const syncGroupMyMessagesFromMembers = useCallback((chatroomId: string, members: GroupPanelMember[]) => { + const myMessageCount = resolveMyGroupMessageCountFromMembers(members) + if (!Number.isFinite(myMessageCount)) return + + setSessionDetail((prev) => { + if (!prev || prev.wxid !== chatroomId || !prev.wxid.includes('@chatroom')) return prev + return { + ...prev, + groupMyMessages: myMessageCount as number + } + }) + }, [resolveMyGroupMessageCountFromMembers]) + + const updateGroupMembersPanelCache = useCallback(( + chatroomId: string, + members: GroupPanelMember[], + includeMessageCounts: boolean + ) => { + groupMembersPanelCacheRef.current.set(chatroomId, { + updatedAt: Date.now(), + members, + includeMessageCounts + }) + if (groupMembersPanelCacheRef.current.size > 80) { + const oldestEntry = Array.from(groupMembersPanelCacheRef.current.entries()) + .sort((a, b) => a[1].updatedAt - b[1].updatedAt)[0] + if (oldestEntry) { + groupMembersPanelCacheRef.current.delete(oldestEntry[0]) + } } }, []) + const setGroupMembersCountStatus = useCallback(( + status: GroupMessageCountStatus, + options?: { onlyWhenNotReady?: boolean } + ) => { + setGroupPanelMembers((prev) => { + if (!Array.isArray(prev) || prev.length === 0) return prev + if (options?.onlyWhenNotReady && prev.some((member) => member.messageCountStatus === 'ready')) { + return prev + } + const next = normalizeGroupPanelMembers(prev, { messageCountStatus: status }) + const changed = next.some((member, index) => member.messageCountStatus !== prev[index]?.messageCountStatus) + return changed ? next : prev + }) + }, [normalizeGroupPanelMembers]) + + const syncGroupMembersMyCountFromDetail = useCallback((chatroomId: string, myMessageCount: number) => { + if (!chatroomId || !chatroomId.includes('@chatroom')) return + const normalizedCount = Number.isFinite(myMessageCount) ? Math.max(0, Math.floor(myMessageCount)) : 0 + + const patchMembers = (members: GroupPanelMember[]): { changed: boolean; members: GroupPanelMember[] } => { + if (!Array.isArray(members) || members.length === 0) { + return { changed: false, members } + } + let changed = false + const patched = members.map((member) => { + if (!isSelfGroupMember(member.username)) return member + if (member.messageCount === normalizedCount) return member + changed = true + return { + ...member, + messageCount: normalizedCount + } + }) + if (!changed) return { changed: false, members } + return { changed: true, members: normalizeGroupPanelMembers(patched) } + } + + const cached = groupMembersPanelCacheRef.current.get(chatroomId) + if (cached && cached.members.length > 0) { + const patchedCache = patchMembers(cached.members) + if (patchedCache.changed) { + updateGroupMembersPanelCache(chatroomId, patchedCache.members, true) + } + } + + setGroupPanelMembers((prev) => { + const patched = patchMembers(prev) + if (!patched.changed) return prev + return patched.members + }) + }, [ + isSelfGroupMember, + normalizeGroupPanelMembers, + updateGroupMembersPanelCache + ]) + + const getGroupMembersPanelDataWithTimeout = useCallback(async ( + chatroomId: string, + options: { forceRefresh?: boolean; includeMessageCounts?: boolean }, + timeoutMs: number + ) => { + let timeoutTimer: number | null = null + try { + const timeoutPromise = new Promise<{ success: false; error: string }>((resolve) => { + timeoutTimer = window.setTimeout(() => { + resolve({ success: false, error: '加载群成员超时,请稍后重试' }) + }, timeoutMs) + }) + return await Promise.race([ + window.electronAPI.groupAnalytics.getGroupMembersPanelData(chatroomId, options), + timeoutPromise + ]) + } finally { + if (timeoutTimer) { + window.clearTimeout(timeoutTimer) + } + } + }, []) + + const loadGroupMembersPanel = useCallback(async (chatroomId: string) => { + if (!chatroomId || !isGroupChatSession(chatroomId)) return + + const requestSeq = ++groupMembersRequestSeqRef.current + const now = Date.now() + const cached = groupMembersPanelCacheRef.current.get(chatroomId) + const cacheFresh = Boolean(cached && now - cached.updatedAt < GROUP_MEMBERS_PANEL_CACHE_TTL_MS) + const hasCachedMembers = Boolean(cached && cached.members.length > 0) + const hasFreshMessageCounts = Boolean(cacheFresh && cached?.includeMessageCounts) + let startedBackgroundRefresh = false + + const refreshMessageCountsInBackground = (forceRefresh: boolean) => { + startedBackgroundRefresh = true + setIsRefreshingGroupMembers(true) + setGroupMembersCountStatus('loading', { onlyWhenNotReady: true }) + void (async () => { + try { + const countsResult = await getGroupMembersPanelDataWithTimeout( + chatroomId, + { forceRefresh, includeMessageCounts: true }, + 25000 + ) + if (requestSeq !== groupMembersRequestSeqRef.current) return + if (!countsResult.success || !Array.isArray(countsResult.data)) { + setGroupMembersError('成员列表已加载,发言统计稍后再试') + setGroupMembersCountStatus('failed', { onlyWhenNotReady: true }) + return + } + + const membersWithCounts = normalizeGroupPanelMembers( + countsResult.data as GroupPanelMember[], + { messageCountStatus: 'ready' } + ) + setGroupPanelMembers(membersWithCounts) + syncGroupMyMessagesFromMembers(chatroomId, membersWithCounts) + setGroupMembersError(null) + updateGroupMembersPanelCache(chatroomId, membersWithCounts, true) + hasInitializedGroupMembersRef.current = true + } catch { + if (requestSeq !== groupMembersRequestSeqRef.current) return + setGroupMembersError('成员列表已加载,发言统计稍后再试') + setGroupMembersCountStatus('failed', { onlyWhenNotReady: true }) + } finally { + if (requestSeq === groupMembersRequestSeqRef.current) { + setIsRefreshingGroupMembers(false) + } + } + })() + } + + if (cacheFresh && cached) { + const cachedMembers = normalizeGroupPanelMembers( + cached.members, + { messageCountStatus: cached.includeMessageCounts ? 'ready' : 'loading' } + ) + setGroupPanelMembers(cachedMembers) + if (cached.includeMessageCounts) { + syncGroupMyMessagesFromMembers(chatroomId, cachedMembers) + } + setGroupMembersError(null) + setGroupMembersLoadingHint('') + setIsLoadingGroupMembers(false) + hasInitializedGroupMembersRef.current = true + if (!hasFreshMessageCounts) { + refreshMessageCountsInBackground(false) + } else { + setIsRefreshingGroupMembers(false) + } + return + } + + setGroupMembersError(null) + if (hasCachedMembers && cached) { + const cachedMembers = normalizeGroupPanelMembers( + cached.members, + { messageCountStatus: cached.includeMessageCounts ? 'ready' : 'loading' } + ) + setGroupPanelMembers(cachedMembers) + if (cached.includeMessageCounts) { + syncGroupMyMessagesFromMembers(chatroomId, cachedMembers) + } + setIsRefreshingGroupMembers(true) + setGroupMembersLoadingHint('') + setIsLoadingGroupMembers(false) + } else { + setGroupPanelMembers([]) + setIsRefreshingGroupMembers(false) + setIsLoadingGroupMembers(true) + setGroupMembersLoadingHint( + hasInitializedGroupMembersRef.current + ? '加载群成员中...' + : '首次加载群成员,正在初始化索引(可能需要几秒)' + ) + } + + try { + const membersResult = await getGroupMembersPanelDataWithTimeout( + chatroomId, + { includeMessageCounts: false, forceRefresh: false }, + 12000 + ) + if (requestSeq !== groupMembersRequestSeqRef.current) return + + if (!membersResult.success || !Array.isArray(membersResult.data)) { + if (!hasCachedMembers) { + setGroupPanelMembers([]) + } + setGroupMembersError(membersResult.error || (hasCachedMembers ? '刷新群成员失败,已显示缓存数据' : '加载群成员失败')) + return + } + + const members = normalizeGroupPanelMembers( + membersResult.data as GroupPanelMember[], + { messageCountStatus: 'loading' } + ) + setGroupPanelMembers(members) + setGroupMembersError(null) + updateGroupMembersPanelCache(chatroomId, members, false) + hasInitializedGroupMembersRef.current = true + refreshMessageCountsInBackground(false) + } catch (e) { + if (requestSeq !== groupMembersRequestSeqRef.current) return + if (!hasCachedMembers) { + setGroupPanelMembers([]) + } + setGroupMembersError(hasCachedMembers ? '刷新群成员失败,已显示缓存数据' : String(e)) + } finally { + if (requestSeq === groupMembersRequestSeqRef.current) { + setIsLoadingGroupMembers(false) + setGroupMembersLoadingHint('') + if (!startedBackgroundRefresh) { + setIsRefreshingGroupMembers(false) + } + } + } + }, [ + getGroupMembersPanelDataWithTimeout, + isGroupChatSession, + syncGroupMyMessagesFromMembers, + normalizeGroupPanelMembers, + updateGroupMembersPanelCache + ]) + + const toggleGroupMembersPanel = useCallback(() => { + if (!currentSessionId || !isGroupChatSession(currentSessionId)) return + if (showGroupMembersPanel) { + setShowGroupMembersPanel(false) + return + } + setShowDetailPanel(false) + setShowGroupMembersPanel(true) + }, [currentSessionId, showGroupMembersPanel, isGroupChatSession]) + // 切换详情面板 const toggleDetailPanel = useCallback(() => { - if (!showDetailPanel && currentSessionId) { - loadSessionDetail(currentSessionId) + if (showDetailPanel) { + setShowDetailPanel(false) + return + } + setShowGroupMembersPanel(false) + setShowDetailPanel(true) + if (currentSessionId) { + void loadSessionDetail(currentSessionId) } - setShowDetailPanel(!showDetailPanel) }, [showDetailPanel, currentSessionId, loadSessionDetail]) + useEffect(() => { + if (!showGroupMembersPanel) return + if (!currentSessionId || !isGroupChatSession(currentSessionId)) { + setShowGroupMembersPanel(false) + return + } + setGroupMemberSearchKeyword('') + void loadGroupMembersPanel(currentSessionId) + }, [showGroupMembersPanel, currentSessionId, loadGroupMembersPanel, isGroupChatSession]) + + useEffect(() => { + const chatroomId = String(sessionDetail?.wxid || '').trim() + if (!chatroomId || !chatroomId.includes('@chatroom')) return + if (!Number.isFinite(sessionDetail?.groupMyMessages)) return + syncGroupMembersMyCountFromDetail(chatroomId, sessionDetail!.groupMyMessages as number) + }, [sessionDetail?.groupMyMessages, sessionDetail?.wxid, syncGroupMembersMyCountFromDetail]) + // 复制字段值到剪贴板 const handleCopyField = useCallback(async (text: string, field: string) => { try { @@ -443,13 +2631,14 @@ function ChatPage(_props: ChatPageProps) { setConnecting(true) setConnectionError(null) try { + const scopePromise = resolveChatCacheScope() const result = await window.electronAPI.chat.connect() if (result.success) { setConnected(true) - await loadSessions() - await loadMyAvatar() + const wxidPromise = window.electronAPI.config.get('myWxid') + await Promise.all([scopePromise, loadSessions(), loadMyAvatar()]) // 获取 myWxid 用于匹配个人头像 - const wxid = await window.electronAPI.config.get('myWxid') + const wxid = await wxidPromise if (wxid) setMyWxid(wxid as string) } else { setConnectionError(result.error || '连接失败') @@ -459,44 +2648,151 @@ function ChatPage(_props: ChatPageProps) { } finally { setConnecting(false) } - }, [loadMyAvatar]) + }, [loadMyAvatar, resolveChatCacheScope]) const handleAccountChanged = useCallback(async () => { senderAvatarCache.clear() senderAvatarLoading.clear() + quotedSenderDisplayCache.clear() + quotedSenderDisplayLoading.clear() + quotedGroupMembersCache.clear() + quotedGroupMembersLoading.clear() + sessionContactProfileCacheRef.current.clear() + pendingSessionContactEnrichRef.current.clear() + sessionContactEnrichAttemptAtRef.current.clear() preloadImageKeysRef.current.clear() lastPreloadSessionRef.current = null + pendingSessionLoadRef.current = null + initialLoadRequestedSessionRef.current = null + sessionSwitchRequestSeqRef.current += 1 + sessionWindowCacheRef.current.clear() + setIsSessionSwitching(false) setSessionDetail(null) + setIsRefreshingDetailStats(false) + setIsLoadingRelationStats(false) + setShowDetailPanel(false) + setShowGroupMembersPanel(false) + setGroupPanelMembers([]) + setGroupMembersError(null) + setGroupMembersLoadingHint('') + setIsRefreshingGroupMembers(false) + setGroupMemberSearchKeyword('') + groupMembersRequestSeqRef.current += 1 + groupMembersPanelCacheRef.current.clear() + hasInitializedGroupMembersRef.current = false + setIsLoadingGroupMembers(false) setCurrentSession(null) setSessions([]) - setFilteredSessions([]) setMessages([]) + setShowScrollToBottom(false) + suppressScrollToBottomButton(260) setSearchKeyword('') setConnectionError(null) setConnected(false) setConnecting(false) setHasMoreMessages(true) setHasMoreLater(false) + const scope = await resolveChatCacheScope() + hydrateSessionListCache(scope) await connect() }, [ connect, + resolveChatCacheScope, + hydrateSessionListCache, setConnected, setConnecting, setConnectionError, setCurrentSession, - setFilteredSessions, setHasMoreLater, setHasMoreMessages, setMessages, setSearchKeyword, setSessionDetail, + setShowDetailPanel, + setShowGroupMembersPanel, + suppressScrollToBottomButton, setSessions ]) + useEffect(() => { + let canceled = false + void configService.getAutoTranscribeVoice() + .then((enabled) => { + if (!canceled) { + setAutoTranscribeVoiceEnabled(Boolean(enabled)) + } + }) + .catch(() => { + if (!canceled) { + setAutoTranscribeVoiceEnabled(false) + } + }) + return () => { + canceled = true + } + }, []) + + useEffect(() => { + let cancelled = false + void (async () => { + const scope = await resolveChatCacheScope() + if (cancelled) return + hydrateSessionListCache(scope) + })() + + return () => { + cancelled = true + } + }, [resolveChatCacheScope, hydrateSessionListCache]) + // 同步 currentSessionId 到 ref useEffect(() => { currentSessionRef.current = currentSessionId - }, [currentSessionId]) + isMessageListAtBottomRef.current = true + topRangeLoadLockRef.current = false + bottomRangeLoadLockRef.current = false + setShowScrollToBottom(false) + suppressScrollToBottomButton(260) + }, [currentSessionId, suppressScrollToBottomButton]) + + const hydrateSessionStatuses = useCallback(async (sessionList: ChatSession[]) => { + const usernames = sessionList.map((s) => s.username).filter(Boolean) + if (usernames.length === 0) return + + try { + const result = await window.electronAPI.chat.getSessionStatuses(usernames) + if (!result.success || !result.map) return + + const statusMap = result.map + const { sessions: latestSessions } = useChatStore.getState() + if (!Array.isArray(latestSessions) || latestSessions.length === 0) return + + let hasChanges = false + const updatedSessions = latestSessions.map((session) => { + const status = statusMap[session.username] + if (!status) return session + + const nextIsFolded = status.isFolded ?? session.isFolded + const nextIsMuted = status.isMuted ?? session.isMuted + if (nextIsFolded === session.isFolded && nextIsMuted === session.isMuted) { + return session + } + + hasChanges = true + return { + ...session, + isFolded: nextIsFolded, + isMuted: nextIsMuted + } + }) + + if (hasChanges) { + setSessions(updatedSessions) + } + } catch (e) { + console.warn('会话状态补齐失败:', e) + } + }, [setSessions]) // 加载会话列表(优化:先返回基础数据,异步加载联系人信息) const loadSessions = async (options?: { silent?: boolean }) => { @@ -506,23 +2802,28 @@ function ChatPage(_props: ChatPageProps) { setLoadingSessions(true) } try { + const scope = await resolveChatCacheScope() const result = await window.electronAPI.chat.getSessions() if (result.success && result.sessions) { // 确保 sessions 是数组 const sessionsArray = Array.isArray(result.sessions) ? result.sessions : [] - const nextSessions = options?.silent ? mergeSessions(sessionsArray) : sessionsArray + const nextSessions = mergeSessions(sessionsArray) // 确保 nextSessions 也是数组 if (Array.isArray(nextSessions)) { - - setSessions(nextSessions) sessionsRef.current = nextSessions + persistSessionListCache(scope, nextSessions) + void hydrateSessionStatuses(nextSessions) // 立即启动联系人信息加载,不再延迟 500ms void enrichSessionsContactInfo(nextSessions) } else { console.error('mergeSessions returned non-array:', nextSessions) - setSessions(sessionsArray) - void enrichSessionsContactInfo(sessionsArray) + const fallbackSessions = sessionsArray.map((session) => mergeSessionContactPresentation(session)) + setSessions(fallbackSessions) + sessionsRef.current = fallbackSessions + persistSessionListCache(scope, fallbackSessions) + void hydrateSessionStatuses(fallbackSessions) + void enrichSessionsContactInfo(fallbackSessions) } } else if (!result.success) { setConnectionError(result.error || '获取会话失败') @@ -539,105 +2840,107 @@ function ChatPage(_props: ChatPageProps) { } } - // 分批异步加载联系人信息(优化性能:防止重复加载,滚动时暂停,只在空闲时加载) + // 分批异步加载联系人信息(优化:缓存优先 + 可持续队列 + 首屏优先批次) const enrichSessionsContactInfo = async (sessions: ChatSession[]) => { - if (sessions.length === 0) return + if (Array.isArray(sessions) && sessions.length > 0) { + const now = Date.now() + for (const session of sessions) { + const username = String(session.username || '').trim() + if (!username || isFoldPlaceholderSession(username)) continue - // 防止重复加载 - if (isEnrichingRef.current) { + const profileCache = sessionContactProfileCacheRef.current + const cachedProfile = profileCache.get(username) + if (cachedProfile && now - cachedProfile.updatedAt > SESSION_CONTACT_PROFILE_CACHE_TTL_MS) { + profileCache.delete(username) + } - return + const hasAvatar = Boolean(normalizeSearchAvatarUrl(session.avatarUrl)) + const hasDisplayName = Boolean(resolveSessionDisplayName(session.displayName, username)) + if (hasAvatar && hasDisplayName) continue + + const profile = profileCache.get(username) + const profileHasAvatar = Boolean(normalizeSearchAvatarUrl(profile?.avatarUrl)) + const profileHasDisplayName = Boolean(resolveSessionDisplayName(profile?.displayName, username)) + if (profileHasAvatar && profileHasDisplayName) continue + + const lastAttemptAt = sessionContactEnrichAttemptAtRef.current.get(username) || 0 + if (now - lastAttemptAt < SESSION_CONTACT_PROFILE_RETRY_INTERVAL_MS) continue + + pendingSessionContactEnrichRef.current.add(username) + } } + if (pendingSessionContactEnrichRef.current.size === 0) return + if (isEnrichingRef.current) return + isEnrichingRef.current = true enrichCancelledRef.current = false - - const totalStart = performance.now() - - // 移除初始 500ms 延迟,让后台加载与 UI 渲染并行 - - // 检查是否被取消 - if (enrichCancelledRef.current) { - isEnrichingRef.current = false - return - } + const batchSize = 8 + let processedBatchCount = 0 try { - // 找出需要加载联系人信息的会话(没有头像或者没有显示名称的) - const needEnrich = sessions.filter(s => !s.avatarUrl || !s.displayName || s.displayName === s.username) - if (needEnrich.length === 0) { - - isEnrichingRef.current = false - return - } - - - - // 进一步减少批次大小,每批3个,避免DLL调用阻塞 - const batchSize = 3 - let loadedCount = 0 - - for (let i = 0; i < needEnrich.length; i += batchSize) { - // 如果正在滚动,暂停加载 + while (!enrichCancelledRef.current && pendingSessionContactEnrichRef.current.size > 0) { if (isScrollingRef.current) { - - // 等待滚动结束 while (isScrollingRef.current && !enrichCancelledRef.current) { - await new Promise(resolve => setTimeout(resolve, 200)) + await new Promise(resolve => setTimeout(resolve, 120)) } - if (enrichCancelledRef.current) break } - - // 检查是否被取消 if (enrichCancelledRef.current) break + const usernames = Array.from(pendingSessionContactEnrichRef.current).slice(0, batchSize) + if (usernames.length === 0) break + usernames.forEach((username) => pendingSessionContactEnrichRef.current.delete(username)) + + const attemptAt = Date.now() + usernames.forEach((username) => sessionContactEnrichAttemptAtRef.current.set(username, attemptAt)) + const batchStart = performance.now() - const batch = needEnrich.slice(i, i + batchSize) - const usernames = batch.map(s => s.username) + const shouldRunImmediately = processedBatchCount < 2 + if (shouldRunImmediately) { + await loadContactInfoBatch(usernames) + } else { + await new Promise((resolve) => { + if ('requestIdleCallback' in window) { + window.requestIdleCallback(() => { + void loadContactInfoBatch(usernames).finally(resolve) + }, { timeout: 700 }) + } else { + setTimeout(() => { + void loadContactInfoBatch(usernames).finally(resolve) + }, 80) + } + }) + } + processedBatchCount += 1 - // 使用 requestIdleCallback 延迟执行,避免阻塞UI - await new Promise((resolve) => { - if ('requestIdleCallback' in window) { - window.requestIdleCallback(() => { - void loadContactInfoBatch(usernames).then(() => resolve()) - }, { timeout: 2000 }) - } else { - setTimeout(() => { - void loadContactInfoBatch(usernames).then(() => resolve()) - }, 300) - } - }) - - loadedCount += batch.length const batchTime = performance.now() - batchStart if (batchTime > 200) { - console.warn(`[性能监控] 批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(needEnrich.length / batchSize)} 耗时: ${batchTime.toFixed(2)}ms (已加载: ${loadedCount}/${needEnrich.length})`) + console.warn(`[性能监控] 联系人批次 ${processedBatchCount} 耗时: ${batchTime.toFixed(2)}ms, batch=${usernames.length}`) } - // 批次间延迟,给UI更多时间(DLL调用可能阻塞,需要更长的延迟) - if (i + batchSize < needEnrich.length && !enrichCancelledRef.current) { - // 如果不在滚动,可以延迟短一点 - const delay = isScrollingRef.current ? 1000 : 800 + if (!enrichCancelledRef.current && pendingSessionContactEnrichRef.current.size > 0) { + const delay = isScrollingRef.current ? 220 : 90 await new Promise(resolve => setTimeout(resolve, delay)) } } const totalTime = performance.now() - totalStart - if (!enrichCancelledRef.current) { - - } else { - + if (totalTime > 500) { + console.info(`[性能监控] 联系人补齐总耗时: ${totalTime.toFixed(2)}ms`) } } catch (e) { console.error('加载联系人信息失败:', e) } finally { isEnrichingRef.current = false + if (!enrichCancelledRef.current && pendingSessionContactEnrichRef.current.size > 0) { + void enrichSessionsContactInfo([]) + } } } // 联系人信息更新队列(防抖批量更新,避免频繁重渲染) - const contactUpdateQueueRef = useRef>(new Map()) + const contactUpdateQueueRef = useRef>(new Map()) const contactUpdateTimerRef = useRef(null) const lastUpdateTimeRef = useRef(0) @@ -648,17 +2951,17 @@ function ChatPage(_props: ChatPageProps) { contactUpdateTimerRef.current = null } - // 增加防抖延迟到500ms,避免在滚动时频繁更新 + // 使用短防抖,让头像和昵称更快补齐但依然避免频繁重渲染 contactUpdateTimerRef.current = window.setTimeout(() => { const updates = contactUpdateQueueRef.current if (updates.size === 0) return const now = Date.now() - // 如果距离上次更新太近(小于1秒),继续延迟 - if (now - lastUpdateTimeRef.current < 1000) { + // 如果距离上次更新太近(小于250ms),继续延迟 + if (now - lastUpdateTimeRef.current < 250) { contactUpdateTimerRef.current = window.setTimeout(() => { flushContactUpdates() - }, 1000 - (now - lastUpdateTimeRef.current)) + }, 250 - (now - lastUpdateTimeRef.current)) return } @@ -671,12 +2974,14 @@ function ChatPage(_props: ChatPageProps) { if (update) { const newDisplayName = update.displayName || session.displayName || session.username const newAvatarUrl = update.avatarUrl || session.avatarUrl - if (newDisplayName !== session.displayName || newAvatarUrl !== session.avatarUrl) { + const newAlias = update.alias || session.alias + if (newDisplayName !== session.displayName || newAvatarUrl !== session.avatarUrl || newAlias !== session.alias) { hasChanges = true return { ...session, displayName: newDisplayName, - avatarUrl: newAvatarUrl + avatarUrl: newAvatarUrl, + alias: newAlias } } } @@ -686,6 +2991,7 @@ function ChatPage(_props: ChatPageProps) { if (hasChanges) { const updateStart = performance.now() setSessions(updatedSessions) + sessionsRef.current = updatedSessions lastUpdateTimeRef.current = Date.now() const updateTime = performance.now() - updateStart if (updateTime > 50) { @@ -695,7 +3001,7 @@ function ChatPage(_props: ChatPageProps) { updates.clear() contactUpdateTimerRef.current = null - }, 500) // 500ms 防抖,减少更新频率 + }, 120) }, [setSessions]) // 加载一批联系人信息并更新会话列表(优化:使用队列批量更新) @@ -708,7 +3014,7 @@ function ChatPage(_props: ChatPageProps) { const dllStart = performance.now() const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) as { success: boolean - contacts?: Record + contacts?: Record error?: string } const dllTime = performance.now() - dllStart @@ -725,18 +3031,34 @@ function ChatPage(_props: ChatPageProps) { // 将更新加入队列,用于侧边栏更新 const contacts = result.contacts || {} for (const [username, contact] of Object.entries(contacts)) { - contactUpdateQueueRef.current.set(username, contact) + const normalizedDisplayName = resolveSessionDisplayName(contact.displayName, username) || contact.displayName + const normalizedAvatarUrl = normalizeSearchAvatarUrl(contact.avatarUrl) + const normalizedAlias = normalizeSearchIdentityText(contact.alias) + contactUpdateQueueRef.current.set(username, { + displayName: normalizedDisplayName, + avatarUrl: normalizedAvatarUrl, + alias: normalizedAlias + }) + + if (normalizedDisplayName || normalizedAvatarUrl || normalizedAlias) { + sessionContactProfileCacheRef.current.set(username, { + displayName: normalizedDisplayName, + avatarUrl: normalizedAvatarUrl, + alias: normalizedAlias, + updatedAt: Date.now() + }) + } // 如果是自己的信息且当前个人头像为空,同步更新 - if (myWxid && username === myWxid && contact.avatarUrl && !myAvatarUrl) { + if (myWxid && username === myWxid && normalizedAvatarUrl && !myAvatarUrl) { - setMyAvatarUrl(contact.avatarUrl) + setMyAvatarUrl(normalizedAvatarUrl) } // 【核心优化】同步更新全局发送者头像缓存,供 MessageBubble 使用 senderAvatarCache.set(username, { - avatarUrl: contact.avatarUrl, - displayName: contact.displayName + avatarUrl: normalizedAvatarUrl, + displayName: normalizedDisplayName }) } // 触发批量更新 @@ -791,7 +3113,11 @@ function ChatPage(_props: ChatPageProps) { flashNewMessages(newOnes.map(getMessageKey)) // 滚动到底部 requestAnimationFrame(() => { - if (messageListRef.current) { + const latestMessages = useChatStore.getState().messages || [] + const lastIndex = latestMessages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) + } else if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) @@ -840,7 +3166,11 @@ function ChatPage(_props: ChatPageProps) { flashNewMessages(newMessages.map(getMessageKey)) // 滚动到底部 requestAnimationFrame(() => { - if (messageListRef.current) { + const currentMessages = useChatStore.getState().messages || [] + const lastIndex = currentMessages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) + } else if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) @@ -852,125 +3182,193 @@ function ChatPage(_props: ChatPageProps) { setIsRefreshingMessages(false) } } - - - - // 动态游标批量大小控制 + // 消息批量大小控制(保持稳定,避免游标反复重建) const currentBatchSizeRef = useRef(50) + const warmupGroupSenderProfiles = useCallback((usernames: string[], defer = false) => { + if (!Array.isArray(usernames) || usernames.length === 0) return + + const runWarmup = () => { + const batchPromise = loadContactInfoBatch(usernames) + usernames.forEach(username => { + if (!senderAvatarLoading.has(username)) { + senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null)) + } + }) + batchPromise.finally(() => { + usernames.forEach(username => senderAvatarLoading.delete(username)) + }) + } + + if (defer) { + if ('requestIdleCallback' in window) { + window.requestIdleCallback(() => { + runWarmup() + }, { timeout: 1200 }) + } else { + globalThis.setTimeout(runWarmup, 120) + } + return + } + + runWarmup() + }, [loadContactInfoBatch]) + // 加载消息 - const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0, ascending = false) => { + const loadMessages = async ( + sessionId: string, + offset = 0, + startTime = 0, + endTime = 0, + ascending = false, + options: LoadMessagesOptions = {} + ) => { const listEl = messageListRef.current const session = sessionMapRef.current.get(sessionId) const unreadCount = session?.unreadCount ?? 0 - let messageLimit = 50 + let messageLimit = currentBatchSizeRef.current if (offset === 0) { - // 初始加载:重置批量大小 - currentBatchSizeRef.current = 50 - // 首屏优化:消息过多时限制数量 - messageLimit = unreadCount > 99 ? 30 : 50 + const preferredLimit = Number.isFinite(options.forceInitialLimit) + ? Math.max(10, Math.floor(options.forceInitialLimit as number)) + : (unreadCount > 99 ? 30 : 40) + currentBatchSizeRef.current = preferredLimit + messageLimit = preferredLimit } else { - // 滚动加载:动态递增 (50 -> 100 -> 200) - if (currentBatchSizeRef.current < 100) { - currentBatchSizeRef.current = 100 - } else { - currentBatchSizeRef.current = 200 - } + // 同一会话内保持固定批量,避免后端游标因 batch 改变而重建 messageLimit = currentBatchSizeRef.current } if (offset === 0) { + suppressScrollToBottomButton(260) + setShowScrollToBottom(false) setLoadingMessages(true) - setMessages([]) + // 切会话时保留旧内容作为过渡,避免大面积闪烁 + setHasInitialMessages(true) } else { setLoadingMore(true) } - // 记录加载前的第一条消息元素 + const visibleRange = visibleMessageRangeRef.current + const visibleStartIndex = Math.min( + Math.max(visibleRange.startIndex, 0), + Math.max(messages.length - 1, 0) + ) + const anchorMessageKeyBeforePrepend = offset > 0 && messages.length > 0 + ? getMessageKey(messages[visibleStartIndex]) + : null + + // 记录加载前的第一条消息元素(非虚拟列表回退路径) const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null try { - const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime, ascending) as { + const useLatestPath = offset === 0 && startTime === 0 && endTime === 0 && !ascending && options.preferLatestPath + const result = (useLatestPath + ? await window.electronAPI.chat.getLatestMessages(sessionId, messageLimit) + : await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime, ascending) + ) as { success: boolean; messages?: Message[]; hasMore?: boolean; + nextOffset?: number; error?: string } + const isStaleSwitchRequest = Boolean( + options.switchRequestSeq && options.switchRequestSeq !== sessionSwitchRequestSeqRef.current + ) + const isStaleInSessionJumpRequest = Boolean( + options.inSessionJumpRequestSeq && options.inSessionJumpRequestSeq !== inSessionResultJumpRequestSeqRef.current + ) + if (isStaleSwitchRequest || isStaleInSessionJumpRequest) { + return + } + if (options.switchRequestSeq && options.switchRequestSeq !== sessionSwitchRequestSeqRef.current) { + return + } + if (currentSessionRef.current !== sessionId) { + return + } if (result.success && result.messages) { + const resultMessages = result.messages if (offset === 0) { - setMessages(result.messages) - if (result.messages.length === 0) { + setMessages(resultMessages) + persistSessionPreviewCache(sessionId, resultMessages) + if (resultMessages.length === 0) { setNoMessageTable(true) setHasMoreMessages(false) } - // 预取发送者信息:在关闭加载遮罩前处理 - const unreadCount = session?.unreadCount ?? 0 + // 群聊发送者信息补齐改为非阻塞执行,避免影响首屏切换 const isGroup = sessionId.includes('@chatroom') - if (isGroup && result.messages.length > 0) { - const unknownSenders = [...new Set(result.messages + if (isGroup && resultMessages.length > 0) { + const unknownSenders = [...new Set(resultMessages .filter(m => m.isSend !== 1 && m.senderUsername && !senderAvatarCache.has(m.senderUsername)) .map(m => m.senderUsername as string) )] if (unknownSenders.length > 0) { - - // 在批量请求前,先将这些发送者标记为加载中,防止 MessageBubble 触发重复请求 - const batchPromise = loadContactInfoBatch(unknownSenders) - unknownSenders.forEach(username => { - if (!senderAvatarLoading.has(username)) { - senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null)) - } - }) - // 确保在请求完成后清理 loading 状态 - batchPromise.finally(() => { - unknownSenders.forEach(username => senderAvatarLoading.delete(username)) - }) + warmupGroupSenderProfiles(unknownSenders, options.deferGroupSenderWarmup === true) } } // 日期跳转时滚动到顶部,否则滚动到底部 + const loadedMessages = result.messages requestAnimationFrame(() => { - if (messageListRef.current) { - if (isDateJumpRef.current) { + if (isDateJumpRef.current) { + if (messageVirtuosoRef.current && resultMessages.length > 0) { + messageVirtuosoRef.current.scrollToIndex({ index: 0, align: 'start', behavior: 'auto' }) + } else if (messageListRef.current) { messageListRef.current.scrollTop = 0 - isDateJumpRef.current = false - } else { - messageListRef.current.scrollTop = messageListRef.current.scrollHeight } + isDateJumpRef.current = false + return + } + + const lastIndex = resultMessages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) + } else if (messageListRef.current) { + messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) } else { - appendMessages(result.messages, true) + appendMessages(resultMessages, true) // 加载更多也同样处理发送者信息预取 const isGroup = sessionId.includes('@chatroom') if (isGroup) { - const unknownSenders = [...new Set(result.messages + const unknownSenders = [...new Set(resultMessages .filter(m => m.isSend !== 1 && m.senderUsername && !senderAvatarCache.has(m.senderUsername)) .map(m => m.senderUsername as string) )] if (unknownSenders.length > 0) { - const batchPromise = loadContactInfoBatch(unknownSenders) - unknownSenders.forEach(username => { - if (!senderAvatarLoading.has(username)) { - senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null)) - } - }) - batchPromise.finally(() => { - unknownSenders.forEach(username => senderAvatarLoading.delete(username)) - }) + warmupGroupSenderProfiles(unknownSenders, false) } } - // 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置 - if (firstMsgEl && listEl) { - requestAnimationFrame(() => { + // 加载更早消息后保持视口锚点,避免跳屏 + const appendedMessages = result.messages + requestAnimationFrame(() => { + if (messageVirtuosoRef.current) { + if (anchorMessageKeyBeforePrepend) { + const latestMessages = useChatStore.getState().messages || [] + const anchorIndex = latestMessages.findIndex((msg) => getMessageKey(msg) === anchorMessageKeyBeforePrepend) + if (anchorIndex >= 0) { + messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' }) + return + } + } + if (resultMessages.length > 0) { + messageVirtuosoRef.current.scrollToIndex({ index: resultMessages.length, align: 'start', behavior: 'auto' }) + } + return + } + + if (firstMsgEl && listEl) { listEl.scrollTop = firstMsgEl.offsetTop - 80 - }) - } + } + }) } // 日期跳转(ascending=true):不往上加载更早的,往下加载更晚的 if (ascending) { @@ -986,7 +3384,10 @@ function ChatPage(_props: ChatPageProps) { } } } - setCurrentOffset(offset + result.messages.length) + const nextOffset = typeof result.nextOffset === 'number' + ? result.nextOffset + : offset + resultMessages.length + setCurrentOffset(nextOffset) } else if (!result.success) { setNoMessageTable(true) setHasMoreMessages(false) @@ -995,12 +3396,430 @@ function ChatPage(_props: ChatPageProps) { console.error('加载消息失败:', e) setConnectionError('加载消息失败') setHasMoreMessages(false) + if (offset === 0 && currentSessionRef.current === sessionId) { + setMessages([]) + } } finally { setLoadingMessages(false) setLoadingMore(false) + if (offset === 0 && pendingSessionLoadRef.current === sessionId) { + if (!options.switchRequestSeq || options.switchRequestSeq === sessionSwitchRequestSeqRef.current) { + pendingSessionLoadRef.current = null + initialLoadRequestedSessionRef.current = null + setIsSessionSwitching(false) + + // 处理从全局搜索跳转过来的情况 + const pendingSearch = pendingInSessionSearchRef.current + if (pendingSearch?.sessionId === sessionId) { + pendingInSessionSearchRef.current = null + void applyPendingInSessionSearch(sessionId, pendingSearch, options.switchRequestSeq) + } + } + } } } + const handleJumpDateSelect = useCallback((date: Date, options: { sessionId?: string; switchRequestSeq?: number } = {}) => { + const targetSessionId = String(options.sessionId || currentSessionRef.current || currentSessionId || '').trim() + if (!targetSessionId) return + const targetDate = new Date(date) + const end = Math.floor(targetDate.setHours(23, 59, 59, 999) / 1000) + // 日期跳转采用“锚点定位”而非“当天过滤”: + // 先定位到当日附近,再允许上下滚动跨天浏览。 + isDateJumpRef.current = false + setCurrentOffset(0) + setJumpStartTime(0) + setJumpEndTime(end) + setShowJumpPopover(false) + void loadMessages(targetSessionId, 0, 0, end, false, { + switchRequestSeq: options.switchRequestSeq + }) + }, [currentSessionId, loadMessages]) + + const cancelInSessionSearchTasks = useCallback(() => { + inSessionSearchGenRef.current += 1 + if (inSessionSearchTimerRef.current) { + clearTimeout(inSessionSearchTimerRef.current) + inSessionSearchTimerRef.current = null + } + setInSessionSearching(false) + setInSessionEnriching(false) + }, []) + + const cancelInSessionSearchJump = useCallback(() => { + inSessionResultJumpRequestSeqRef.current += 1 + if (inSessionResultJumpTimerRef.current) { + window.clearTimeout(inSessionResultJumpTimerRef.current) + inSessionResultJumpTimerRef.current = null + } + }, []) + + const resolveSearchSessionContext = useCallback((sessionId?: string) => { + const normalizedSessionId = String(sessionId || currentSessionRef.current || currentSessionId || '').trim() + const currentSearchSession = normalizedSessionId && Array.isArray(sessions) + ? sessions.find(session => session.username === normalizedSessionId) + : undefined + const resolvedSession = currentSearchSession + ? ( + standaloneSessionWindow && + normalizedInitialSessionId && + currentSearchSession.username === normalizedInitialSessionId + ? { + ...currentSearchSession, + displayName: currentSearchSession.displayName || fallbackDisplayName || currentSearchSession.username, + avatarUrl: currentSearchSession.avatarUrl || fallbackAvatarUrl || undefined + } + : currentSearchSession + ) + : ( + normalizedSessionId + ? { + username: normalizedSessionId, + displayName: fallbackDisplayName || normalizedSessionId, + avatarUrl: fallbackAvatarUrl || undefined + } as ChatSession + : undefined + ) + const isGroupSearchSession = Boolean( + resolvedSession && ( + isGroupChatSession(resolvedSession.username) || + ( + standaloneSessionWindow && + resolvedSession.username === normalizedInitialSessionId && + normalizedStandaloneInitialContactType === 'group' + ) + ) + ) + const isDirectSearchSession = Boolean( + resolvedSession && + isSingleContactSession(resolvedSession.username) && + !isGroupSearchSession + ) + return { + normalizedSessionId, + resolvedSession, + isDirectSearchSession, + isGroupSearchSession, + resolvedSessionDisplayName: normalizeSearchIdentityText(resolvedSession?.displayName) || normalizedSessionId || undefined, + resolvedSessionAvatarUrl: normalizeSearchAvatarUrl(resolvedSession?.avatarUrl) + } + }, [ + currentSessionId, + fallbackAvatarUrl, + fallbackDisplayName, + normalizedInitialSessionId, + normalizedStandaloneInitialContactType, + sessions, + standaloneSessionWindow, + isGroupChatSession + ]) + + const hydrateInSessionSearchResults = useCallback((rawMessages: Message[], sessionId?: string) => { + const sortedMessages = sortMessagesByCreateTimeDesc(rawMessages || []) + if (sortedMessages.length === 0) return [] + + const { + normalizedSessionId, + isDirectSearchSession, + isGroupSearchSession, + resolvedSessionDisplayName, + resolvedSessionAvatarUrl + } = resolveSearchSessionContext(sessionId) + const resolvedSessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId) + + return sortedMessages.map((message) => { + const senderUsername = normalizeSearchIdentityText(message.senderUsername) || message.senderUsername + const inferredSelfFromSender = isGroupSearchSession && isCurrentUserSearchIdentity(senderUsername, myWxid) + const senderDisplayName = resolveSearchSenderDisplayName( + message.senderDisplayName, + senderUsername, + normalizedSessionId + ) + const senderUsernameFallback = resolveSearchSenderUsernameFallback(senderUsername) + const senderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl) + const nextIsSend = inferredSelfFromSender ? 1 : message.isSend + const nextSenderDisplayName = nextIsSend === 1 + ? (senderDisplayName || '我') + : ( + senderDisplayName || + (isDirectSearchSession ? resolvedSessionDisplayName : undefined) || + senderUsernameFallback || + (isDirectSearchSession ? resolvedSessionUsernameFallback : undefined) || + '未知' + ) + const nextSenderAvatarUrl = nextIsSend === 1 + ? (senderAvatarUrl || myAvatarUrl) + : (senderAvatarUrl || (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined)) + + if ( + senderUsername === message.senderUsername && + nextIsSend === message.isSend && + nextSenderDisplayName === message.senderDisplayName && + nextSenderAvatarUrl === message.senderAvatarUrl + ) { + return message + } + + return { + ...message, + isSend: nextIsSend, + senderUsername, + senderDisplayName: nextSenderDisplayName, + senderAvatarUrl: nextSenderAvatarUrl + } + }) + }, [currentSessionId, myAvatarUrl, myWxid, resolveSearchSessionContext]) + + const enrichMessagesWithSenderProfiles = useCallback(async (rawMessages: Message[], sessionId?: string) => { + let messages = hydrateInSessionSearchResults(rawMessages, sessionId) + if (messages.length === 0) return [] + + const sessionContext = resolveSearchSessionContext(sessionId) + const { normalizedSessionId, isDirectSearchSession, isGroupSearchSession } = sessionContext + let resolvedSessionDisplayName = sessionContext.resolvedSessionDisplayName + let resolvedSessionAvatarUrl = sessionContext.resolvedSessionAvatarUrl + + if ( + normalizedSessionId && + isDirectSearchSession && + ( + !resolvedSessionAvatarUrl || + !resolvedSessionDisplayName || + resolvedSessionDisplayName === normalizedSessionId + ) + ) { + try { + const result = await window.electronAPI.chat.enrichSessionsContactInfo([normalizedSessionId]) + const profile = result.success && result.contacts ? result.contacts[normalizedSessionId] : undefined + const profileDisplayName = resolveSearchSenderDisplayName( + profile?.displayName, + normalizedSessionId, + normalizedSessionId + ) + const profileAvatarUrl = normalizeSearchAvatarUrl(profile?.avatarUrl) + if (profileDisplayName) { + resolvedSessionDisplayName = profileDisplayName + } + if (profileAvatarUrl) { + resolvedSessionAvatarUrl = profileAvatarUrl + } + if (profileDisplayName || profileAvatarUrl) { + messages = messages.map((message) => { + if (message.isSend === 1) return message + const preservedDisplayName = resolveSearchSenderDisplayName( + message.senderDisplayName, + message.senderUsername, + normalizedSessionId + ) + return { + ...message, + senderDisplayName: preservedDisplayName || + profileDisplayName || + resolvedSessionDisplayName || + resolveSearchSenderUsernameFallback(message.senderUsername) || + message.senderDisplayName, + senderAvatarUrl: normalizeSearchAvatarUrl(message.senderAvatarUrl) || profileAvatarUrl || resolvedSessionAvatarUrl || message.senderAvatarUrl + } + }) + } + } catch { + // ignore session profile enrichment errors and keep raw search results usable + } + } + + if (normalizedSessionId && isGroupSearchSession) { + const missingSenderMessages = messages.filter((message) => { + if (message.localId <= 0) return false + if (message.isSend === 1) return false + return !normalizeSearchIdentityText(message.senderUsername) + }) + + if (missingSenderMessages.length > 0) { + const messageByLocalId = new Map() + for (let index = 0; index < missingSenderMessages.length; index += 8) { + const batch = missingSenderMessages.slice(index, index + 8) + const detailResults = await Promise.allSettled( + batch.map(async (message) => { + const result = await window.electronAPI.chat.getMessage(normalizedSessionId, message.localId) + if (!result.success || !result.message) return null + return { + localId: message.localId, + message: hydrateInSessionSearchResults([{ + ...message, + ...result.message, + parsedContent: message.parsedContent || result.message.parsedContent, + rawContent: message.rawContent || result.message.rawContent, + content: message.content || result.message.content + } as Message], normalizedSessionId)[0] + } + }) + ) + + for (const detail of detailResults) { + if (detail.status !== 'fulfilled' || !detail.value?.message) continue + messageByLocalId.set(detail.value.localId, detail.value.message) + } + } + + if (messageByLocalId.size > 0) { + messages = messages.map(message => messageByLocalId.get(message.localId) || message) + } + } + } + + const profileMap = new Map() + const pendingLoads: Array> = [] + const missingUsernames: string[] = [] + + const usernames = [...new Set( + messages + .map((message) => normalizeSearchIdentityText(message.senderUsername)) + .filter((username): username is string => Boolean(username)) + )] + + for (const username of usernames) { + const cached = senderAvatarCache.get(username) + if (cached) { + profileMap.set(username, cached) + continue + } + + const pending = senderAvatarLoading.get(username) + if (pending) { + pendingLoads.push( + pending.then((profile) => { + if (profile) { + profileMap.set(username, profile) + } + }).catch(() => {}) + ) + continue + } + + missingUsernames.push(username) + } + + if (pendingLoads.length > 0) { + await Promise.allSettled(pendingLoads) + } + + if (missingUsernames.length > 0) { + try { + const result = await window.electronAPI.chat.enrichSessionsContactInfo(missingUsernames) + if (result.success && result.contacts) { + for (const [username, profile] of Object.entries(result.contacts)) { + const normalizedProfile = { + avatarUrl: profile.avatarUrl, + displayName: profile.displayName + } + profileMap.set(username, normalizedProfile) + senderAvatarCache.set(username, normalizedProfile) + } + } + } catch { + // ignore sender enrichment errors and keep raw search results usable + } + } + + return messages.map((message) => { + const sender = normalizeSearchIdentityText(message.senderUsername) + const profile = sender ? profileMap.get(sender) : undefined + const inferredSelfFromSender = isGroupSearchSession && isCurrentUserSearchIdentity(sender, myWxid) + const profileDisplayName = resolveSearchSenderDisplayName( + profile?.displayName, + sender, + normalizedSessionId + ) + const currentSenderDisplayName = resolveSearchSenderDisplayName( + message.senderDisplayName, + sender, + normalizedSessionId + ) + const senderUsernameFallback = resolveSearchSenderUsernameFallback(sender) + const sessionUsernameFallback = resolveSearchSenderUsernameFallback(normalizedSessionId) + const currentSenderAvatarUrl = normalizeSearchAvatarUrl(message.senderAvatarUrl) + const nextIsSend = inferredSelfFromSender ? 1 : message.isSend + const nextSenderDisplayName = nextIsSend === 1 + ? (currentSenderDisplayName || profileDisplayName || '我') + : ( + profileDisplayName || + currentSenderDisplayName || + (isDirectSearchSession ? resolvedSessionDisplayName : undefined) || + senderUsernameFallback || + (isDirectSearchSession ? sessionUsernameFallback : undefined) || + '未知' + ) + const nextSenderAvatarUrl = nextIsSend === 1 + ? (currentSenderAvatarUrl || myAvatarUrl || normalizeSearchAvatarUrl(profile?.avatarUrl)) + : ( + currentSenderAvatarUrl || + normalizeSearchAvatarUrl(profile?.avatarUrl) || + (isDirectSearchSession ? resolvedSessionAvatarUrl : undefined) + ) + + if ( + sender === message.senderUsername && + nextIsSend === message.isSend && + nextSenderDisplayName === message.senderDisplayName && + nextSenderAvatarUrl === message.senderAvatarUrl + ) { + return message + } + + return { + ...message, + isSend: nextIsSend, + senderUsername: sender || message.senderUsername, + senderDisplayName: nextSenderDisplayName, + senderAvatarUrl: nextSenderAvatarUrl + } + }) + }, [ + currentSessionId, + hydrateInSessionSearchResults, + myAvatarUrl, + myWxid, + resolveSearchSessionContext + ]) + + const applyPendingInSessionSearch = useCallback(async ( + sessionId: string, + payload: PendingInSessionSearchPayload, + switchRequestSeq?: number + ) => { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + if (payload.sessionId !== normalizedSessionId) return + if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return + if (currentSessionRef.current !== normalizedSessionId) return + + const immediateResults = hydrateInSessionSearchResults(payload.results || [], normalizedSessionId) + setShowInSessionSearch(true) + setInSessionQuery(payload.keyword) + setInSessionSearchError(null) + setInSessionResults(immediateResults) + + if (payload.firstMsgTime > 0) { + handleJumpDateSelect(new Date(payload.firstMsgTime * 1000), { + sessionId: normalizedSessionId, + switchRequestSeq + }) + } + + setInSessionEnriching(true) + void enrichMessagesWithSenderProfiles(immediateResults, normalizedSessionId).then((enrichedResults) => { + if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return + if (currentSessionRef.current !== normalizedSessionId) return + setInSessionResults(enrichedResults) + }).catch(() => { + // ignore sender enrichment errors and keep current search results usable + }).finally(() => { + if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return + if (currentSessionRef.current !== normalizedSessionId) return + setInSessionEnriching(false) + }) + }, [enrichMessagesWithSenderProfiles, handleJumpDateSelect, hydrateInSessionSearchResults]) + // 加载更晚的消息 const loadLaterMessages = useCallback(async () => { if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return @@ -1033,6 +3852,111 @@ function ChatPage(_props: ChatPageProps) { } }, [currentSessionId, isLoadingMore, isLoadingMessages, messages, getMessageKey, appendMessages, setHasMoreLater, setLoadingMore]) + const refreshSessionIncrementally = useCallback(async (sessionId: string, switchRequestSeq?: number) => { + const currentMessages = useChatStore.getState().messages || [] + const lastMsg = currentMessages[currentMessages.length - 1] + const minTime = lastMsg?.createTime || 0 + if (!sessionId || minTime <= 0) return + + try { + const result = await window.electronAPI.chat.getNewMessages(sessionId, minTime, 120) as { + success: boolean + messages?: Message[] + error?: string + } + if (switchRequestSeq && switchRequestSeq !== sessionSwitchRequestSeqRef.current) return + if (currentSessionRef.current !== sessionId) return + if (!result.success || !Array.isArray(result.messages) || result.messages.length === 0) return + + const latestMessages = useChatStore.getState().messages || [] + const existing = new Set(latestMessages.map(getMessageKey)) + const newMessages = result.messages.filter((msg) => !existing.has(getMessageKey(msg))) + if (newMessages.length > 0) { + appendMessages(newMessages, false) + } + } catch (error) { + console.warn('[SessionCache] 增量刷新失败:', error) + } + }, [appendMessages, getMessageKey]) + + // 选择会话 + const selectSessionById = useCallback((sessionId: string, options: { force?: boolean } = {}) => { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return + const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1 + sessionSwitchRequestSeqRef.current = switchRequestSeq + currentSessionRef.current = normalizedSessionId + + const pendingSearch = pendingInSessionSearchRef.current + const shouldPreservePendingSearch = pendingSearch?.sessionId === normalizedSessionId + cancelInSessionSearchTasks() + cancelInSessionSearchJump() + + // 清空会话内搜索状态(除非是从全局搜索跳转过来) + if (!shouldPreservePendingSearch) { + pendingInSessionSearchRef.current = null + setShowInSessionSearch(false) + setInSessionQuery('') + setInSessionResults([]) + setInSessionSearchError(null) + } + + setCurrentSession(normalizedSessionId, { preserveMessages: false }) + setNoMessageTable(false) + + const restoredFromWindowCache = restoreSessionWindowCache(normalizedSessionId) + if (restoredFromWindowCache) { + pendingSessionLoadRef.current = null + initialLoadRequestedSessionRef.current = null + setIsSessionSwitching(false) + + // 处理从全局搜索跳转过来的情况 + if (pendingSearch?.sessionId === normalizedSessionId) { + pendingInSessionSearchRef.current = null + void applyPendingInSessionSearch(normalizedSessionId, pendingSearch, switchRequestSeq) + } + + void refreshSessionIncrementally(normalizedSessionId, switchRequestSeq) + } else { + pendingSessionLoadRef.current = normalizedSessionId + initialLoadRequestedSessionRef.current = normalizedSessionId + setIsSessionSwitching(true) + void hydrateSessionPreview(normalizedSessionId) + setCurrentOffset(0) + setJumpStartTime(0) + setJumpEndTime(0) + void loadMessages(normalizedSessionId, 0, 0, 0, false, { + preferLatestPath: true, + deferGroupSenderWarmup: true, + forceInitialLimit: 30, + switchRequestSeq + }) + } + // 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开 + setShowJumpPopover(false) + setShowDetailPanel(false) + setShowGroupMembersPanel(false) + setGroupMemberSearchKeyword('') + setGroupMembersError(null) + setGroupMembersLoadingHint('') + setIsRefreshingGroupMembers(false) + groupMembersRequestSeqRef.current += 1 + setIsLoadingGroupMembers(false) + setSessionDetail(null) + setIsRefreshingDetailStats(false) + setIsLoadingRelationStats(false) + }, [ + currentSessionId, + setCurrentSession, + restoreSessionWindowCache, + refreshSessionIncrementally, + hydrateSessionPreview, + loadMessages, + cancelInSessionSearchJump, + cancelInSessionSearchTasks, + applyPendingInSessionSearch + ]) + // 选择会话 const handleSelectSession = (session: ChatSession) => { // 点击折叠群入口,切换到折叠群视图 @@ -1040,17 +3964,7 @@ function ChatPage(_props: ChatPageProps) { setFoldedView(true) return } - if (session.username === currentSessionId) return - setCurrentSession(session.username) - setCurrentOffset(0) - setJumpStartTime(0) - setJumpEndTime(0) - loadMessages(session.username, 0, 0, 0) - // 重置详情面板 - setSessionDetail(null) - if (showDetailPanel) { - loadSessionDetail(session.username) - } + selectSessionById(session.username) } // 搜索过滤 @@ -1063,43 +3977,502 @@ function ChatPage(_props: ChatPageProps) { setSearchKeyword('') } - // 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行) - const scrollTimeoutRef = useRef(null) - const handleScroll = useCallback(() => { - if (!messageListRef.current) return + // 会话内搜索 + const inSessionSearchTimerRef = useRef | null>(null) + const inSessionSearchGenRef = useRef(0) + const handleInSessionSearch = useCallback(async (keyword: string) => { + setInSessionQuery(keyword) + if (inSessionSearchTimerRef.current) clearTimeout(inSessionSearchTimerRef.current) + inSessionSearchTimerRef.current = null + inSessionSearchGenRef.current += 1 + if (!keyword.trim() || !currentSessionId) { + setInSessionResults([]) + setInSessionSearchError(null) + setInSessionSearching(false) + setInSessionEnriching(false) + return + } + setInSessionSearchError(null) + const gen = inSessionSearchGenRef.current + const sid = currentSessionId + inSessionSearchTimerRef.current = setTimeout(async () => { + if (gen !== inSessionSearchGenRef.current) return + setInSessionSearching(true) + try { + const res = await window.electronAPI.chat.searchMessages(keyword.trim(), sid, 50, 0) + if (!res?.success) { + throw new Error(res?.error || '搜索失败') + } + if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return + const messages = hydrateInSessionSearchResults(res?.messages || [], sid) + setInSessionResults(messages) + setInSessionSearchError(null) - // 节流:延迟执行,避免滚动时频繁计算 - if (scrollTimeoutRef.current) { - cancelAnimationFrame(scrollTimeoutRef.current) + setInSessionEnriching(true) + void enrichMessagesWithSenderProfiles(messages, sid).then((enriched) => { + if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return + setInSessionResults(enriched) + }).catch(() => { + // ignore sender enrichment errors and keep current search results usable + }).finally(() => { + if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return + setInSessionEnriching(false) + }) + } catch (error) { + if (gen !== inSessionSearchGenRef.current || currentSessionRef.current !== sid) return + setInSessionResults([]) + setInSessionSearchError(error instanceof Error ? error.message : String(error)) + setInSessionEnriching(false) + } finally { + if (gen === inSessionSearchGenRef.current) setInSessionSearching(false) + } + }, 500) + }, [currentSessionId, enrichMessagesWithSenderProfiles, hydrateInSessionSearchResults]) + + const handleToggleInSessionSearch = useCallback(() => { + setShowInSessionSearch(v => { + if (v) { + cancelInSessionSearchTasks() + cancelInSessionSearchJump() + setInSessionQuery('') + setInSessionResults([]) + setInSessionSearchError(null) + } else { + setTimeout(() => inSessionSearchRef.current?.focus(), 50) + } + return !v + }) + }, [cancelInSessionSearchJump, cancelInSessionSearchTasks]) + + // 全局消息搜索 + const globalMsgSearchTimerRef = useRef | null>(null) + const globalMsgSearchGenRef = useRef(0) + const ensureGlobalMsgSearchNotStale = useCallback((gen: number) => { + if (gen !== globalMsgSearchGenRef.current) { + throw new Error(GLOBAL_MSG_SEARCH_CANCELED_ERROR) + } + }, []) + + const runLegacyGlobalMsgSearch = useCallback(async ( + keyword: string, + sessionList: ChatSession[], + gen: number + ): Promise => { + const results: GlobalMsgSearchResult[] = [] + for (let index = 0; index < sessionList.length; index += GLOBAL_MSG_LEGACY_CONCURRENCY) { + ensureGlobalMsgSearchNotStale(gen) + const chunk = sessionList.slice(index, index + GLOBAL_MSG_LEGACY_CONCURRENCY) + const chunkResults = await Promise.allSettled( + chunk.map(async (session) => { + const res = await window.electronAPI.chat.searchMessages(keyword, session.username, GLOBAL_MSG_PER_SESSION_LIMIT, 0) + if (!res?.success) { + throw new Error(res?.error || `搜索失败: ${session.username}`) + } + return normalizeGlobalMsgSearchMessages(res?.messages || [], session.username) + }) + ) + ensureGlobalMsgSearchNotStale(gen) + + for (const item of chunkResults) { + if (item.status === 'rejected') { + throw item.reason instanceof Error ? item.reason : new Error(String(item.reason)) + } + if (item.value.length > 0) { + results.push(...item.value) + } + } + } + return sortMessagesByCreateTimeDesc(results) + }, [ensureGlobalMsgSearchNotStale]) + + const compareGlobalMsgSearchShadow = useCallback(( + keyword: string, + stagedResults: GlobalMsgSearchResult[], + legacyResults: GlobalMsgSearchResult[] + ) => { + const stagedMap = buildGlobalMsgSearchSessionLocalIds(stagedResults) + const legacyMap = buildGlobalMsgSearchSessionLocalIds(legacyResults) + const stagedSessions = Object.keys(stagedMap).sort() + const legacySessions = Object.keys(legacyMap).sort() + + let mismatch = stagedSessions.length !== legacySessions.length + if (!mismatch) { + for (let i = 0; i < stagedSessions.length; i += 1) { + if (stagedSessions[i] !== legacySessions[i]) { + mismatch = true + break + } + } } - scrollTimeoutRef.current = requestAnimationFrame(() => { - if (!messageListRef.current) return + if (!mismatch) { + for (const sessionId of stagedSessions) { + const stagedIds = stagedMap[sessionId] || [] + const legacyIds = legacyMap[sessionId] || [] + if (stagedIds.length !== legacyIds.length) { + mismatch = true + break + } + for (let i = 0; i < stagedIds.length; i += 1) { + if (stagedIds[i] !== legacyIds[i]) { + mismatch = true + break + } + } + if (mismatch) break + } + } - const { scrollTop, clientHeight, scrollHeight } = messageListRef.current - - // 显示回到底部按钮:距离底部超过 300px - const distanceFromBottom = scrollHeight - scrollTop - clientHeight - setShowScrollToBottom(distanceFromBottom > 300) - - // 预加载:当滚动到顶部 30% 区域时开始加载 - if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) { - const threshold = clientHeight * 0.3 - if (scrollTop < threshold) { - loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) + if (!mismatch) { + const stagedOrder = stagedResults.map((row) => `${row.sessionId}:${row.localId || 0}:${row.messageKey || ''}`) + const legacyOrder = legacyResults.map((row) => `${row.sessionId}:${row.localId || 0}:${row.messageKey || ''}`) + if (stagedOrder.length !== legacyOrder.length) { + mismatch = true + } else { + for (let i = 0; i < stagedOrder.length; i += 1) { + if (stagedOrder[i] !== legacyOrder[i]) { + mismatch = true + break + } } } + } - // 预加载更晚的消息 - if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) { - const threshold = clientHeight * 0.3 - const distanceFromBottom = scrollHeight - scrollTop - clientHeight - if (distanceFromBottom < threshold) { - loadLaterMessages() - } - } + if (!mismatch) return + console.warn('[GlobalMsgSearch] shadow compare mismatch', { + keyword, + stagedSessionCount: stagedSessions.length, + legacySessionCount: legacySessions.length, + stagedResultCount: stagedResults.length, + legacyResultCount: legacyResults.length, + stagedMap, + legacyMap }) - }, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages]) + }, []) + + const handleGlobalMsgSearch = useCallback(async (keyword: string) => { + const normalizedKeyword = keyword.trim() + setGlobalMsgQuery(keyword) + if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current) + globalMsgSearchTimerRef.current = null + globalMsgSearchGenRef.current += 1 + if (!normalizedKeyword) { + pendingGlobalMsgSearchReplayRef.current = null + globalMsgPrefixCacheRef.current = null + setGlobalMsgResults([]) + setGlobalMsgSearchError(null) + setShowGlobalMsgSearch(false) + setGlobalMsgSearching(false) + setGlobalMsgSearchPhase('idle') + setGlobalMsgIsBackfilling(false) + setGlobalMsgAuthoritativeSessionCount(0) + return + } + setShowGlobalMsgSearch(true) + setGlobalMsgSearchError(null) + setGlobalMsgSearchPhase('seed') + setGlobalMsgIsBackfilling(false) + setGlobalMsgAuthoritativeSessionCount(0) + + const sessionList = Array.isArray(sessionsRef.current) ? sessionsRef.current.filter((session) => String(session.username || '').trim()) : [] + if (!isConnectedRef.current || sessionList.length === 0) { + pendingGlobalMsgSearchReplayRef.current = normalizedKeyword + setGlobalMsgResults([]) + setGlobalMsgSearchError(null) + setGlobalMsgSearching(false) + setGlobalMsgSearchPhase('idle') + setGlobalMsgIsBackfilling(false) + setGlobalMsgAuthoritativeSessionCount(0) + return + } + + pendingGlobalMsgSearchReplayRef.current = null + const gen = globalMsgSearchGenRef.current + globalMsgSearchTimerRef.current = setTimeout(async () => { + if (gen !== globalMsgSearchGenRef.current) return + setGlobalMsgSearching(true) + setGlobalMsgSearchPhase('seed') + setGlobalMsgIsBackfilling(false) + setGlobalMsgAuthoritativeSessionCount(0) + try { + ensureGlobalMsgSearchNotStale(gen) + + const seedResponse = await window.electronAPI.chat.searchMessages(normalizedKeyword, undefined, GLOBAL_MSG_SEED_LIMIT, 0) + if (!seedResponse?.success) { + throw new Error(seedResponse?.error || '搜索失败') + } + ensureGlobalMsgSearchNotStale(gen) + + const seedRows = normalizeGlobalMsgSearchMessages(seedResponse?.messages || []) + const seedMap = buildGlobalMsgSearchSessionMap(seedRows) + const authoritativeMap = new Map() + setGlobalMsgResults(composeGlobalMsgSearchResults(seedMap, authoritativeMap)) + setGlobalMsgSearchError(null) + setGlobalMsgSearchPhase('backfill') + setGlobalMsgIsBackfilling(true) + + const previousPrefixCache = globalMsgPrefixCacheRef.current + const previousKeyword = String(previousPrefixCache?.keyword || '').trim() + const canUsePrefixCache = Boolean( + previousPrefixCache && + previousPrefixCache.completed && + previousKeyword && + normalizedKeyword.startsWith(previousKeyword) + ) + let targetSessionList = canUsePrefixCache + ? sessionList.filter((session) => previousPrefixCache?.matchedSessionIds.has(session.username)) + : sessionList + if (canUsePrefixCache && previousPrefixCache) { + let foundOutsidePrefix = false + for (const sessionId of seedMap.keys()) { + if (!previousPrefixCache.matchedSessionIds.has(sessionId)) { + foundOutsidePrefix = true + break + } + } + if (foundOutsidePrefix) { + targetSessionList = sessionList + } + } + + for (let index = 0; index < targetSessionList.length; index += GLOBAL_MSG_BACKFILL_CONCURRENCY) { + ensureGlobalMsgSearchNotStale(gen) + const chunk = targetSessionList.slice(index, index + GLOBAL_MSG_BACKFILL_CONCURRENCY) + const chunkResults = await Promise.allSettled( + chunk.map(async (session) => { + const res = await window.electronAPI.chat.searchMessages(normalizedKeyword, session.username, GLOBAL_MSG_PER_SESSION_LIMIT, 0) + if (!res?.success) { + throw new Error(res?.error || `搜索失败: ${session.username}`) + } + return { + sessionId: session.username, + messages: normalizeGlobalMsgSearchMessages(res?.messages || [], session.username) + } + }) + ) + ensureGlobalMsgSearchNotStale(gen) + + for (const item of chunkResults) { + if (item.status === 'rejected') { + throw item.reason instanceof Error ? item.reason : new Error(String(item.reason)) + } + authoritativeMap.set(item.value.sessionId, item.value.messages) + } + setGlobalMsgAuthoritativeSessionCount(authoritativeMap.size) + setGlobalMsgResults(composeGlobalMsgSearchResults(seedMap, authoritativeMap)) + } + + ensureGlobalMsgSearchNotStale(gen) + const finalResults = composeGlobalMsgSearchResults(seedMap, authoritativeMap) + setGlobalMsgResults(finalResults) + setGlobalMsgSearchError(null) + setGlobalMsgSearchPhase('done') + setGlobalMsgIsBackfilling(false) + + const matchedSessionIds = new Set() + for (const row of finalResults) { + matchedSessionIds.add(row.sessionId) + } + globalMsgPrefixCacheRef.current = { + keyword: normalizedKeyword, + matchedSessionIds, + completed: true + } + + if (shouldRunGlobalMsgShadowCompareSample()) { + void (async () => { + try { + const legacyResults = await runLegacyGlobalMsgSearch(normalizedKeyword, sessionList, gen) + if (gen !== globalMsgSearchGenRef.current) return + compareGlobalMsgSearchShadow(normalizedKeyword, finalResults, legacyResults) + } catch (error) { + if (isGlobalMsgSearchCanceled(error)) return + console.warn('[GlobalMsgSearch] shadow compare failed:', error) + } + })() + } + } catch (error) { + if (isGlobalMsgSearchCanceled(error)) return + if (gen !== globalMsgSearchGenRef.current) return + setGlobalMsgResults([]) + setGlobalMsgSearchError(error instanceof Error ? error.message : String(error)) + setGlobalMsgSearchPhase('done') + setGlobalMsgIsBackfilling(false) + setGlobalMsgAuthoritativeSessionCount(0) + globalMsgPrefixCacheRef.current = null + } finally { + if (gen === globalMsgSearchGenRef.current) setGlobalMsgSearching(false) + } + }, 500) + }, [compareGlobalMsgSearchShadow, ensureGlobalMsgSearchNotStale, runLegacyGlobalMsgSearch]) + + const handleCloseGlobalMsgSearch = useCallback(() => { + globalMsgSearchGenRef.current += 1 + if (globalMsgSearchTimerRef.current) clearTimeout(globalMsgSearchTimerRef.current) + globalMsgSearchTimerRef.current = null + pendingGlobalMsgSearchReplayRef.current = null + globalMsgPrefixCacheRef.current = null + setShowGlobalMsgSearch(false) + setGlobalMsgQuery('') + setGlobalMsgResults([]) + setGlobalMsgSearchError(null) + setGlobalMsgSearching(false) + setGlobalMsgSearchPhase('idle') + setGlobalMsgIsBackfilling(false) + setGlobalMsgAuthoritativeSessionCount(0) + }, []) + + const handleMessageRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { + visibleMessageRangeRef.current = range + const total = messages.length + const shouldWarmupVisibleGroupSenders = Boolean( + currentSessionId && ( + isGroupChatSession(currentSessionId) || + ( + standaloneSessionWindow && + normalizedInitialSessionId && + currentSessionId === normalizedInitialSessionId && + normalizedStandaloneInitialContactType === 'group' + ) + ) + ) + if (total <= 0) { + isMessageListAtBottomRef.current = true + setShowScrollToBottom(prev => (prev ? false : prev)) + return + } + + if (range.endIndex >= Math.max(total - 2, 0)) { + isMessageListAtBottomRef.current = true + setShowScrollToBottom(prev => (prev ? false : prev)) + } + + if ( + range.startIndex <= 2 && + !topRangeLoadLockRef.current && + !isLoadingMore && + !isLoadingMessages && + hasMoreMessages && + currentSessionId + ) { + topRangeLoadLockRef.current = true + void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) + } + + if ( + range.endIndex >= total - 3 && + !bottomRangeLoadLockRef.current && + !suppressAutoLoadLaterRef.current && + !isLoadingMore && + !isLoadingMessages && + hasMoreLater && + currentSessionId + ) { + bottomRangeLoadLockRef.current = true + void loadLaterMessages() + } + + if (shouldWarmupVisibleGroupSenders) { + const now = Date.now() + if (now - lastVisibleSenderWarmupAtRef.current >= 180) { + lastVisibleSenderWarmupAtRef.current = now + const latestMessages = useChatStore.getState().messages || [] + const visibleStart = Math.max(range.startIndex - 12, 0) + const visibleEnd = Math.min(range.endIndex + 20, total - 1) + const pendingUsernames = new Set() + for (let index = visibleStart; index <= visibleEnd; index += 1) { + const msg = latestMessages[index] + if (!msg || msg.isSend === 1) continue + const sender = String(msg.senderUsername || '').trim() + if (!sender) continue + if (senderAvatarCache.has(sender) || senderAvatarLoading.has(sender)) continue + pendingUsernames.add(sender) + if (pendingUsernames.size >= 24) break + } + if (pendingUsernames.size > 0) { + warmupGroupSenderProfiles([...pendingUsernames], false) + } + } + } + }, [ + messages.length, + isLoadingMore, + isLoadingMessages, + hasMoreMessages, + hasMoreLater, + currentSessionId, + currentOffset, + jumpStartTime, + jumpEndTime, + isGroupChatSession, + standaloneSessionWindow, + normalizedInitialSessionId, + normalizedStandaloneInitialContactType, + warmupGroupSenderProfiles, + loadMessages, + loadLaterMessages + ]) + + const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => { + if (messages.length <= 0) { + isMessageListAtBottomRef.current = true + setShowScrollToBottom(prev => (prev ? false : prev)) + return + } + + const listEl = messageListRef.current + const distanceFromBottom = listEl + ? (listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight)) + : Number.POSITIVE_INFINITY + const nearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(messages.length - 2, 0) + const nearBottomByDistance = distanceFromBottom <= 140 + const effectiveAtBottom = atBottom || nearBottomByRange || nearBottomByDistance + isMessageListAtBottomRef.current = effectiveAtBottom + + if (!effectiveAtBottom) { + bottomRangeLoadLockRef.current = false + // 用户主动离开底部后,解除“搜索跳转后的自动向后加载抑制” + suppressAutoLoadLaterRef.current = false + } + + if ( + isLoadingMessages || + isSessionSwitching || + isLoadingMore || + suppressScrollToBottomButtonRef.current + ) { + setShowScrollToBottom(prev => (prev ? false : prev)) + return + } + + if (effectiveAtBottom) { + setShowScrollToBottom(prev => (prev ? false : prev)) + return + } + const shouldShow = distanceFromBottom > 180 + setShowScrollToBottom(prev => (prev === shouldShow ? prev : shouldShow)) + }, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching]) + + const handleMessageListWheel = useCallback((event: React.WheelEvent) => { + if (event.deltaY <= 18) return + if (!currentSessionId || isLoadingMore || isLoadingMessages || !hasMoreLater) return + const listEl = messageListRef.current + if (!listEl) return + const distanceFromBottom = listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight) + if (distanceFromBottom > 96) return + if (bottomRangeLoadLockRef.current) return + + // 用户明确向下滚动时允许加载后续消息 + suppressAutoLoadLaterRef.current = false + bottomRangeLoadLockRef.current = true + void loadLaterMessages() + }, [currentSessionId, hasMoreLater, isLoadingMessages, isLoadingMore, loadLaterMessages]) + + const handleMessageAtTopStateChange = useCallback((atTop: boolean) => { + if (!atTop) { + topRangeLoadLockRef.current = false + } + }, []) const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => { @@ -1112,7 +4485,8 @@ function ChatPage(_props: ChatPageProps) { prev.lastTimestamp === next.lastTimestamp && prev.lastMsgType === next.lastMsgType && prev.displayName === next.displayName && - prev.avatarUrl === next.avatarUrl + prev.avatarUrl === next.avatarUrl && + prev.alias === next.alias ) }, []) @@ -1123,15 +4497,16 @@ function ChatPage(_props: ChatPageProps) { return Array.isArray(sessionsRef.current) ? sessionsRef.current : [] } if (!Array.isArray(sessionsRef.current) || sessionsRef.current.length === 0) { - return nextSessions + return nextSessions.map((next) => mergeSessionContactPresentation(next)) } const prevMap = new Map(sessionsRef.current.map((s) => [s.username, s])) return nextSessions.map((next) => { const prev = prevMap.get(next.username) - if (!prev) return next - return isSameSession(prev, next) ? prev : next + const merged = mergeSessionContactPresentation(next, prev) + if (!prev) return merged + return isSameSession(prev, merged) ? prev : merged }) - }, [isSameSession]) + }, [isSameSession, mergeSessionContactPresentation]) const flashNewMessages = useCallback((keys: string[]) => { if (keys.length === 0) return @@ -1141,15 +4516,59 @@ function ChatPage(_props: ChatPageProps) { }, 2500) }, []) + const handleInSessionResultJump = useCallback((msg: Message) => { + const targetTime = Number(msg.createTime || 0) + const targetSessionId = String(currentSessionRef.current || currentSessionId || '').trim() + if (!targetTime || !targetSessionId) return + + if (inSessionResultJumpTimerRef.current) { + window.clearTimeout(inSessionResultJumpTimerRef.current) + inSessionResultJumpTimerRef.current = null + } + + const requestSeq = inSessionResultJumpRequestSeqRef.current + 1 + inSessionResultJumpRequestSeqRef.current = requestSeq + const anchorEndTime = targetTime + 1 + const targetMessageKey = getMessageKey(msg) + + inSessionResultJumpTimerRef.current = window.setTimeout(() => { + inSessionResultJumpTimerRef.current = null + if (requestSeq !== inSessionResultJumpRequestSeqRef.current) return + if (currentSessionRef.current !== targetSessionId) return + + setCurrentOffset(0) + setJumpStartTime(0) + setJumpEndTime(anchorEndTime) + // 搜索跳转后默认不自动回流到最新消息,仅在用户主动向下滚动时加载后续 + suppressAutoLoadLaterRef.current = true + flashNewMessages([targetMessageKey]) + void loadMessages(targetSessionId, 0, 0, anchorEndTime, false, { + inSessionJumpRequestSeq: requestSeq + }) + }, 220) + }, [currentSessionId, flashNewMessages, getMessageKey, loadMessages]) + // 滚动到底部 const scrollToBottom = useCallback(() => { + suppressScrollToBottomButton(220) + isMessageListAtBottomRef.current = true + setShowScrollToBottom(false) + const lastIndex = messages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ + index: lastIndex, + align: 'end', + behavior: 'auto' + }) + return + } if (messageListRef.current) { messageListRef.current.scrollTo({ top: messageListRef.current.scrollHeight, - behavior: 'smooth' + behavior: 'auto' }) } - }, []) + }, [messages.length, suppressScrollToBottomButton]) // 拖动调节侧边栏宽度 const handleResizeStart = useCallback((e: React.MouseEvent) => { @@ -1184,6 +4603,18 @@ function ChatPage(_props: ChatPageProps) { // 组件卸载时清理 return () => { avatarLoadQueue.clear() + if (previewPersistTimerRef.current !== null) { + window.clearTimeout(previewPersistTimerRef.current) + previewPersistTimerRef.current = null + } + if (sessionListPersistTimerRef.current !== null) { + window.clearTimeout(sessionListPersistTimerRef.current) + sessionListPersistTimerRef.current = null + } + if (scrollBottomButtonArmTimerRef.current !== null) { + window.clearTimeout(scrollBottomButtonArmTimerRef.current) + scrollBottomButtonArmTimerRef.current = null + } if (contactUpdateTimerRef.current) { clearTimeout(contactUpdateTimerRef.current) } @@ -1191,6 +4622,9 @@ function ChatPage(_props: ChatPageProps) { clearTimeout(sessionScrollTimeoutRef.current) } contactUpdateQueueRef.current.clear() + pendingSessionContactEnrichRef.current.clear() + sessionContactEnrichAttemptAtRef.current.clear() + sessionContactProfileCacheRef.current.clear() enrichCancelledRef.current = true isEnrichingRef.current = false } @@ -1214,6 +4648,32 @@ function ChatPage(_props: ChatPageProps) { lastMessageTimeRef.current = lastMsg?.createTime ?? 0 }, [messages, getMessageKey]) + useEffect(() => { + lastObservedMessageCountRef.current = messages.length + if (messages.length <= 0) { + isMessageListAtBottomRef.current = true + } + }, [currentSessionId]) + + useEffect(() => { + const previousCount = lastObservedMessageCountRef.current + const currentCount = messages.length + lastObservedMessageCountRef.current = currentCount + if (currentCount <= previousCount) return + if (!currentSessionId || isLoadingMessages || isSessionSwitching) return + const wasNearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(previousCount - 2, 0) + if (!isMessageListAtBottomRef.current && !wasNearBottomByRange) return + suppressScrollToBottomButton(220) + isMessageListAtBottomRef.current = true + requestAnimationFrame(() => { + const latestMessages = useChatStore.getState().messages || [] + const lastIndex = latestMessages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) + } + }) + }, [messages.length, currentSessionId, isLoadingMessages, isSessionSwitching, suppressScrollToBottomButton]) + useEffect(() => { currentSessionRef.current = currentSessionId }, [currentSessionId]) @@ -1266,14 +4726,46 @@ function ChatPage(_props: ChatPageProps) { sessionMapRef.current = nextMap }, [sessions]) + useEffect(() => { + if (!Array.isArray(sessions) || sessions.length === 0) return + const now = Date.now() + const cache = sessionContactProfileCacheRef.current + + for (const session of sessions) { + const username = String(session.username || '').trim() + if (!username || isFoldPlaceholderSession(username)) continue + + const displayName = resolveSessionDisplayName(session.displayName, username) + const avatarUrl = normalizeSearchAvatarUrl(session.avatarUrl) + const alias = normalizeSearchIdentityText(session.alias) + if (!displayName && !avatarUrl && !alias) continue + + const prev = cache.get(username) + cache.set(username, { + displayName: displayName || prev?.displayName, + avatarUrl: avatarUrl || prev?.avatarUrl, + alias: alias || prev?.alias, + updatedAt: now + }) + } + + for (const [username, profile] of cache.entries()) { + if (now - profile.updatedAt > SESSION_CONTACT_PROFILE_CACHE_TTL_MS) { + cache.delete(username) + } + } + }, [sessions]) + useEffect(() => { sessionsRef.current = Array.isArray(sessions) ? sessions : [] }, [sessions]) useEffect(() => { - isLoadingMessagesRef.current = isLoadingMessages - isLoadingMoreRef.current = isLoadingMore - }, [isLoadingMessages, isLoadingMore]) + if (!isLoadingMore) { + topRangeLoadLockRef.current = false + bottomRangeLoadLockRef.current = false + } + }, [isLoadingMore]) useEffect(() => { if (initialRevealTimerRef.current !== null) { @@ -1309,11 +4801,18 @@ function ChatPage(_props: ChatPageProps) { }, [currentSessionId, messages.length, isLoadingMessages]) useEffect(() => { - if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) { + if (currentSessionId && isConnected && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) { + if (pendingSessionLoadRef.current === currentSessionId) return + if (initialLoadRequestedSessionRef.current === currentSessionId) return + initialLoadRequestedSessionRef.current = currentSessionId setHasInitialMessages(false) - loadMessages(currentSessionId, 0) + void loadMessages(currentSessionId, 0, 0, 0, false, { + preferLatestPath: true, + deferGroupSenderWarmup: true, + forceInitialLimit: 30 + }) } - }, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore, noMessageTable]) + }, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable]) useEffect(() => { return () => { @@ -1328,31 +4827,194 @@ function ChatPage(_props: ChatPageProps) { isConnectedRef.current = isConnected }, [isConnected]) + useEffect(() => { + const replayKeyword = pendingGlobalMsgSearchReplayRef.current + if (!replayKeyword || !isConnected || sessions.length === 0) return + pendingGlobalMsgSearchReplayRef.current = null + void handleGlobalMsgSearch(replayKeyword) + }, [isConnected, sessions.length, handleGlobalMsgSearch]) + + useEffect(() => { + return () => { + inSessionSearchGenRef.current += 1 + if (inSessionSearchTimerRef.current) { + clearTimeout(inSessionSearchTimerRef.current) + inSessionSearchTimerRef.current = null + } + globalMsgSearchGenRef.current += 1 + if (globalMsgSearchTimerRef.current) { + clearTimeout(globalMsgSearchTimerRef.current) + globalMsgSearchTimerRef.current = null + } + globalMsgPrefixCacheRef.current = null + } + }, []) + useEffect(() => { searchKeywordRef.current = searchKeyword }, [searchKeyword]) - // 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口 useEffect(() => { - if (!Array.isArray(sessions)) { - setFilteredSessions([]) - return + if (!showJumpPopover) return + const handleGlobalPointerDown = (event: MouseEvent) => { + const target = event.target as Node | null + if (!target) return + if (jumpCalendarWrapRef.current?.contains(target)) return + if (jumpPopoverPortalRef.current?.contains(target)) return + setShowJumpPopover(false) } + document.addEventListener('mousedown', handleGlobalPointerDown) + return () => { + document.removeEventListener('mousedown', handleGlobalPointerDown) + } + }, [showJumpPopover]) + + useEffect(() => { + if (!showJumpPopover) return + const syncPosition = () => { + requestAnimationFrame(() => updateJumpPopoverPosition()) + } + + syncPosition() + window.addEventListener('resize', syncPosition) + window.addEventListener('scroll', syncPosition, true) + return () => { + window.removeEventListener('resize', syncPosition) + window.removeEventListener('scroll', syncPosition, true) + } + }, [showJumpPopover, updateJumpPopoverPosition]) + + useEffect(() => { + setShowJumpPopover(false) + setLoadingDates(false) + setLoadingDateCounts(false) + setHasLoadedMessageDates(false) + setMessageDates(new Set()) + setMessageDateCounts({}) + }, [currentSessionId]) + + useEffect(() => { + if (!currentSessionId || !Array.isArray(messages) || messages.length === 0) return + persistSessionPreviewCache(currentSessionId, messages) + saveSessionWindowCache(currentSessionId, { + messages, + currentOffset, + hasMoreMessages, + hasMoreLater, + jumpStartTime, + jumpEndTime + }) + }, [ + currentSessionId, + messages, + currentOffset, + hasMoreMessages, + hasMoreLater, + jumpStartTime, + jumpEndTime, + persistSessionPreviewCache, + saveSessionWindowCache + ]) + + useEffect(() => { + return () => { + inSessionResultJumpRequestSeqRef.current += 1 + if (inSessionResultJumpTimerRef.current) { + window.clearTimeout(inSessionResultJumpTimerRef.current) + } + } + }, []) + + useEffect(() => { + if (!Array.isArray(sessions) || sessions.length === 0) return + if (sessionListPersistTimerRef.current !== null) { + window.clearTimeout(sessionListPersistTimerRef.current) + } + sessionListPersistTimerRef.current = window.setTimeout(() => { + persistSessionListCache(chatCacheScopeRef.current, sessions) + sessionListPersistTimerRef.current = null + }, 260) + }, [sessions, persistSessionListCache]) + + // 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口 + const filteredSessions = useMemo(() => { + if (!Array.isArray(sessions)) { + return [] + } + + // 检查是否有折叠的群聊 + const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) + const hasFoldedGroups = foldedGroups.length > 0 + const visible = sessions.filter(s => { if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false return true }) + + // 如果有折叠的群聊,但列表中没有入口,则插入入口 + if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) { + // 找到最新的折叠消息 + const latestFolded = foldedGroups.reduce((latest, current) => { + const latestTime = latest.sortTimestamp || latest.lastTimestamp + const currentTime = current.sortTimestamp || current.lastTimestamp + return currentTime > latestTime ? current : latest + }) + + const foldEntry: ChatSession = { + username: 'placeholder_foldgroup', + displayName: '折叠的聊天', + summary: `${latestFolded.displayName || latestFolded.username}: ${latestFolded.summary}`, + type: 0, + sortTimestamp: latestFolded.sortTimestamp || latestFolded.lastTimestamp, + lastTimestamp: latestFolded.lastTimestamp || latestFolded.sortTimestamp, + lastMsgType: 0, + unreadCount: foldedGroups.reduce((sum, s) => sum + (s.unreadCount || 0), 0), + isMuted: false, + isFolded: false + } + + // 按时间戳插入到正确位置 + const foldTime = foldEntry.sortTimestamp || foldEntry.lastTimestamp + const insertIndex = visible.findIndex(s => { + const sTime = s.sortTimestamp || s.lastTimestamp + return sTime < foldTime + }) + if (insertIndex === -1) { + visible.push(foldEntry) + } else { + visible.splice(insertIndex, 0, foldEntry) + } + } + if (!searchKeyword.trim()) { - setFilteredSessions(visible) - return + return visible } const lower = searchKeyword.toLowerCase() - setFilteredSessions(visible.filter(s => - s.displayName?.toLowerCase().includes(lower) || - s.username.toLowerCase().includes(lower) || - s.summary.toLowerCase().includes(lower) - )) - }, [sessions, searchKeyword, setFilteredSessions]) + return visible + .filter(s => { + const matchedByName = s.displayName?.toLowerCase().includes(lower) + const matchedByUsername = s.username.toLowerCase().includes(lower) + const matchedByAlias = s.alias?.toLowerCase().includes(lower) + return matchedByName || matchedByUsername || matchedByAlias + }) + .map(s => { + const matchedByName = s.displayName?.toLowerCase().includes(lower) + const matchedByUsername = s.username.toLowerCase().includes(lower) + const matchedByAlias = s.alias?.toLowerCase().includes(lower) + + let matchedField: 'wxid' | 'alias' | 'name' | undefined = undefined + + if (matchedByUsername && !matchedByName && !matchedByAlias) { + matchedField = 'wxid' + } else if (matchedByAlias && !matchedByName && !matchedByUsername) { + matchedField = 'alias' + } else if (matchedByName && !matchedByUsername && !matchedByAlias) { + matchedField = 'name' + } + + return { ...s, matchedField } + }) + }, [sessions, searchKeyword]) // 折叠群列表(独立计算,供折叠 panel 使用) const foldedSessions = useMemo(() => { @@ -1360,13 +5022,59 @@ function ChatPage(_props: ChatPageProps) { const folded = sessions.filter(s => s.isFolded) if (!searchKeyword.trim() || !foldedView) return folded const lower = searchKeyword.toLowerCase() - return folded.filter(s => - s.displayName?.toLowerCase().includes(lower) || - s.username.toLowerCase().includes(lower) || - s.summary.toLowerCase().includes(lower) - ) + return folded + // 1. 先过滤 + .filter(s => { + const matchedByName = s.displayName?.toLowerCase().includes(lower) + const matchedByUsername = s.username.toLowerCase().includes(lower) + const matchedByAlias = s.alias?.toLowerCase().includes(lower) + const matchedBySummary = s.summary?.toLowerCase().includes(lower) // 注意:这里有个 summary + + return matchedByName || matchedByUsername || matchedByAlias || matchedBySummary + }) + // 2. 后映射 + .map(s => { + const matchedByName = s.displayName?.toLowerCase().includes(lower) + const matchedByUsername = s.username.toLowerCase().includes(lower) + const matchedByAlias = s.alias?.toLowerCase().includes(lower) + const matchedBySummary = s.summary?.toLowerCase().includes(lower) + + let matchedField: 'wxid' | 'alias' | 'name' | undefined = undefined + + if (matchedByUsername && !matchedByName && !matchedBySummary && !matchedByAlias) { + matchedField = 'wxid' + } else if (matchedByAlias && !matchedByName && !matchedBySummary && !matchedByUsername) { + matchedField = 'alias' + } + + // ✅ 同样返回新对象 + return { ...s, matchedField } + }) }, [sessions, searchKeyword, foldedView]) + const sessionLookupMap = useMemo(() => { + const map = new Map() + for (const session of sessions) { + const username = String(session.username || '').trim() + if (!username) continue + map.set(username, session) + } + return map + }, [sessions]) + const groupedGlobalMsgResults = useMemo(() => { + const grouped = globalMsgResults.reduce((acc, msg) => { + const sessionId = (msg as any).sessionId || '未知' + if (!acc[sessionId]) acc[sessionId] = [] + acc[sessionId].push(msg) + return acc + }, {} as Record) + return Object.entries(grouped) + }, [globalMsgResults]) + + const hasSessionRecords = Array.isArray(sessions) && sessions.length > 0 + const shouldShowSessionsSkeleton = isLoadingSessions && !hasSessionRecords + const isSessionListSyncing = (isLoadingSessions || isRefreshingSessions) && hasSessionRecords + // 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算 const formatSessionTime = useCallback((timestamp: number): string => { @@ -1397,7 +5105,21 @@ function ChatPage(_props: ChatPageProps) { // 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback) const currentSession = (() => { const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined - if (found || !currentSessionId) return found + if (found) { + if ( + standaloneSessionWindow && + normalizedInitialSessionId && + found.username === normalizedInitialSessionId + ) { + return { + ...found, + displayName: found.displayName || fallbackDisplayName || found.username, + avatarUrl: found.avatarUrl || fallbackAvatarUrl || undefined + } + } + return found + } + if (!currentSessionId) return found return { username: currentSessionId, type: 0, @@ -1407,26 +5129,158 @@ function ChatPage(_props: ChatPageProps) { lastTimestamp: 0, lastMsgType: 0, displayName: fallbackDisplayName || currentSessionId, + avatarUrl: fallbackAvatarUrl || undefined, } as ChatSession })() + const filteredGroupPanelMembers = useMemo(() => { + const keyword = groupMemberSearchKeyword.trim().toLowerCase() + if (!keyword) return groupPanelMembers + return groupPanelMembers.filter((member) => { + const fields = [ + member.username, + member.displayName, + member.groupNickname, + member.remark, + member.nickname, + member.alias + ] + return fields.some(field => String(field || '').toLowerCase().includes(keyword)) + }) + }, [groupMemberSearchKeyword, groupPanelMembers]) + const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId)) + const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog + const isCurrentSessionGroup = Boolean( + currentSession && ( + isGroupChatSession(currentSession.username) || + ( + standaloneSessionWindow && + currentSession.username === normalizedInitialSessionId && + normalizedStandaloneInitialContactType === 'group' + ) + ) + ) + const isCurrentSessionPrivateSnsSupported = Boolean( + currentSession && + isSingleContactSession(currentSession.username) && + !isCurrentSessionGroup + ) + + const openCurrentSessionSnsTimeline = useCallback(() => { + if (!currentSession || !isCurrentSessionPrivateSnsSupported) return + setChatSnsTimelineTarget({ + username: currentSession.username, + displayName: currentSession.displayName || currentSession.username, + avatarUrl: currentSession.avatarUrl + }) + }, [currentSession, isCurrentSessionPrivateSnsSupported]) + + useEffect(() => { + if (!standaloneSessionWindow) return + setStandaloneInitialLoadRequested(false) + setStandaloneLoadStage(normalizedInitialSessionId ? 'connecting' : 'idle') + setFallbackDisplayName(normalizedStandaloneInitialDisplayName || null) + setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl || null) + }, [ + standaloneSessionWindow, + normalizedInitialSessionId, + normalizedStandaloneInitialDisplayName, + normalizedStandaloneInitialAvatarUrl + ]) + + useEffect(() => { + if (!standaloneSessionWindow) return + if (!normalizedInitialSessionId) return + + if (normalizedStandaloneInitialDisplayName) { + setFallbackDisplayName(normalizedStandaloneInitialDisplayName) + } + if (normalizedStandaloneInitialAvatarUrl) { + setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl) + } + + if (!currentSessionId) { + setCurrentSession(normalizedInitialSessionId, { preserveMessages: false }) + } + if (!isConnected || isConnecting) { + setStandaloneLoadStage('connecting') + } + }, [ + standaloneSessionWindow, + normalizedInitialSessionId, + normalizedStandaloneInitialDisplayName, + normalizedStandaloneInitialAvatarUrl, + currentSessionId, + isConnected, + isConnecting, + setCurrentSession + ]) + + useEffect(() => { + if (!standaloneSessionWindow) return + if (!normalizedInitialSessionId) return + if (!isConnected || isConnecting) return + if (currentSessionId === normalizedInitialSessionId && standaloneInitialLoadRequested) return + setStandaloneInitialLoadRequested(true) + setStandaloneLoadStage('loading') + selectSessionById(normalizedInitialSessionId, { + force: currentSessionId === normalizedInitialSessionId + }) + }, [ + standaloneSessionWindow, + normalizedInitialSessionId, + isConnected, + isConnecting, + currentSessionId, + standaloneInitialLoadRequested, + selectSessionById + ]) + + useEffect(() => { + if (!standaloneSessionWindow || !normalizedInitialSessionId) return + if (!isConnected || isConnecting) { + setStandaloneLoadStage('connecting') + return + } + if (!standaloneInitialLoadRequested) { + setStandaloneLoadStage('loading') + return + } + if (currentSessionId !== normalizedInitialSessionId) { + setStandaloneLoadStage('loading') + return + } + if (isLoadingMessages || isSessionSwitching) { + setStandaloneLoadStage('loading') + return + } + setStandaloneLoadStage('ready') + }, [ + standaloneSessionWindow, + normalizedInitialSessionId, + isConnected, + isConnecting, + standaloneInitialLoadRequested, + currentSessionId, + isLoadingMessages, + isSessionSwitching + ]) // 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称 useEffect(() => { if (!currentSessionId) return const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined if (found) { - setFallbackDisplayName(null) + if (found.displayName) setFallbackDisplayName(found.displayName) + if (found.avatarUrl) setFallbackAvatarUrl(found.avatarUrl) return } loadContactInfoBatch([currentSessionId]).then(() => { const cached = senderAvatarCache.get(currentSessionId) if (cached?.displayName) setFallbackDisplayName(cached.displayName) + if (cached?.avatarUrl) setFallbackAvatarUrl(cached.avatarUrl) }) }, [currentSessionId, sessions]) - // 判断是否为群聊 - const isGroupChat = (username: string) => username.includes('@chatroom') - // 渲染日期分隔 const shouldShowDateDivider = (msg: Message, prevMsg?: Message): boolean => { if (!prevMsg) return true @@ -1489,6 +5343,7 @@ function ChatPage(_props: ChatPageProps) { setBatchVoiceCount(voiceMessages.length) setBatchVoiceDates(sortedDates) setBatchSelectedDates(new Set(sortedDates)) + setBatchVoiceTaskType('transcribe') setShowBatchConfirm(true) }, [sessions, currentSessionId, isBatchTranscribing]) @@ -1525,23 +5380,34 @@ function ChatPage(_props: ChatPageProps) { const handleExportCurrentSession = useCallback(() => { if (!currentSessionId) return - navigate('/export', { - state: { - preselectSessionIds: [currentSessionId] - } + if (inProgressExportSessionIds.has(currentSessionId) || isPreparingExportDialog) return + + const requestId = `chat-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const sessionName = currentSession?.displayName || currentSession?.username || currentSessionId + pendingExportRequestIdRef.current = requestId + setIsPreparingExportDialog(true) + setExportPrepareHint('') + if (exportPrepareLongWaitTimerRef.current) { + window.clearTimeout(exportPrepareLongWaitTimerRef.current) + exportPrepareLongWaitTimerRef.current = null + } + emitOpenSingleExport({ + sessionId: currentSessionId, + sessionName, + requestId }) - }, [currentSessionId, navigate]) + }, [currentSession, currentSessionId, inProgressExportSessionIds, isPreparingExportDialog]) const handleGroupAnalytics = useCallback(() => { - if (!currentSessionId || !isGroupChat(currentSessionId)) return - navigate('/group-analytics', { + if (!currentSessionId || !isGroupChatSession(currentSessionId)) return + navigate('/analytics/group', { state: { preselectGroupIds: [currentSessionId] } }) - }, [currentSessionId, navigate]) + }, [currentSessionId, navigate, isGroupChatSession]) - // 确认批量转写 + // 确认批量语音任务(解密/转写) const confirmBatchTranscribe = useCallback(async () => { if (!currentSessionId) return @@ -1573,23 +5439,35 @@ function ChatPage(_props: ChatPageProps) { const session = sessions.find(s => s.username === currentSessionId) if (!session) return - startTranscribe(voiceMessages.length, session.displayName || session.username) + const taskType = batchVoiceTaskType + startTranscribe(voiceMessages.length, session.displayName || session.username, taskType) - // 检查模型状态 - const modelStatus = await window.electronAPI.whisper.getModelStatus() - if (!modelStatus?.exists) { - alert('SenseVoice 模型未下载,请先在设置中下载模型') - finishTranscribe(0, 0) - return + if (taskType === 'transcribe') { + // 检查模型状态 + const modelStatus = await window.electronAPI.whisper.getModelStatus() + if (!modelStatus?.exists) { + alert('SenseVoice 模型未下载,请先在设置中下载模型') + finishTranscribe(0, 0) + return + } } let successCount = 0 let failCount = 0 let completedCount = 0 - const concurrency = 10 + const concurrency = taskType === 'decrypt' ? 12 : 10 - const transcribeOne = async (msg: Message) => { + const runOne = async (msg: Message) => { try { + if (taskType === 'decrypt') { + const result = await window.electronAPI.chat.getVoiceData( + session.username, + String(msg.localId), + msg.createTime, + msg.serverIdRaw || msg.serverId + ) + return { success: Boolean(result.success && result.data) } + } const result = await window.electronAPI.chat.getVoiceTranscript( session.username, String(msg.localId), @@ -1603,7 +5481,7 @@ function ChatPage(_props: ChatPageProps) { for (let i = 0; i < voiceMessages.length; i += concurrency) { const batch = voiceMessages.slice(i, i + concurrency) - const results = await Promise.all(batch.map(msg => transcribeOne(msg))) + const results = await Promise.all(batch.map(msg => runOne(msg))) results.forEach(result => { if (result.success) successCount++ @@ -1614,7 +5492,7 @@ function ChatPage(_props: ChatPageProps) { } finishTranscribe(successCount, failCount) - }, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, startTranscribe, updateProgress, finishTranscribe]) + }, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, batchVoiceTaskType, startTranscribe, updateProgress, finishTranscribe]) // 批量转写:按日期的消息数量 const batchCountByDate = useMemo(() => { @@ -1635,6 +5513,12 @@ function ChatPage(_props: ChatPageProps) { ).length }, [batchVoiceMessages, batchSelectedDates]) + const batchVoiceTaskTitle = batchVoiceTaskType === 'decrypt' ? '批量解密语音' : '批量语音转文字' + const batchVoiceTaskVerb = batchVoiceTaskType === 'decrypt' ? '解密' : '转写' + const batchVoiceTaskMinutes = Math.ceil( + batchSelectedMessageCount * (batchVoiceTaskType === 'decrypt' ? 0.6 : 2) / 60 + ) + const toggleBatchDate = useCallback((date: string) => { setBatchSelectedDates(prev => { const next = new Set(prev) @@ -1696,20 +5580,17 @@ function ChatPage(_props: ChatPageProps) { } // 并发池:同时跑 concurrency 个任务 - const pool: Promise[] = [] + const pool = new Set>() for (const img of images) { - const p = decryptOne(img) - pool.push(p) - if (pool.length >= concurrency) { + const p = decryptOne(img).then(() => { pool.delete(p) }) + pool.add(p) + if (pool.size >= concurrency) { await Promise.race(pool) - // 移除已完成的 - for (let j = pool.length - 1; j >= 0; j--) { - const settled = await Promise.race([pool[j].then(() => true), Promise.resolve(false)]) - if (settled) pool.splice(j, 1) - } } } - await Promise.all(pool) + if (pool.size > 0) { + await Promise.all(pool) + } finishDecrypt(successCount, failCount) }, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress]) @@ -1743,53 +5624,66 @@ function ChatPage(_props: ChatPageProps) { const selectAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set(batchImageDates)), [batchImageDates]) const clearAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set()), []) - const lastSelectedIdRef = useRef(null) + const lastSelectedKeyRef = useRef(null) - const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => { + const handleToggleSelection = useCallback((messageKey: string, isShiftKey: boolean = false) => { setSelectedMessages(prev => { const next = new Set(prev) // Range selection with Shift key - if (isShiftKey && lastSelectedIdRef.current !== null && lastSelectedIdRef.current !== localId) { + if (isShiftKey && lastSelectedKeyRef.current !== null && lastSelectedKeyRef.current !== messageKey) { const currentMsgs = useChatStore.getState().messages || [] - const idx1 = currentMsgs.findIndex(m => m.localId === lastSelectedIdRef.current) - const idx2 = currentMsgs.findIndex(m => m.localId === localId) + const idx1 = currentMsgs.findIndex(m => getMessageKey(m) === lastSelectedKeyRef.current) + const idx2 = currentMsgs.findIndex(m => getMessageKey(m) === messageKey) if (idx1 !== -1 && idx2 !== -1) { const start = Math.min(idx1, idx2) const end = Math.max(idx1, idx2) for (let i = start; i <= end; i++) { - next.add(currentMsgs[i].localId) + next.add(getMessageKey(currentMsgs[i])) } } } else { // Normal toggle - if (next.has(localId)) { - next.delete(localId) - lastSelectedIdRef.current = null // Reset last selection on uncheck? Or keep? Usually keep last interaction. + if (next.has(messageKey)) { + next.delete(messageKey) + lastSelectedKeyRef.current = null } else { - next.add(localId) - lastSelectedIdRef.current = localId + next.add(messageKey) + lastSelectedKeyRef.current = messageKey } } return next }) - }, []) + }, [getMessageKey]) const formatBatchDateLabel = useCallback((dateStr: string) => { const [y, m, d] = dateStr.split('-').map(Number) return `${y}年${m}月${d}日` }, []) + const clampContextMenuPosition = useCallback((x: number, y: number) => { + const viewportPadding = 12 + const estimatedMenuWidth = 180 + const estimatedMenuHeight = 188 + const maxLeft = Math.max(viewportPadding, window.innerWidth - estimatedMenuWidth - viewportPadding) + const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedMenuHeight - viewportPadding) + return { + x: Math.min(Math.max(x, viewportPadding), maxLeft), + y: Math.min(Math.max(y, viewportPadding), maxTop) + } + }, []) + // 消息右键菜单处理 const handleContextMenu = useCallback((e: React.MouseEvent, message: Message) => { e.preventDefault() + const nextPos = clampContextMenuPosition(e.clientX, e.clientY) setContextMenu({ - x: e.clientX, - y: e.clientY, + x: nextPos.x, + y: nextPos.y, message }) - }, []) + }, [clampContextMenuPosition]) // 关闭右键菜单 useEffect(() => { @@ -1818,11 +5712,12 @@ function ChatPage(_props: ChatPageProps) { // 执行单条删除动作 const performSingleDelete = async (msg: Message) => { try { - const dbPathHint = (msg as any)._db_path + const targetMessageKey = getMessageKey(msg) + const dbPathHint = msg._db_path const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, msg.localId, msg.createTime, dbPathHint) if (result.success) { const currentMessages = useChatStore.getState().messages || [] - const newMessages = currentMessages.filter(m => m.localId !== msg.localId) + const newMessages = currentMessages.filter(m => getMessageKey(m) !== targetMessageKey) useChatStore.getState().setMessages(newMessages) } else { alert('删除失败: ' + (result.error || '原因未知')) @@ -1884,7 +5779,7 @@ function ChatPage(_props: ChatPageProps) { if (result.success) { const currentMessages = useChatStore.getState().messages || [] const newMessages = currentMessages.map(m => { - if (m.localId === editingMessage.message.localId) { + if (getMessageKey(m) === getMessageKey(editingMessage.message)) { return { ...m, parsedContent: finalContent, content: finalContent, rawContent: finalContent } } return m @@ -1925,37 +5820,44 @@ function ChatPage(_props: ChatPageProps) { try { const currentMessages = useChatStore.getState().messages || [] - const selectedIds = Array.from(selectedMessages) - const deletedIds = new Set() + const selectedKeys = Array.from(selectedMessages) + const deletedKeys = new Set() - for (let i = 0; i < selectedIds.length; i++) { + for (let i = 0; i < selectedKeys.length; i++) { if (cancelDeleteRef.current) break - const id = selectedIds[i] - const msgObj = currentMessages.find(m => m.localId === id) - const dbPathHint = (msgObj as any)?._db_path + const key = selectedKeys[i] + const msgObj = currentMessages.find(m => getMessageKey(m) === key) + const dbPathHint = msgObj?._db_path const createTime = msgObj?.createTime || 0 + const localId = msgObj?.localId || 0 - try { - const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, id, createTime, dbPathHint) - if (result.success) { - deletedIds.add(id) - } - } catch (err) { - console.error(`删除消息 ${id} 失败:`, err) + if (!msgObj) { + setDeleteProgress({ current: i + 1, total: selectedKeys.length }) + continue } - setDeleteProgress({ current: i + 1, total: selectedIds.length }) + try { + const result = await (window as any).electronAPI.chat.deleteMessage(currentSessionId, localId, createTime, dbPathHint) + if (result.success) { + deletedKeys.add(key) + } + } catch (err) { + console.error(`删除消息 ${localId} 失败:`, err) + } + + setDeleteProgress({ current: i + 1, total: selectedKeys.length }) } - const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedIds.has(m.localId)) + const finalMessages = (useChatStore.getState().messages || []).filter(m => !deletedKeys.has(getMessageKey(m))) useChatStore.getState().setMessages(finalMessages) setIsSelectionMode(false) - setSelectedMessages(new Set()) + setSelectedMessages(new Set()) + lastSelectedKeyRef.current = null if (cancelDeleteRef.current) { - alert(`操作已中止。已删除 ${deletedIds.size} 条,剩余记录保留。`) + alert(`操作已中止。已删除 ${deletedKeys.size} 条,剩余记录保留。`) } } catch (e) { alert('批量删除出现错误: ' + String(e)) @@ -1967,8 +5869,91 @@ function ChatPage(_props: ChatPageProps) { } } + const messageVirtuosoComponents = useMemo(() => ({ + Header: () => ( + hasMoreMessages ? ( +
+ {isLoadingMore ? ( + <> + + 加载更多... + + ) : ( + 向上滚动加载更多 + )} +
+ ) : null + ), + Footer: () => ( + hasMoreLater ? ( +
+ {isLoadingMore ? ( + <> + + 正在加载后续消息... + + ) : ( + 向下滚动查看更新消息 + )} +
+ ) : null + ) + }), [hasMoreMessages, hasMoreLater, isLoadingMore]) + + const renderMessageListItem = useCallback((index: number, msg: Message) => { + if (!currentSession) return null + + const prevMsg = index > 0 ? messages[index - 1] : undefined + const showDateDivider = shouldShowDateDivider(msg, prevMsg) + const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) + const isSent = msg.isSend === 1 + const isSystem = isSystemMessage(msg.localType) + const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') + const messageKey = getMessageKey(msg) + + return ( +
+ {showDateDivider && ( +
+ {formatDateDivider(msg.createTime)} +
+ )} + +
+ ) + }, [ + messages, + highlightedMessageSet, + getMessageKey, + formatDateDivider, + currentSession, + myAvatarUrl, + myWxid, + isCurrentSessionGroup, + autoTranscribeVoiceEnabled, + handleRequireModelDownload, + handleContextMenu, + isSelectionMode, + selectedMessages, + handleToggleSelection + ]) + return ( -
+
{/* 自定义删除确认对话框 */} {deleteConfirm.show && (
@@ -2040,6 +6025,7 @@ function ChatPage(_props: ChatPageProps) {
)} {/* 左侧会话列表 */} + {!standaloneSessionWindow && (
handleSearch(e.target.value)} + onChange={(e) => { + handleSearch(e.target.value) + handleGlobalMsgSearch(e.target.value) + }} /> {searchKeyword && ( - )} @@ -2091,8 +6080,87 @@ function ChatPage(_props: ChatPageProps) {
)} + {/* 全局消息搜索结果 */} + {globalMsgQuery && ( +
+ {globalMsgSearchError ? ( +
+ +

{globalMsgSearchError}

+
+ ) : globalMsgResults.length > 0 ? ( + <> +
+ 聊天记录: + {globalMsgSearching && ( + + {globalMsgIsBackfilling + ? `补全中 ${globalMsgAuthoritativeSessionCount > 0 ? `(${globalMsgAuthoritativeSessionCount})` : ''}...` + : '搜索中...'} + + )} + {!globalMsgSearching && globalMsgSearchPhase === 'done' && ( + 已完成 + )} +
+
+ {groupedGlobalMsgResults.map(([sessionId, messages]) => { + const session = sessionLookupMap.get(sessionId) + const firstMsg = messages[0] + const count = messages.length + return ( +
{ + if (session) { + pendingInSessionSearchRef.current = { + sessionId, + keyword: globalMsgQuery, + firstMsgTime: firstMsg.createTime || 0, + results: messages + } + handleSelectSession(session) + } + }} + > + +
+
+ {session?.displayName || sessionId} +
+
+ +
+ {count > 1 && ( +
共 {count} 条相关聊天记录
+ )} +
+
+ ) + })} +
+ + ) : globalMsgSearching ? ( +
+ + {globalMsgSearchPhase === 'seed' ? '搜索中...' : '补全中...'} +
+ ) : ( +
+ +

未找到相关消息

+
+ )} +
+ )} + {/* ... (previous content) ... */} - {isLoadingSessions ? ( + {shouldShowSessionsSkeleton ? (
{[1, 2, 3, 4, 5].map(i => (
@@ -2109,30 +6177,36 @@ function ChatPage(_props: ChatPageProps) { {/* 普通会话列表 */}
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? ( -
{ - isScrollingRef.current = true - if (sessionScrollTimeoutRef.current) { - clearTimeout(sessionScrollTimeoutRef.current) - } - sessionScrollTimeoutRef.current = window.setTimeout(() => { - isScrollingRef.current = false - sessionScrollTimeoutRef.current = null - }, 200) - }} - > - {filteredSessions.map(session => ( + <> + {searchKeyword && ( +
联系人:
+ )} +
{ + isScrollingRef.current = true + if (sessionScrollTimeoutRef.current) { + clearTimeout(sessionScrollTimeoutRef.current) + } + sessionScrollTimeoutRef.current = window.setTimeout(() => { + isScrollingRef.current = false + sessionScrollTimeoutRef.current = null + }, 200) + }} + > + {filteredSessions.map(session => ( ))}
+ ) : (
@@ -2153,6 +6227,7 @@ function ChatPage(_props: ChatPageProps) { isActive={currentSessionId === session.username} onSelect={handleSelectSession} formatTime={formatSessionTime} + searchKeyword={searchKeyword} /> ))}
@@ -2165,12 +6240,10 @@ function ChatPage(_props: ChatPageProps) {
)} - -
+ )} - {/* 拖动调节条 */} -
+ {!standaloneSessionWindow &&
} {/* 右侧消息区域 */}
@@ -2181,16 +6254,16 @@ function ChatPage(_props: ChatPageProps) { src={currentSession.avatarUrl} name={currentSession.displayName || currentSession.username} size={40} - className={isGroupChat(currentSession.username) ? 'group session-avatar' : 'session-avatar'} + className={isCurrentSessionGroup ? 'group session-avatar' : 'session-avatar'} />

{currentSession.displayName || currentSession.username}

- {isGroupChat(currentSession.username) && ( + {isCurrentSessionGroup && (
群聊
)}
- {isGroupChat(currentSession.username) && ( + {!standaloneSessionWindow && isCurrentSessionGroup && ( )} - - - - + )} + {!standaloneSessionWindow && ( + + )} + {!standaloneSessionWindow && isCurrentSessionPrivateSnsSupported && ( + + )} + {!standaloneSessionWindow && ( + + )} + {!standaloneSessionWindow && ( + + )} +
+ +
+ {showJumpPopover && createPortal( +
+ setShowJumpPopover(false)} + onSelect={handleJumpDateSelect} + messageDates={messageDates} + hasLoadedMessageDates={hasLoadedMessageDates} + messageDateCounts={messageDateCounts} + loadingDates={loadingDates} + loadingDateCounts={loadingDateCounts} + style={{ position: 'static', top: 'auto', right: 'auto' }} + /> +
, + document.body + )} + - setShowJumpDialog(false)} - onSelect={(date) => { - if (!currentSessionId) return - const start = Math.floor(date.setHours(0, 0, 0, 0) / 1000) - const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000) - isDateJumpRef.current = true - setCurrentOffset(0) - setJumpStartTime(start) - setJumpEndTime(end) - loadMessages(currentSessionId, 0, start, end, true) - }} - messageDates={messageDates} - loadingDates={loadingDates} - /> - + {!shouldHideStandaloneDetailButton && ( + + )}
-
- {isLoadingMessages && !hasInitialMessages && ( + {isPreparingExportDialog && exportPrepareHint && ( +
+ + {exportPrepareHint} +
+ )} + + setChatSnsTimelineTarget(null)} + /> + + {/* 会话内搜索浮窗 */} + {showInSessionSearch && ( +
+
+ + handleInSessionSearch(e.target.value)} + className="search-input" + /> + {inSessionSearching && } + +
+ {inSessionQuery && ( +
+ {inSessionSearching + ? '搜索中...' + : inSessionSearchError + ? '搜索失败' + : `找到 ${inSessionResults.length} 条结果`} +
+ )} + {inSessionQuery && !inSessionSearching && inSessionSearchError && ( +
+ +

{inSessionSearchError}

+
+ )} + {inSessionResults.length > 0 && ( +
+ {inSessionResults.map((msg, i) => { + const resolvedSenderDisplayName = resolveSearchSenderDisplayName( + msg.senderDisplayName, + msg.senderUsername, + currentSessionId + ) + const resolvedSenderUsername = resolveSearchSenderUsernameFallback(msg.senderUsername) + const resolvedSenderAvatarUrl = normalizeSearchAvatarUrl(msg.senderAvatarUrl) + const resolvedCurrentSessionName = normalizeSearchIdentityText(currentSession?.displayName) || + resolveSearchSenderUsernameFallback(currentSession?.username) || + resolveSearchSenderUsernameFallback(currentSessionId) + const senderName = resolvedSenderDisplayName || ( + msg.isSend === 1 + ? '我' + : (isCurrentSessionPrivateSnsSupported + ? resolvedCurrentSessionName || (inSessionEnriching ? '加载中...' : '未知') + : resolvedSenderUsername || (inSessionEnriching ? '加载中...' : '未知成员')) + ) + const senderAvatar = resolvedSenderAvatarUrl || ( + msg.isSend === 1 + ? myAvatarUrl + : (isCurrentSessionPrivateSnsSupported ? normalizeSearchAvatarUrl(currentSession?.avatarUrl) : undefined) + ) + const senderAvatarLoading = inSessionEnriching && !senderAvatar + const previewText = (msg.parsedContent || msg.content || '').slice(0, 80) + const displayTime = msg.createTime + ? new Date(msg.createTime * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) + : '' + const resultKey = getMessageKey(msg) + + return ( +
handleInSessionResultJump(msg)}> +
+ +
+
+ {senderName} + {previewText} +
+ {displayTime} +
+ ) + })} +
+ )} + {inSessionQuery && !inSessionSearching && !inSessionSearchError && inSessionResults.length === 0 && ( +
+ +

未找到相关消息

+
+ )} +
+ )} + +
+ {standaloneSessionWindow && standaloneLoadStage !== 'ready' && ( +
+ + {standaloneLoadStage === 'connecting' ? '正在建立连接...' : '正在加载最近消息...'} + {connectionError && {connectionError}} +
+ )} + {isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
- 加载消息中... + {isSessionSwitching ? '切换会话中...' : '加载消息中...'}
)}
- {hasMoreMessages && ( -
- {isLoadingMore ? ( - <> - - 加载更多... - - ) : ( - 向上滚动加载更多 - )} -
- )} - - {!isLoadingMessages && messages.length === 0 && !hasMoreMessages && ( + {!isLoadingMessages && messages.length === 0 && !hasMoreMessages ? (
该联系人没有聊天记录
- )} - - {(messages || []).map((msg, index) => { - const prevMsg = index > 0 ? messages[index - 1] : undefined - const showDateDivider = shouldShowDateDivider(msg, prevMsg) - - // 显示时间:第一条消息,或者与上一条消息间隔超过5分钟 - const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) - const isSent = msg.isSend === 1 - const isSystem = isSystemMessage(msg.localType) - - // 系统消息居中显示 - const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') - - const messageKey = getMessageKey(msg) - return ( -
- {showDateDivider && ( -
- {formatDateDivider(msg.createTime)} -
- )} - -
- ) - })} - - {hasMoreLater && ( -
- {isLoadingMore ? ( - <> - - 正在加载后续消息... - - ) : ( - 向下滚动查看更新消息 - )} -
+ ) : ( + (atBottom || isMessageListAtBottomRef.current ? 'auto' : false)} + atBottomThreshold={80} + atBottomStateChange={handleMessageAtBottomStateChange} + atTopStateChange={handleMessageAtTopStateChange} + rangeChanged={handleMessageRangeChanged} + computeItemKey={(_, msg) => getMessageKey(msg)} + components={messageVirtuosoComponents} + itemContent={renderMessageListItem} + /> )} {/* 回到底部按钮 */} @@ -2398,6 +6561,98 @@ function ChatPage(_props: ChatPageProps) {
+ {/* 群成员面板 */} + {showGroupMembersPanel && isCurrentSessionGroup && ( +
+
+

群成员

+ +
+ +
+ 共 {groupPanelMembers.length} 人 +
+ + setGroupMemberSearchKeyword(event.target.value)} + placeholder="搜索成员" + /> +
+
+ + {isRefreshingGroupMembers && ( +
+ + 正在统计成员发言数... +
+ )} + {groupMembersError && groupPanelMembers.length > 0 && ( +
+ {groupMembersError} +
+ )} + + {isLoadingGroupMembers ? ( +
+ + {groupMembersLoadingHint || '加载群成员中...'} +
+ ) : groupMembersError && groupPanelMembers.length === 0 ? ( +
{groupMembersError}
+ ) : filteredGroupPanelMembers.length === 0 ? ( +
{groupMemberSearchKeyword.trim() ? '暂无匹配成员' : '暂无群成员数据'}
+ ) : ( +
+ {filteredGroupPanelMembers.map((member) => ( +
+
+ +
+
+ + {member.displayName || member.username} + +
+ {member.isOwner && ( + + 群主 + + )} + {member.isFriend && ( + + 好友 + + )} +
+
+ + {member.alias || member.username} + +
+
+ + {member.messageCountStatus === 'loading' + ? '统计中' + : member.messageCountStatus === 'failed' + ? '统计失败' + : `${member.messageCount.toLocaleString()} 条`} + +
+ ))} +
+ )} +
+ )} + {/* 会话详情面板 */} {showDetailPanel && (
@@ -2407,7 +6662,7 @@ function ChatPage(_props: ChatPageProps) {
- {isLoadingDetail ? ( + {isLoadingDetail && !sessionDetail ? (
加载中... @@ -2455,46 +6710,170 @@ function ChatPage(_props: ChatPageProps) {
- 消息统计 + 消息统计(导出口径) +
+
+ {isRefreshingDetailStats + ? '统计刷新中...' + : sessionDetail.statsUpdatedAt + ? `${sessionDetail.statsStale ? '缓存于' : '更新于'} ${formatYmdHmDateTime(sessionDetail.statsUpdatedAt)}${sessionDetail.statsStale ? '(将后台刷新)' : ''}` + : (isLoadingDetailExtra ? '统计加载中...' : '暂无统计缓存')}
消息总数 {Number.isFinite(sessionDetail.messageCount) ? sessionDetail.messageCount.toLocaleString() - : '—'} + : ((isLoadingDetail || isLoadingDetailExtra) ? '统计中...' : '—')}
- {sessionDetail.firstMessageTime && ( +
+ 语音 + + {Number.isFinite(sessionDetail.voiceMessages) + ? (sessionDetail.voiceMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 图片 + + {Number.isFinite(sessionDetail.imageMessages) + ? (sessionDetail.imageMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 视频 + + {Number.isFinite(sessionDetail.videoMessages) + ? (sessionDetail.videoMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 表情包 + + {Number.isFinite(sessionDetail.emojiMessages) + ? (sessionDetail.emojiMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 转账消息数 + + {Number.isFinite(sessionDetail.transferMessages) + ? (sessionDetail.transferMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 红包消息数 + + {Number.isFinite(sessionDetail.redPacketMessages) + ? (sessionDetail.redPacketMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 通话消息数 + + {Number.isFinite(sessionDetail.callMessages) + ? (sessionDetail.callMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+ {sessionDetail.wxid.includes('@chatroom') ? ( + <> +
+ 我发的消息数 + + {Number.isFinite(sessionDetail.groupMyMessages) + ? (sessionDetail.groupMyMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 群人数 + + {Number.isFinite(sessionDetail.groupMemberCount) + ? (sessionDetail.groupMemberCount as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 群发言人数 + + {Number.isFinite(sessionDetail.groupActiveSpeakers) + ? (sessionDetail.groupActiveSpeakers as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 群共同好友数 + + {sessionDetail.relationStatsLoaded + ? (Number.isFinite(sessionDetail.groupMutualFriends) + ? (sessionDetail.groupMutualFriends as number).toLocaleString() + : '—') + : ( + + )} + +
+ + ) : (
- - 首条消息 + 共同群聊数 - {Number.isFinite(sessionDetail.firstMessageTime) - ? new Date(sessionDetail.firstMessageTime * 1000).toLocaleDateString('zh-CN') - : '—'} - -
- )} - {sessionDetail.latestMessageTime && ( -
- - 最新消息 - - {Number.isFinite(sessionDetail.latestMessageTime) - ? new Date(sessionDetail.latestMessageTime * 1000).toLocaleDateString('zh-CN') - : '—'} + {sessionDetail.relationStatsLoaded + ? (Number.isFinite(sessionDetail.privateMutualGroups) + ? (sessionDetail.privateMutualGroups as number).toLocaleString() + : '—') + : ( + + )}
)} +
+ + 首条消息 + + {sessionDetail.firstMessageTime + ? formatYmdDateFromSeconds(sessionDetail.firstMessageTime) + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ + 最新消息 + + {sessionDetail.latestMessageTime + ? formatYmdDateFromSeconds(sessionDetail.latestMessageTime) + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
- {Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 && ( -
-
- - 数据库分布 -
+
+
+ + 数据库分布 +
+ {Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 ? (
{sessionDetail.messageTables.map((t, i) => (
@@ -2503,8 +6882,12 @@ function ChatPage(_props: ChatPageProps) {
))}
-
- )} + ) : ( +
+ {isLoadingDetailExtra ? '统计中...' : '暂无统计数据'} +
+ )} +
) : (
暂无详情
@@ -2516,7 +6899,8 @@ function ChatPage(_props: ChatPageProps) { ) : (
-

选择一个会话开始查看聊天记录

+

{standaloneSessionWindow ? '会话加载中或暂无会话记录' : '选择一个会话开始查看聊天记录'}

+ {standaloneSessionWindow && connectionError &&

{connectionError}

}
)}
@@ -2533,12 +6917,13 @@ function ChatPage(_props: ChatPageProps) { // 下载完成后,触发页面刷新让组件重新尝试转写 // 通过更新缓存触发组件重新检查 if (pendingVoiceTranscriptRequest) { - // 清除缓存中的请求标记,让组件可以重新尝试 - const cacheKey = `voice-transcript:${pendingVoiceTranscriptRequest.messageId}` // 不直接调用转写,而是让组件自己重试 // 通过触发一个自定义事件来通知所有 MessageBubble 组件 window.dispatchEvent(new CustomEvent('model-downloaded', { - detail: { messageId: pendingVoiceTranscriptRequest.messageId } + detail: { + sessionId: pendingVoiceTranscriptRequest.sessionId, + messageId: pendingVoiceTranscriptRequest.messageId + } })) } setPendingVoiceTranscriptRequest(null) @@ -2552,10 +6937,26 @@ function ChatPage(_props: ChatPageProps) {
e.stopPropagation()}>
-

批量语音转文字

+

{batchVoiceTaskTitle}

-

选择要转写的日期(仅显示有语音的日期),然后开始转写。

+

先选择任务类型,再选择日期(仅显示有语音的日期),然后开始处理。

+
+ + +
{batchVoiceDates.length > 0 && (
@@ -2590,12 +6991,16 @@ function ChatPage(_props: ChatPageProps) {
预计耗时: - 约 {Math.ceil(batchSelectedMessageCount * 2 / 60)} 分钟 + 约 {batchVoiceTaskMinutes} 分钟
- 批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。 + + {batchVoiceTaskType === 'decrypt' + ? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能。' + : '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。'} +
@@ -2604,7 +7009,7 @@ function ChatPage(_props: ChatPageProps) {
@@ -2708,14 +7113,16 @@ function ChatPage(_props: ChatPageProps) { {contextMenu && createPortal( <>
setContextMenu(null)} - style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 9998 }} /> + style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 12040 }} />
e.stopPropagation()} > @@ -2725,7 +7132,8 @@ function ChatPage(_props: ChatPageProps) {
{ setIsSelectionMode(true) - setSelectedMessages(new Set([contextMenu.message.localId])) + setSelectedMessages(new Set([getMessageKey(contextMenu.message)])) + lastSelectedKeyRef.current = getMessageKey(contextMenu.message) setContextMenu(null) }}> @@ -2735,11 +7143,143 @@ function ChatPage(_props: ChatPageProps) { 删除消息
+
{ setShowMessageInfo(contextMenu.message); setContextMenu(null) }}> + + 查看消息信息 +
, document.body )} + {/* 消息信息弹窗 */} + {showMessageInfo && createPortal( +
setShowMessageInfo(null)}> +
e.stopPropagation()}> +
+

消息详情

+ +
+
+
+
+ + Local ID + {showMessageInfo.localId} + +
+
+ + Server ID + {showMessageInfo.serverId} +
+
+ 消息类型 + {showMessageInfo.localType} +
+
+ 发送者 + {showMessageInfo.senderUsername || '-'} + {showMessageInfo.senderUsername && ( + + )} +
+
+ + 创建时间 + {new Date(showMessageInfo.createTime * 1000).toLocaleString()} +
+
+ 发送状态 + {showMessageInfo.isSend === 1 ? '发送' : '接收'} +
+
+ + {(showMessageInfo.imageMd5 || showMessageInfo.videoMd5 || showMessageInfo.voiceDurationSeconds != null) && ( +
+
+ + 媒体信息 +
+ {showMessageInfo.imageMd5 && ( +
+ Image MD5 + {showMessageInfo.imageMd5} + +
+ )} + {showMessageInfo.imageDatName && ( +
+ DAT 文件 + {showMessageInfo.imageDatName} +
+ )} + {showMessageInfo.videoMd5 && ( +
+ Video MD5 + {showMessageInfo.videoMd5} + +
+ )} + {showMessageInfo.voiceDurationSeconds != null && ( +
+ + 语音时长 + {showMessageInfo.voiceDurationSeconds}秒 +
+ )} +
+ )} + + {(showMessageInfo.emojiMd5 || showMessageInfo.emojiCdnUrl) && ( +
+
+ 表情包信息 +
+ {showMessageInfo.emojiMd5 && ( +
+ MD5 + {showMessageInfo.emojiMd5} +
+ )} + {showMessageInfo.emojiCdnUrl && ( +
+ CDN URL + {showMessageInfo.emojiCdnUrl} +
+ )} +
+ )} + + {showMessageInfo.localType !== 1 && (showMessageInfo.rawContent || showMessageInfo.content) && ( +
+
+ 原始消息内容 + +
+
+
{showMessageInfo.rawContent || showMessageInfo.content}
+
+
+ )} +
+
+
, + document.body + )} + {/* 修改消息弹窗 */} {editingMessage && createPortal(
@@ -2869,7 +7409,8 @@ function ChatPage(_props: ChatPageProps) { className="btn-secondary" onClick={() => { setIsSelectionMode(false) - setSelectedMessages(new Set()) + setSelectedMessages(new Set()) + lastSelectedKeyRef.current = null }} style={{ padding: '6px 16px', @@ -2916,9 +7457,40 @@ const emojiDataUrlCache = new Map() const imageDataUrlCache = new Map() const voiceDataUrlCache = new Map() const voiceTranscriptCache = new Map() +type SharedImageDecryptResult = { success: boolean; localPath?: string; liveVideoPath?: string; error?: string } +const imageDecryptInFlight = new Map>() const senderAvatarCache = new Map() const senderAvatarLoading = new Map>() +function getSharedImageDecryptTask( + key: string, + createTask: () => Promise +): Promise { + const existing = imageDecryptInFlight.get(key) + if (existing) return existing + const task = createTask().finally(() => { + if (imageDecryptInFlight.get(key) === task) { + imageDecryptInFlight.delete(key) + } + }) + imageDecryptInFlight.set(key, task) + return task +} + +const buildVoiceCacheIdentity = ( + sessionId: string, + message: Pick +): string => { + const normalizedSessionId = String(sessionId || '').trim() + const localId = Math.max(0, Math.floor(Number(message?.localId || 0))) + const createTime = Math.max(0, Math.floor(Number(message?.createTime || 0))) + const serverIdRaw = String(message?.serverIdRaw ?? message?.serverId ?? '').trim() + const serverId = /^\d+$/.test(serverIdRaw) + ? serverIdRaw.replace(/^0+(?=\d)/, '') + : String(Math.max(0, Math.floor(Number(serverIdRaw || 0)))) + return `${normalizedSessionId}:${localId}:${createTime}:${serverId || '0'}` +} + // 引用消息中的动画表情组件 function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) { const cacheKey = md5 || cdnUrl @@ -2947,10 +7519,13 @@ function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) { // 消息气泡组件 function MessageBubble({ message, + messageKey, session, showTime, myAvatarUrl, + myWxid, isGroupChat, + autoTranscribeVoiceEnabled, onRequireModelDownload, onContextMenu, isSelectionMode, @@ -2958,15 +7533,18 @@ function MessageBubble({ onToggleSelection }: { message: Message; + messageKey: string; session: ChatSession; showTime?: boolean; myAvatarUrl?: string; + myWxid?: string; isGroupChat?: boolean; + autoTranscribeVoiceEnabled?: boolean; onRequireModelDownload?: (sessionId: string, messageId: string) => void; onContextMenu?: (e: React.MouseEvent, message: Message) => void; isSelectionMode?: boolean; isSelected?: boolean; - onToggleSelection?: (localId: number, isShiftKey?: boolean) => void; + onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void; }) { const isSystem = isSystemMessage(message.localType) const isEmoji = message.localType === 47 @@ -2979,9 +7557,31 @@ function MessageBubble({ const isSent = message.isSend === 1 const [senderAvatarUrl, setSenderAvatarUrl] = useState(undefined) const [senderName, setSenderName] = useState(undefined) + const [quotedSenderName, setQuotedSenderName] = useState(undefined) + const [quoteLayout, setQuoteLayout] = useState('quote-top') + const senderProfileRequestSeqRef = useRef(0) const [emojiError, setEmojiError] = useState(false) const [emojiLoading, setEmojiLoading] = useState(false) + // 缓存相关的 state 必须在所有 Hooks 之前声明 + const cacheKey = message.emojiMd5 || message.emojiCdnUrl || '' + const [emojiLocalPath, setEmojiLocalPath] = useState( + () => emojiDataUrlCache.get(cacheKey) || message.emojiLocalPath + ) + const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}` + const [imageLocalPath, setImageLocalPath] = useState( + () => imageDataUrlCache.get(imageCacheKey) + ) + const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message) + const voiceCacheKey = `voice:${voiceIdentityKey}` + const [voiceDataUrl, setVoiceDataUrl] = useState( + () => voiceDataUrlCache.get(voiceCacheKey) + ) + const voiceTranscriptCacheKey = `voice-transcript:${voiceIdentityKey}` + const [voiceTranscript, setVoiceTranscript] = useState( + () => voiceTranscriptCache.get(voiceTranscriptCacheKey) + ) + // State variables... const [imageError, setImageError] = useState(false) const [imageLoading, setImageLoading] = useState(false) @@ -2990,11 +7590,17 @@ function MessageBubble({ const imageUpdateCheckedRef = useRef(null) const imageClickTimerRef = useRef(null) const imageContainerRef = useRef(null) + const emojiContainerRef = useRef(null) + const imageResizeBaselineRef = useRef(null) + const emojiResizeBaselineRef = useRef(null) + const imageObservedHeightRef = useRef(null) + const emojiObservedHeightRef = useRef(null) const imageAutoDecryptTriggered = useRef(false) const imageAutoHdTriggered = useRef(null) const [imageInView, setImageInView] = useState(false) const imageForceHdAttempted = useRef(null) const imageForceHdPending = useRef(false) + const imageDecryptPendingRef = useRef(false) const [imageLiveVideoPath, setImageLiveVideoPath] = useState(undefined) const [voiceError, setVoiceError] = useState(false) const [voiceLoading, setVoiceLoading] = useState(false) @@ -3003,12 +7609,17 @@ function MessageBubble({ const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false) const [voiceTranscriptError, setVoiceTranscriptError] = useState(false) const voiceTranscriptRequestedRef = useRef(false) - const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(true) const [voiceCurrentTime, setVoiceCurrentTime] = useState(0) const [voiceDuration, setVoiceDuration] = useState(0) const [voiceWaveform, setVoiceWaveform] = useState([]) const voiceAutoDecryptTriggered = useRef(false) + + const [systemAlert, setSystemAlert] = useState<{ + title: string; + message: React.ReactNode; + } | null>(null) + // 转账消息双方名称 const [transferPayerName, setTransferPayerName] = useState(undefined) const [transferReceiverName, setTransferReceiverName] = useState(undefined) @@ -3053,33 +7664,6 @@ function MessageBubble({ } }, [isVideo, message.videoMd5, message.content, message.parsedContent]) - // 加载自动转文字配置 - useEffect(() => { - const loadConfig = async () => { - const enabled = await configService.getAutoTranscribeVoice() - setAutoTranscribeVoice(enabled) - } - loadConfig() - }, []) - - // 从缓存获取表情包 data URL - const cacheKey = message.emojiMd5 || message.emojiCdnUrl || '' - const [emojiLocalPath, setEmojiLocalPath] = useState( - () => emojiDataUrlCache.get(cacheKey) || message.emojiLocalPath - ) - const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}` - const [imageLocalPath, setImageLocalPath] = useState( - () => imageDataUrlCache.get(imageCacheKey) - ) - const voiceCacheKey = `voice:${message.localId}` - const [voiceDataUrl, setVoiceDataUrl] = useState( - () => voiceDataUrlCache.get(voiceCacheKey) - ) - const voiceTranscriptCacheKey = `voice-transcript:${message.localId}` - const [voiceTranscript, setVoiceTranscript] = useState( - () => voiceTranscriptCache.get(voiceTranscriptCacheKey) - ) - const formatTime = (timestamp: number): string => { if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间' const date = new Date(timestamp * 1000) @@ -3108,12 +7692,108 @@ function MessageBubble({ return 'image/jpeg' }, []) - // 获取头像首字母 - const getAvatarLetter = (name: string): string => { - if (!name) return '?' - const chars = [...name] - return chars[0] || '?' - } + const getImageObserverRoot = useCallback((): Element | null => { + return imageContainerRef.current?.closest('.message-list') ?? null + }, []) + + const stabilizeScrollerByDelta = useCallback((host: HTMLElement | null, delta: number) => { + if (!host) return + if (!Number.isFinite(delta) || Math.abs(delta) < 1) return + const scroller = host.closest('.message-list') as HTMLDivElement | null + if (!scroller) return + + const distanceFromBottom = scroller.scrollHeight - (scroller.scrollTop + scroller.clientHeight) + if (distanceFromBottom <= 96) return + + const scrollerRect = scroller.getBoundingClientRect() + const hostRect = host.getBoundingClientRect() + const hostTopInScroller = hostRect.top - scrollerRect.top + scroller.scrollTop + const viewportBottom = scroller.scrollTop + scroller.clientHeight + if (hostTopInScroller > viewportBottom + 24) return + + scroller.scrollTop += delta + }, []) + + const bindResizeObserverForHost = useCallback(( + host: HTMLElement | null, + observedHeightRef: React.MutableRefObject, + pendingBaselineRef: React.MutableRefObject + ) => { + if (!host) return + + const initialHeight = host.getBoundingClientRect().height + observedHeightRef.current = Number.isFinite(initialHeight) && initialHeight > 0 ? initialHeight : null + if (typeof ResizeObserver === 'undefined') return + + const observer = new ResizeObserver(() => { + const nextHeight = host.getBoundingClientRect().height + if (!Number.isFinite(nextHeight) || nextHeight <= 0) { + observedHeightRef.current = null + return + } + const previousHeight = observedHeightRef.current + observedHeightRef.current = nextHeight + if (!Number.isFinite(previousHeight) || (previousHeight as number) <= 0) return + if (pendingBaselineRef.current !== null) return + stabilizeScrollerByDelta(host, nextHeight - (previousHeight as number)) + }) + + observer.observe(host) + return () => { + observer.disconnect() + } + }, [stabilizeScrollerByDelta]) + + const captureResizeBaseline = useCallback( + (host: HTMLElement | null, baselineRef: React.MutableRefObject) => { + if (!host) return + const height = host.getBoundingClientRect().height + if (!Number.isFinite(height) || height <= 0) return + baselineRef.current = height + }, + [] + ) + + const stabilizeScrollAfterResize = useCallback( + (host: HTMLElement | null, baselineRef: React.MutableRefObject) => { + if (!host) return + const baseline = baselineRef.current + baselineRef.current = null + if (!Number.isFinite(baseline) || (baseline as number) <= 0) return + + requestAnimationFrame(() => { + const nextHeight = host.getBoundingClientRect().height + stabilizeScrollerByDelta(host, nextHeight - (baseline as number)) + }) + }, + [stabilizeScrollerByDelta] + ) + + const captureImageResizeBaseline = useCallback(() => { + captureResizeBaseline(imageContainerRef.current, imageResizeBaselineRef) + }, [captureResizeBaseline]) + + const captureEmojiResizeBaseline = useCallback(() => { + captureResizeBaseline(emojiContainerRef.current, emojiResizeBaselineRef) + }, [captureResizeBaseline]) + + const stabilizeImageScrollAfterResize = useCallback(() => { + stabilizeScrollAfterResize(imageContainerRef.current, imageResizeBaselineRef) + }, [stabilizeScrollAfterResize]) + + const stabilizeEmojiScrollAfterResize = useCallback(() => { + stabilizeScrollAfterResize(emojiContainerRef.current, emojiResizeBaselineRef) + }, [stabilizeScrollAfterResize]) + + useEffect(() => { + if (!isImage) return + return bindResizeObserverForHost(imageContainerRef.current, imageObservedHeightRef, imageResizeBaselineRef) + }, [isImage, imageLocalPath, imageLoading, imageError, bindResizeObserverForHost]) + + useEffect(() => { + if (!isEmoji) return + return bindResizeObserverForHost(emojiContainerRef.current, emojiObservedHeightRef, emojiResizeBaselineRef) + }, [isEmoji, emojiLocalPath, emojiLoading, emojiError, bindResizeObserverForHost]) // 下载表情包 const downloadEmoji = () => { @@ -3122,6 +7802,7 @@ function MessageBubble({ // 先检查缓存 const cached = emojiDataUrlCache.get(cacheKey) if (cached) { + captureEmojiResizeBaseline() setEmojiLocalPath(cached) setEmojiError(false) return @@ -3132,6 +7813,7 @@ function MessageBubble({ window.electronAPI.chat.downloadEmoji(message.emojiCdnUrl, message.emojiMd5).then((result: { success: boolean; localPath?: string; error?: string }) => { if (result.success && result.localPath) { emojiDataUrlCache.set(cacheKey, result.localPath) + captureEmojiResizeBaseline() setEmojiLocalPath(result.localPath) } else { setEmojiError(true) @@ -3145,37 +7827,55 @@ function MessageBubble({ // 群聊中获取发送者信息 (如果自己发的没头像,也尝试拉取) useEffect(() => { - if (message.senderUsername && (isGroupChat || (isSent && !myAvatarUrl))) { - const sender = message.senderUsername - const cached = senderAvatarCache.get(sender) - if (cached) { - setSenderAvatarUrl(cached.avatarUrl) - setSenderName(cached.displayName) - return - } - const pending = senderAvatarLoading.get(sender) - if (pending) { - pending.then((result: { avatarUrl?: string; displayName?: string } | null) => { - if (result) { - setSenderAvatarUrl(result.avatarUrl) - setSenderName(result.displayName) - } - }) - return - } - const request = window.electronAPI.chat.getContactAvatar(sender) - senderAvatarLoading.set(sender, request) - request.then((result: { avatarUrl?: string; displayName?: string } | null) => { - if (result) { - senderAvatarCache.set(sender, result) - setSenderAvatarUrl(result.avatarUrl) - setSenderName(result.displayName) - } - }).catch(() => { }).finally(() => { - senderAvatarLoading.delete(sender) - }) + const sender = String(message.senderUsername || '').trim() + const cached = sender ? senderAvatarCache.get(sender) : undefined + setSenderAvatarUrl(cached?.avatarUrl || message.senderAvatarUrl || undefined) + setSenderName(cached?.displayName || message.senderDisplayName || undefined) + + if (!sender || !(isGroupChat || (isSent && !myAvatarUrl))) return + + const requestSeq = senderProfileRequestSeqRef.current + 1 + senderProfileRequestSeqRef.current = requestSeq + let cancelled = false + const applyProfile = (result: { avatarUrl?: string; displayName?: string } | null) => { + if (!result || cancelled) return + if (requestSeq !== senderProfileRequestSeqRef.current) return + if (result.avatarUrl) setSenderAvatarUrl(result.avatarUrl) + if (result.displayName) setSenderName(result.displayName) } - }, [isGroupChat, isSent, message.senderUsername, myAvatarUrl]) + + if (cached) { + applyProfile(cached) + return () => { + cancelled = true + } + } + + const pending = senderAvatarLoading.get(sender) + if (pending) { + pending.then(applyProfile).catch(() => { }) + return () => { + cancelled = true + } + } + + const request = window.electronAPI.chat.getContactAvatar(sender) + senderAvatarLoading.set(sender, request) + request.then((result: { avatarUrl?: string; displayName?: string } | null) => { + if (result) { + senderAvatarCache.set(sender, result) + } + applyProfile(result) + }).catch(() => { }).finally(() => { + if (senderAvatarLoading.get(sender) === request) { + senderAvatarLoading.delete(sender) + } + }) + + return () => { + cancelled = true + } + }, [isGroupChat, isSent, message.senderAvatarUrl, message.senderDisplayName, message.senderUsername, myAvatarUrl]) // 解析转账消息的付款方和收款方显示名称 useEffect(() => { @@ -3202,31 +7902,39 @@ function MessageBubble({ if (emojiLocalPath) return // 后端已从本地缓存找到文件(转发表情包无 CDN URL 的情况) if (isEmoji && message.emojiLocalPath && !emojiLocalPath) { + captureEmojiResizeBaseline() setEmojiLocalPath(message.emojiLocalPath) return } if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) { downloadEmoji() } - }, [isEmoji, message.emojiCdnUrl, message.emojiLocalPath, emojiLocalPath, emojiLoading, emojiError]) + }, [isEmoji, message.emojiCdnUrl, message.emojiLocalPath, emojiLocalPath, emojiLoading, emojiError, captureEmojiResizeBaseline]) - const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => { - if (!isImage) return - if (imageLoading) return + const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false): Promise => { + if (!isImage) return { success: false } + if (imageDecryptPendingRef.current) return { success: false } + imageDecryptPendingRef.current = true if (!silent) { setImageLoading(true) setImageError(false) } try { if (message.imageMd5 || message.imageDatName) { - const result = await window.electronAPI.image.decrypt({ - sessionId: session.username, - imageMd5: message.imageMd5 || undefined, - imageDatName: message.imageDatName, - force: forceUpdate + const sharedDecryptKey = `${session.username}:${imageCacheKey}:${forceUpdate ? 'force' : 'normal'}` + const result = await getSharedImageDecryptTask(sharedDecryptKey, async () => { + return await window.electronAPI.image.decrypt({ + sessionId: session.username, + imageMd5: message.imageMd5 || undefined, + imageDatName: message.imageDatName, + force: forceUpdate + }) as SharedImageDecryptResult }) if (result.success && result.localPath) { imageDataUrlCache.set(imageCacheKey, result.localPath) + if (imageLocalPath !== result.localPath) { + captureImageResizeBaseline() + } setImageLocalPath(result.localPath) setImageHasUpdate(false) if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath) @@ -3239,18 +7947,22 @@ function MessageBubble({ const mime = detectImageMimeFromBase64(fallback.data) const dataUrl = `data:${mime};base64,${fallback.data}` imageDataUrlCache.set(imageCacheKey, dataUrl) + if (imageLocalPath !== dataUrl) { + captureImageResizeBaseline() + } setImageLocalPath(dataUrl) setImageHasUpdate(false) - return { success: true, localPath: dataUrl } as any + return { success: true, localPath: dataUrl } } if (!silent) setImageError(true) } catch { if (!silent) setImageError(true) } finally { if (!silent) setImageLoading(false) + imageDecryptPendingRef.current = false } - return { success: false } as any - }, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64]) + return { success: false } + }, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline]) const triggerForceHd = useCallback(() => { if (!message.imageMd5 && !message.imageDatName) return @@ -3271,13 +7983,13 @@ function MessageBubble({ imageClickTimerRef.current = window.setTimeout(() => { setImageClicked(false) }, 800) - console.info('[UI] image decrypt click', { + console.info('[UI] image decrypt click (force HD)', { sessionId: session.username, imageMd5: message.imageMd5, imageDatName: message.imageDatName, localId: message.localId }) - void requestImageDecrypt() + void requestImageDecrypt(true) }, [message.imageDatName, message.imageMd5, message.localId, requestImageDecrypt, session.username]) const handleOpenImageViewer = useCallback(async () => { @@ -3286,8 +7998,9 @@ function MessageBubble({ let finalImagePath = imageLocalPath let finalLiveVideoPath = imageLiveVideoPath || undefined - // If current cache is a thumbnail, wait for a silent force-HD decrypt before opening viewer. - if (imageHasUpdate) { + // Every explicit preview click re-runs the forced HD search/decrypt path so + // users don't need to re-enter the session after WeChat materializes a new original image. + if (message.imageMd5 || message.imageDatName) { try { const upgraded = await requestImageDecrypt(true, true) if (upgraded?.success && upgraded.localPath) { @@ -3310,6 +8023,9 @@ function MessageBubble({ finalImagePath = resolved.localPath finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath imageDataUrlCache.set(imageCacheKey, resolved.localPath) + if (imageLocalPath !== resolved.localPath) { + captureImageResizeBaseline() + } setImageLocalPath(resolved.localPath) if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath) setImageHasUpdate(Boolean(resolved.hasUpdate)) @@ -3319,10 +8035,10 @@ function MessageBubble({ void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath) }, [ - imageHasUpdate, imageLiveVideoPath, imageLocalPath, imageCacheKey, + captureImageResizeBaseline, message.imageDatName, message.imageMd5, requestImageDecrypt, @@ -3352,6 +8068,7 @@ function MessageBubble({ if (result.success && result.localPath) { imageDataUrlCache.set(imageCacheKey, result.localPath) if (!imageLocalPath || imageLocalPath !== result.localPath) { + captureImageResizeBaseline() setImageLocalPath(result.localPath) setImageError(false) } @@ -3362,7 +8079,7 @@ function MessageBubble({ return () => { cancelled = true } - }, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username]) + }, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline]) useEffect(() => { if (!isImage) return @@ -3390,15 +8107,21 @@ function MessageBubble({ (payload.imageMd5 && payload.imageMd5 === message.imageMd5) || (payload.imageDatName && payload.imageDatName === message.imageDatName) if (matchesCacheKey) { - imageDataUrlCache.set(imageCacheKey, payload.localPath) - setImageLocalPath(payload.localPath) + const cachedPath = imageDataUrlCache.get(imageCacheKey) + if (cachedPath !== payload.localPath) { + imageDataUrlCache.set(imageCacheKey, payload.localPath) + } + if (imageLocalPath !== payload.localPath) { + captureImageResizeBaseline() + } + setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath)) setImageError(false) } }) return () => { unsubscribe?.() } - }, [isImage, imageCacheKey, message.imageDatName, message.imageMd5]) + }, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline]) // 图片进入视野前自动解密(懒加载) useEffect(() => { @@ -3412,34 +8135,25 @@ function MessageBubble({ const observer = new IntersectionObserver( (entries) => { const entry = entries[0] - // rootMargin 设置为 200px,提前触发解密 - if (entry.isIntersecting && !imageAutoDecryptTriggered.current) { - imageAutoDecryptTriggered.current = true - void requestImageDecrypt() - } - }, - { rootMargin: '200px', threshold: 0 } - ) - - observer.observe(container) - return () => observer.disconnect() - }, [isImage, imageLocalPath, message.imageMd5, message.imageDatName, requestImageDecrypt]) - - // 进入视野时自动尝试切换高清图 - useEffect(() => { - if (!isImage) return - const container = imageContainerRef.current - if (!container) return - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0] + // rootMargin 设置为 200px,提前感知即将进入视野的图片 setImageInView(entry.isIntersecting) }, - { rootMargin: '120px', threshold: 0 } + { root: getImageObserverRoot(), rootMargin: '200px', threshold: 0 } ) + observer.observe(container) return () => observer.disconnect() - }, [isImage]) + }, [getImageObserverRoot, isImage]) + + // 进入视野后自动触发一次普通解密 + useEffect(() => { + if (!isImage || !imageInView) return + if (imageLocalPath || imageLoading) return + if (!message.imageMd5 && !message.imageDatName) return + if (imageAutoDecryptTriggered.current) return + imageAutoDecryptTriggered.current = true + void requestImageDecrypt() + }, [isImage, imageInView, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, requestImageDecrypt]) useEffect(() => { if (!isImage || !imageHasUpdate || !imageInView) return @@ -3448,19 +8162,6 @@ function MessageBubble({ triggerForceHd() }, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd]) - useEffect(() => { - if (!isImage || !imageHasUpdate) return - if (imageAutoHdTriggered.current === imageCacheKey) return - imageAutoHdTriggered.current = imageCacheKey - triggerForceHd() - }, [isImage, imageHasUpdate, imageCacheKey, triggerForceHd]) - - // 更激进:进入视野/打开预览时,无论 hasUpdate 与否都尝试强制高清 - useEffect(() => { - if (!isImage || !imageInView) return - triggerForceHd() - }, [isImage, imageInView, triggerForceHd]) - useEffect(() => { if (!isVoice) return @@ -3562,14 +8263,16 @@ function MessageBubble({ // 监听流式转写结果 useEffect(() => { if (!isVoice) return - const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { msgId: string; text: string }) => { - if (payload.msgId === String(message.localId)) { - setVoiceTranscript(payload.text) - voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text) - } + const removeListener = window.electronAPI.chat.onVoiceTranscriptPartial?.((payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => { + const sameSession = !payload.sessionId || payload.sessionId === session.username + const sameMsgId = payload.msgId === String(message.localId) + const sameCreateTime = payload.createTime == null || Number(payload.createTime) === Number(message.createTime || 0) + if (!sameSession || !sameMsgId || !sameCreateTime) return + setVoiceTranscript(payload.text) + voiceTranscriptCache.set(voiceTranscriptCacheKey, payload.text) }) return () => removeListener?.() - }, [isVoice, message.localId, voiceTranscriptCacheKey]) + }, [isVoice, message.createTime, message.localId, session.username, voiceTranscriptCacheKey]) const requestVoiceTranscript = useCallback(async () => { if (voiceTranscriptLoading || voiceTranscriptRequestedRef.current) return @@ -3596,9 +8299,9 @@ function MessageBubble({ } const result = await window.electronAPI.chat.getVoiceTranscript( - session.username, - String(message.localId), - message.createTime + session.username, + String(message.localId), + message.createTime ) if (result.success) { @@ -3606,6 +8309,21 @@ function MessageBubble({ voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText) setVoiceTranscript(transcriptText) } else { + if (result.error === 'SEGFAULT_ERROR') { + console.warn('[ChatPage] 捕获到语音引擎底层段错误'); + + setSystemAlert({ + title: '引擎崩溃提示', + message: ( + <> + 语音识别引擎发生底层崩溃 (Segmentation Fault)。

+ 如果您使用的是 Linux 等自定义程度较高的系统,请检查 sherpa-onnx 的相关系统动态链接库 (如 glibc 等) 是否兼容。 + + ) + }); + + } + setVoiceTranscriptError(true) voiceTranscriptRequestedRef.current = false } @@ -3623,14 +8341,17 @@ function MessageBubble({ } finally { setVoiceTranscriptLoading(false) } - }, [message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload]) + }, [message.createTime, message.localId, session.username, voiceTranscriptCacheKey, voiceTranscriptLoading, onRequireModelDownload]) // 监听模型下载完成事件 useEffect(() => { if (!isVoice) return const handleModelDownloaded = (event: CustomEvent) => { - if (event.detail?.messageId === String(message.localId)) { + if ( + event.detail?.messageId === String(message.localId) && + (!event.detail?.sessionId || event.detail?.sessionId === session.username) + ) { // 重置状态,允许重新尝试转写 voiceTranscriptRequestedRef.current = false setVoiceTranscriptError(false) @@ -3643,7 +8364,7 @@ function MessageBubble({ return () => { window.removeEventListener('model-downloaded', handleModelDownloaded as EventListener) } - }, [isVoice, message.localId, requestVoiceTranscript]) + }, [isVoice, message.localId, requestVoiceTranscript, session.username]) // 视频懒加载 const videoAutoLoadTriggered = useRef(false) @@ -3711,93 +8432,23 @@ function MessageBubble({ void requestVideoInfo() }, [isVideo, isVideoVisible, videoInfo, requestVideoInfo]) - - // 根据设置决定是否自动转写 - const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false) - useEffect(() => { - window.electronAPI.config.get('autoTranscribeVoice').then((value: unknown) => { - setAutoTranscribeEnabled(value === true) - }) - }, []) - - useEffect(() => { - if (!autoTranscribeEnabled) return + if (!autoTranscribeVoiceEnabled) return if (!isVoice) return if (!voiceDataUrl) return - if (!autoTranscribeVoice) return // 如果自动转文字已关闭,不自动转文字 if (voiceTranscriptError) return if (voiceTranscriptLoading || voiceTranscript !== undefined || voiceTranscriptRequestedRef.current) return void requestVoiceTranscript() - }, [autoTranscribeEnabled, isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript]) - - // Selection mode handling removed from here to allow normal rendering - // We will wrap the output instead - - // Regular rendering logic... - if (isSystem) { - return ( -
onContextMenu?.(e, message)} - style={{ cursor: isSelectionMode ? 'pointer' : 'default', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }} - onClick={(e) => { - if (isSelectionMode) { - e.stopPropagation() - onToggleSelection?.(message.localId, e.shiftKey) - } - }} - > - {isSelectionMode && ( -
- {isSelected && } -
- )} -
{message.parsedContent}
-
- ) - } - - // 检测是否为链接卡片消息 - const isLinkMessage = String(message.localType) === '21474836529' || - (message.rawContent && (message.rawContent.includes(' 0 + }, [autoTranscribeVoiceEnabled, isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript]) // 去除企业微信 ID 前缀 - const cleanMessageContent = (content: string) => { + const cleanMessageContent = useCallback((content: string) => { if (!content) return '' return content.replace(/^[a-zA-Z0-9]+@openim:\n?/, '') - } + }, []) // 解析混合文本和表情 - const renderTextWithEmoji = (text: string) => { + const renderTextWithEmoji = useCallback((text: string) => { if (!text) return text const parts = text.split(/\[(.*?)\]/g) return parts.map((part, index) => { @@ -3821,6 +8472,202 @@ function MessageBubble({ } return part }) + }, []) + + const cleanedParsedContent = useMemo( + () => cleanMessageContent(message.parsedContent || ''), + [cleanMessageContent, message.parsedContent] + ) + + const appMsgRawXml = message.rawContent || message.parsedContent || '' + const appMsgContainsTag = useMemo( + () => appMsgRawXml.includes(' { + if (!appMsgContainsTag) return null + try { + const start = appMsgRawXml.indexOf('') + const xml = start >= 0 ? appMsgRawXml.slice(start) : appMsgRawXml + const doc = new DOMParser().parseFromString(xml, 'text/xml') + if (doc.querySelector('parsererror')) return null + return doc + } catch { + return null + } + }, [appMsgContainsTag, appMsgRawXml]) + const appMsgTextCache = useMemo(() => new Map(), [appMsgDoc]) + const queryAppMsgText = useCallback((selector: string): string => { + const cached = appMsgTextCache.get(selector) + if (cached !== undefined) return cached + const value = appMsgDoc?.querySelector(selector)?.textContent?.trim() || '' + appMsgTextCache.set(selector, value) + return value + }, [appMsgDoc, appMsgTextCache]) + const quotedSenderUsername = resolveQuotedSenderUsername( + queryAppMsgText('refermsg > fromusr'), + queryAppMsgText('refermsg > chatusr') + ) + const quotedContent = message.quotedContent || queryAppMsgText('refermsg > content') || '' + const quotedSenderFallbackName = useMemo( + () => resolveQuotedSenderFallbackDisplayName( + session.username, + quotedSenderUsername, + message.quotedSender || queryAppMsgText('refermsg > displayname') || '' + ), + [message.quotedSender, queryAppMsgText, quotedSenderUsername, session.username] + ) + + useEffect(() => { + let cancelled = false + const nextFallbackName = quotedSenderFallbackName || undefined + setQuotedSenderName(nextFallbackName) + + if (!quotedContent || !quotedSenderUsername) { + return () => { + cancelled = true + } + } + + void resolveQuotedSenderDisplayName({ + sessionId: session.username, + senderUsername: quotedSenderUsername, + fallbackDisplayName: nextFallbackName, + isGroupChat, + myWxid + }).then((resolvedName) => { + if (cancelled) return + setQuotedSenderName(resolvedName || nextFallbackName) + }) + + return () => { + cancelled = true + } + }, [ + quotedContent, + quotedSenderFallbackName, + quotedSenderUsername, + session.username, + isGroupChat, + myWxid + ]) + + useEffect(() => { + let cancelled = false + void configService.getQuoteLayout().then((layout) => { + if (!cancelled) setQuoteLayout(layout) + }).catch(() => { + if (!cancelled) setQuoteLayout('quote-top') + }) + return () => { + cancelled = true + } + }, []) + + const locationMessageMeta = useMemo(() => { + if (message.localType !== 48) return null + const raw = message.rawContent || '' + const poiname = raw.match(/poiname="([^"]*)"/)?.[1] || message.locationPoiname || '位置' + const label = raw.match(/label="([^"]*)"/)?.[1] || message.locationLabel || '' + const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || String(message.locationLat || 0)) + const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || String(message.locationLng || 0)) + const zoom = 15 + const tileX = Math.floor((lng + 180) / 360 * Math.pow(2, zoom)) + const latRad = lat * Math.PI / 180 + const tileY = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * Math.pow(2, zoom)) + const mapTileUrl = (lat && lng) + ? `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${tileX}&y=${tileY}&z=${zoom}` + : '' + return { poiname, label, lat, lng, mapTileUrl } + }, [message.localType, message.rawContent, message.locationPoiname, message.locationLabel, message.locationLat, message.locationLng]) + + // 检测是否为链接卡片消息 + const isLinkMessage = String(message.localType) === '21474836529' || appMsgContainsTag + const bubbleClass = isSent ? 'sent' : 'received' + + // 头像逻辑: + // - 自己发的:优先使用 myAvatarUrl,缺失则用 senderAvatarUrl (补救) + // - 群聊中对方发的:使用发送者头像 + // - 私聊中对方发的:使用会话头像 + const fallbackSenderName = String(message.senderDisplayName || message.senderUsername || '').trim() || undefined + const resolvedSenderName = senderName || fallbackSenderName + const resolvedSenderAvatarUrl = senderAvatarUrl || message.senderAvatarUrl + const avatarUrl = isSent + ? (myAvatarUrl || resolvedSenderAvatarUrl) + : (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl) + + // 是否有引用消息 + const hasQuote = quotedContent.length > 0 + const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName + const renderBubbleWithQuote = useCallback((quotedNode: React.ReactNode, messageNode: React.ReactNode) => { + const quoteFirst = quoteLayout !== 'quote-bottom' + return ( +
+ {quoteFirst ? ( + <> + {quotedNode} + {messageNode} + + ) : ( + <> + {messageNode} + {quotedNode} + + )} +
+ ) + }, [quoteLayout]) + + const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => ( +
+ {displayQuotedSenderName && {displayQuotedSenderName}} + {contentNode} +
+ ), [displayQuotedSenderName]) + + const handlePlayVideo = useCallback(async () => { + if (!videoInfo?.videoUrl) return + try { + await window.electronAPI.window.openVideoPlayerWindow(videoInfo.videoUrl) + } catch (e) { + console.error('打开视频播放窗口失败:', e) + } + }, [videoInfo?.videoUrl]) + + // Selection mode handling removed from here to allow normal rendering + // We will wrap the output instead + if (isSystem) { + return ( +
onContextMenu?.(e, message)} + style={{ cursor: isSelectionMode ? 'pointer' : 'default', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }} + onClick={(e) => { + if (isSelectionMode) { + e.stopPropagation() + onToggleSelection?.(messageKey, e.shiftKey) + } + }} + > + {isSelectionMode && ( +
+ {isSelected && } +
+ )} +
{message.parsedContent}
+
+ ) } // 渲染消息内容 @@ -3851,8 +8698,14 @@ function MessageBubble({ alt="图片" className="image-message" onClick={() => { void handleOpenImageViewer() }} - onLoad={() => setImageError(false)} - onError={() => setImageError(true)} + onLoad={() => { + setImageError(false) + stabilizeImageScrollAfterResize() + }} + onError={() => { + imageResizeBaselineRef.current = null + setImageError(true) + }} /> {imageLiveVideoPath && (
@@ -3868,15 +8721,6 @@ function MessageBubble({ // 视频消息 if (isVideo) { - const handlePlayVideo = useCallback(async () => { - if (!videoInfo?.videoUrl) return - try { - await window.electronAPI.window.openVideoPlayerWindow(videoInfo.videoUrl) - } catch (e) { - console.error('打开视频播放窗口失败:', e) - } - }, [videoInfo?.videoUrl]) - // 未进入可视区域时显示占位符 if (!isVideoVisible) { return ( @@ -3965,7 +8809,7 @@ function MessageBubble({ session.username, String(message.localId), message.createTime, - message.serverId + message.serverIdRaw || message.serverId ) if (result.success && result.data) { const url = `data:audio/wav;base64,${result.data}` @@ -4153,18 +8997,8 @@ function MessageBubble({ // 位置消息 if (message.localType === 48) { - const raw = message.rawContent || '' - const poiname = raw.match(/poiname="([^"]*)"/)?.[1] || message.locationPoiname || '位置' - const label = raw.match(/label="([^"]*)"/)?.[1] || message.locationLabel || '' - const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || String(message.locationLat || 0)) - const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || String(message.locationLng || 0)) - const zoom = 15 - const tileX = Math.floor((lng + 180) / 360 * Math.pow(2, zoom)) - const latRad = lat * Math.PI / 180 - const tileY = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * Math.pow(2, zoom)) - const mapTileUrl = (lat && lng) - ? `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${tileX}&y=${tileY}&z=${zoom}` - : '' + if (!locationMessageMeta) return null + const { poiname, label, lat, lng, mapTileUrl } = locationMessageMeta return (
window.electronAPI.shell.openExternal(`https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(poiname || label)}`)}>
@@ -4190,30 +9024,16 @@ function MessageBubble({ // 链接消息 (AppMessage) const appMsgRichPreview = (() => { - const rawXml = message.rawContent || '' - if (!rawXml || (!rawXml.includes(' { - if (doc) return doc - try { - const start = rawXml.indexOf('') - const xml = start >= 0 ? rawXml.slice(start) : rawXml - doc = new DOMParser().parseFromString(xml, 'text/xml') - } catch { - doc = null - } - return doc - } - const q = (selector: string) => getDoc()?.querySelector(selector)?.textContent?.trim() || '' + const rawXml = appMsgRawXml + if (!appMsgContainsTag) return null + const q = queryAppMsgText const xmlType = message.xmlType || q('appmsg > type') || q('type') // type 57: 引用回复消息,解析 refermsg 渲染为引用样式 if (xmlType === '57') { - const replyText = q('title') || cleanMessageContent(message.parsedContent) || '' + const replyText = q('title') || cleanedParsedContent || '' const referContent = q('refermsg > content') || '' - const referSender = q('refermsg > displayname') || '' const referType = q('refermsg > type') || '' // 根据被引用消息类型渲染对应内容 @@ -4243,17 +9063,14 @@ function MessageBubble({ } return ( -
-
- {referSender && {referSender}} - {renderReferContent()} -
+ renderBubbleWithQuote( + renderQuotedMessageBlock(renderReferContent()),
{renderTextWithEmoji(cleanMessageContent(replyText))}
-
+ ) ) } - const title = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || 'Card' + const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card' const desc = message.appMsgDesc || q('des') const url = message.linkUrl || q('url') const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl') @@ -4288,12 +9105,7 @@ function MessageBubble({ // 对视频号提取真实标题,避免出现 "当前版本不支持该内容" let displayTitle = title if (kind === 'finder' && (!displayTitle || displayTitle.includes('不支持'))) { - try { - const d = new DOMParser().parseFromString(rawXml, 'text/xml') - displayTitle = d.querySelector('finderFeed desc')?.textContent?.trim() || desc || '' - } catch { - displayTitle = desc || '' - } + displayTitle = q('finderFeed > desc') || q('finderFeed desc') || desc || '' } const openExternal = (e: React.MouseEvent, nextUrl?: string) => { @@ -4344,30 +9156,19 @@ function MessageBubble({ if (kind === 'quote') { // 引用回复消息(appMsgKind='quote',xmlType=57) - const replyText = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || '' + const replyText = message.linkTitle || q('title') || cleanedParsedContent || '' const referContent = message.quotedContent || q('refermsg > content') || '' - const referSender = message.quotedSender || q('refermsg > displayname') || '' return ( -
-
- {referSender && {referSender}} - {renderTextWithEmoji(cleanMessageContent(referContent))} -
+ renderBubbleWithQuote( + renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(referContent))),
{renderTextWithEmoji(cleanMessageContent(replyText))}
-
+ ) ) } if (kind === 'red-packet') { // 专属红包卡片 - const greeting = (() => { - try { - const d = getDoc() - if (!d) return '' - return d.querySelector('receivertitle')?.textContent?.trim() || - d.querySelector('sendertitle')?.textContent?.trim() || '' - } catch { return '' } - })() + const greeting = q('receivertitle') || q('sendertitle') || '' return (
@@ -4535,38 +9336,19 @@ function MessageBubble({ return appMsgRichPreview } - const isAppMsg = message.rawContent?.includes('')) - - const parser = new DOMParser() - parsedDoc = parser.parseFromString(xmlContent, 'text/xml') - - title = parsedDoc.querySelector('title')?.textContent || '链接' - desc = parsedDoc.querySelector('des')?.textContent || '' - url = parsedDoc.querySelector('url')?.textContent || '' - appMsgType = parsedDoc.querySelector('appmsg > type')?.textContent || parsedDoc.querySelector('type')?.textContent || '' - textAnnouncement = parsedDoc.querySelector('textannouncement')?.textContent || '' - } catch (e) { - console.error('解析 AppMsg 失败:', e) - } + if (appMsgContainsTag) { + const q = queryAppMsgText + const title = q('title') || '链接' + const desc = q('des') + const url = q('url') + const appMsgType = message.xmlType || q('appmsg > type') || q('type') + const textAnnouncement = q('textannouncement') + const parsedDoc: Document | null = appMsgDoc // 引用回复消息 (type=57),防止被误判为链接 if (appMsgType === '57') { - const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanMessageContent(message.parsedContent) || '' + const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanedParsedContent || '' const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || '' - const referSender = parsedDoc?.querySelector('refermsg > displayname')?.textContent?.trim() || '' const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || '' const renderReferContent2 = () => { @@ -4590,13 +9372,10 @@ function MessageBubble({ } return ( -
-
- {referSender && {referSender}} - {renderReferContent2()} -
+ renderBubbleWithQuote( + renderQuotedMessageBlock(renderReferContent2()),
{renderTextWithEmoji(cleanMessageContent(replyText))}
-
+ ) ) } @@ -4627,11 +9406,12 @@ function MessageBubble({ ? `共 ${recordList.length} 条聊天记录` : desc || '聊天记录' - const previewItems = recordList.slice(0, 4) + const previewItems = buildChatRecordPreviewItems(recordList, 3) + const remainingCount = Math.max(0, recordList.length - previewItems.length) return (
{ e.stopPropagation() // 打开聊天记录窗口 @@ -4639,42 +9419,32 @@ function MessageBubble({ }} title="点击查看详细聊天记录" > -
-
- {displayTitle} -
+
+ {displayTitle}
-
-
- {previewItems.length > 0 ? ( - <> -
- {metaText} -
-
- {previewItems.map((item, i) => ( -
- - {item.sourcename ? `${item.sourcename}: ` : ''} - - {item.datadesc || item.datatitle || '[媒体消息]'} -
- ))} - {recordList.length > previewItems.length && ( -
还有 {recordList.length - previewItems.length} 条…
- )} -
- - ) : ( -
- {desc || '点击打开查看完整聊天记录'} +
+ {metaText} +
+ {previewItems.length > 0 ? ( +
+ {previewItems.map((item, i) => ( +
+ + {hasRenderableChatRecordName(item.sourcename) ? `${item.sourcename}: ` : ''} + + {getChatRecordPreviewText(item)}
+ ))} + {remainingCount > 0 && ( +
还有 {remainingCount} 条…
)}
-
- + ) : ( +
+ {desc || '点击打开查看完整聊天记录'}
-
+ )} +
聊天记录
) } @@ -4834,14 +9604,16 @@ function MessageBubble({ // 没有 cdnUrl 或加载失败,显示占位符 if ((!message.emojiCdnUrl && !message.emojiLocalPath) || emojiError) { return ( -
- - - - - - - 表情包未缓存 +
+
+ + + + + + + 表情包未缓存 +
) } @@ -4849,20 +9621,31 @@ function MessageBubble({ // 显示加载中 if (emojiLoading || !emojiLocalPath) { return ( -
- +
+
+ +
) } // 显示表情图片 return ( - 表情 setEmojiError(true)} - /> +
+ 表情 { + setEmojiError(false) + stabilizeEmojiScrollAfterResize() + }} + onError={() => { + emojiResizeBaselineRef.current = null + setEmojiError(true) + }} + /> +
) } @@ -4871,19 +9654,14 @@ function MessageBubble({ // 带引用的消息 if (hasQuote) { - return ( -
-
- {message.quotedSender && {message.quotedSender}} - {renderTextWithEmoji(cleanMessageContent(message.quotedContent || ''))} -
-
{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}
-
+ return renderBubbleWithQuote( + renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(quotedContent))), +
{renderTextWithEmoji(cleanedParsedContent)}
) } // 普通消息 - return
{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}
+ return
{renderTextWithEmoji(cleanedParsedContent)}
} return ( @@ -4905,7 +9683,7 @@ function MessageBubble({ onClick={(e) => { if (isSelectionMode) { e.stopPropagation() - onToggleSelection?.(message.localId, e.shiftKey) + onToggleSelection?.(messageKey, e.shiftKey) } }} > @@ -4934,7 +9712,7 @@ function MessageBubble({
@@ -4943,7 +9721,7 @@ function MessageBubble({ {/* 群聊中显示发送者名称 */} {isGroupChat && !isSent && (
- {senderName || message.senderUsername || '群成员'} + {resolvedSenderName || '群成员'}
)} {renderContent()} @@ -4968,9 +9746,55 @@ function MessageBubble({ {isSelected && }
)} + {systemAlert && createPortal( +
setSystemAlert(null)} style={{ zIndex: 99999 }}> +
e.stopPropagation()} style={{ maxWidth: '400px' }}> +
+ +
+
+

{systemAlert.title}

+

+ {systemAlert.message} +

+
+
+ +
+
+
, + document.body + )}
) } +const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => { + if (prevProps.message !== nextProps.message) return false + if (prevProps.messageKey !== nextProps.messageKey) return false + if (prevProps.showTime !== nextProps.showTime) return false + if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false + if (prevProps.myWxid !== nextProps.myWxid) return false + if (prevProps.isGroupChat !== nextProps.isGroupChat) return false + if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false + if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false + if (prevProps.isSelected !== nextProps.isSelected) return false + if (prevProps.onRequireModelDownload !== nextProps.onRequireModelDownload) return false + if (prevProps.onContextMenu !== nextProps.onContextMenu) return false + if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false + + return ( + prevProps.session.username === nextProps.session.username && + prevProps.session.displayName === nextProps.session.displayName && + prevProps.session.avatarUrl === nextProps.session.avatarUrl + ) +}) + export default ChatPage diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index f7986ff..ed71ec3 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -148,6 +148,17 @@ svg { opacity: 0.7; transition: transform 0.2s; + flex-shrink: 0; + } + + .chip-label { + min-width: 0; + } + + .chip-count { + margin-left: auto; + text-align: right; + font-variant-numeric: tabular-nums; } &:hover { @@ -177,6 +188,22 @@ padding: 0 20px 12px; font-size: 13px; color: var(--text-secondary); + + .contacts-cache-meta { + margin-left: 10px; + color: var(--text-tertiary); + font-size: 12px; + + &.syncing { + color: var(--primary); + } + } + + .avatar-enrich-progress { + margin-left: 10px; + color: var(--text-tertiary); + font-size: 12px; + } } .selection-toolbar { @@ -213,10 +240,103 @@ } } + .load-issue-state { + flex: 1; + padding: 14px 14px 18px; + overflow-y: auto; + } + + .issue-card { + border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color)); + background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg)); + border-radius: 12px; + padding: 14px; + color: var(--text-primary); + + .issue-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary)); + margin-bottom: 8px; + } + + .issue-message { + margin: 0 0 8px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .issue-reason { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .issue-hints { + margin: 10px 0 0; + padding-left: 18px; + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.6; + } + + .issue-actions { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .issue-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; + padding: 7px 10px; + font-size: 12px; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + color: var(--text-primary); + border-color: var(--text-tertiary); + background: var(--bg-hover); + } + + &.primary { + background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary)); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + color: var(--primary); + } + } + + .issue-diagnostics { + margin-top: 12px; + border-radius: 8px; + background: var(--bg-primary); + border: 1px dashed var(--border-color); + padding: 10px; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + } + } + .contacts-list { flex: 1; overflow-y: auto; padding: 0 12px 12px; + position: relative; &::-webkit-scrollbar { width: 6px; @@ -229,15 +349,31 @@ } } + .contacts-list-virtual { + position: relative; + min-height: 100%; + } + + .contact-row { + position: absolute; + left: 0; + right: 0; + height: 76px; + padding-bottom: 4px; + will-change: transform; + } + .contact-item { display: flex; align-items: center; gap: 12px; padding: 12px; + height: 72px; + box-sizing: border-box; border-radius: 10px; transition: all 0.2s; cursor: pointer; - margin-bottom: 4px; + margin-bottom: 0; &:hover { background: var(--bg-hover); @@ -399,6 +535,28 @@ word-break: break-all; user-select: text; } + + .detail-entry-btn { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + padding: 6px 10px; + font-size: 12px; + line-height: 1; + cursor: pointer; + transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease; + + &:hover { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); + } + } } .goto-chat-btn { diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 1f74576..35e5a1d 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -1,24 +1,51 @@ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react' import { useNavigate } from 'react-router-dom' -import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react' +import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList, Aperture } from 'lucide-react' import { useChatStore } from '../stores/chatStore' +import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore' +import * as configService from '../services/config' +import type { ContactInfo } from '../types/models' +import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' +import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline' import './ContactsPage.scss' -interface ContactInfo { - username: string - displayName: string - remark?: string - nickname?: string +interface ContactEnrichInfo { + displayName?: string avatarUrl?: string - type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' } +const AVATAR_ENRICH_BATCH_SIZE = 80 +const SEARCH_DEBOUNCE_MS = 120 +const VIRTUAL_ROW_HEIGHT = 76 +const VIRTUAL_OVERSCAN = 10 +const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 +const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 + +interface ContactsLoadSession { + requestId: string + startedAt: number + attempt: number + timeoutMs: number +} + +interface ContactsLoadIssue { + kind: 'timeout' | 'error' + title: string + message: string + reason: string + errorDetail?: string + occurredAt: number + elapsedMs: number +} + +type ContactsDataSource = 'cache' | 'network' | null + function ContactsPage() { const [contacts, setContacts] = useState([]) - const [filteredContacts, setFilteredContacts] = useState([]) const [selectedUsernames, setSelectedUsernames] = useState>(new Set()) const [isLoading, setIsLoading] = useState(true) const [searchKeyword, setSearchKeyword] = useState('') + const [debouncedSearchKeyword, setDebouncedSearchKeyword] = useState('') const [contactTypes, setContactTypes] = useState({ friends: true, groups: false, @@ -29,6 +56,9 @@ function ContactsPage() { // 导出模式与查看详情 const [exportMode, setExportMode] = useState(false) const [selectedContact, setSelectedContact] = useState(null) + const [snsUserPostCounts, setSnsUserPostCounts] = useState>({}) + const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle') + const [snsTimelineTarget, setSnsTimelineTarget] = useState(null) const navigate = useNavigate() const { setCurrentSession } = useChatStore() @@ -39,79 +69,530 @@ function ContactsPage() { const [isExporting, setIsExporting] = useState(false) const [showFormatSelect, setShowFormatSelect] = useState(false) const formatDropdownRef = useRef(null) + const listRef = useRef(null) + const loadVersionRef = useRef(0) + const [avatarEnrichProgress, setAvatarEnrichProgress] = useState({ + loaded: 0, + total: 0, + running: false + }) + const [scrollTop, setScrollTop] = useState(0) + const [listViewportHeight, setListViewportHeight] = useState(480) + const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts) + const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts) + const loadAttemptRef = useRef(0) + const loadTimeoutTimerRef = useRef(null) + const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const [loadSession, setLoadSession] = useState(null) + const [loadIssue, setLoadIssue] = useState(null) + const [showDiagnostics, setShowDiagnostics] = useState(false) + const [diagnosticTick, setDiagnosticTick] = useState(Date.now()) + const [contactsDataSource, setContactsDataSource] = useState(null) + const [contactsUpdatedAt, setContactsUpdatedAt] = useState(null) + const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState(null) + const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const contactsCacheScopeRef = useRef('default') + const contactsAvatarCacheRef = useRef>({}) - // 加载通讯录 - const loadContacts = useCallback(async () => { - setIsLoading(true) - try { - const result = await window.electronAPI.chat.connect() - if (!result.success) { - console.error('连接失败:', result.error) - setIsLoading(false) - return - } - const contactsResult = await window.electronAPI.chat.getContacts() - - if (contactsResult.success && contactsResult.contacts) { - - + const ensureContactsCacheScope = useCallback(async () => { + if (contactsCacheScopeRef.current !== 'default') { + return contactsCacheScopeRef.current + } + const [dbPath, myWxid] = await Promise.all([ + configService.getDbPath(), + configService.getMyWxid() + ]) + const scopeKey = dbPath || myWxid + ? `${dbPath || ''}::${myWxid || ''}` + : 'default' + contactsCacheScopeRef.current = scopeKey + return scopeKey + }, []) - // 获取头像URL - const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username) - if (usernames.length > 0) { - const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) - if (avatarResult.success && avatarResult.contacts) { - contactsResult.contacts.forEach((contact: ContactInfo) => { - const enriched = avatarResult.contacts?.[contact.username] - if (enriched?.avatarUrl) { - contact.avatarUrl = enriched.avatarUrl - } - }) - } + useEffect(() => { + let cancelled = false + void (async () => { + try { + const value = await configService.getContactsLoadTimeoutMs() + if (!cancelled) { + setContactsLoadTimeoutMs(value) } - - setContacts(contactsResult.contacts) - setFilteredContacts(contactsResult.contacts) - setSelectedUsernames(new Set()) + } catch (error) { + console.error('读取通讯录超时配置失败:', error) } - } catch (e) { - console.error('加载通讯录失败:', e) - } finally { - setIsLoading(false) + })() + return () => { + cancelled = true } }, []) useEffect(() => { - loadContacts() - }, [loadContacts]) + contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs + }, [contactsLoadTimeoutMs]) + + const mergeAvatarCacheIntoContacts = useCallback((sourceContacts: ContactInfo[]): ContactInfo[] => { + const avatarCache = contactsAvatarCacheRef.current + if (!sourceContacts.length || Object.keys(avatarCache).length === 0) { + return sourceContacts + } + let changed = false + const merged = sourceContacts.map((contact) => { + const cachedAvatar = avatarCache[contact.username]?.avatarUrl + if (!cachedAvatar || contact.avatarUrl) { + return contact + } + changed = true + return { + ...contact, + avatarUrl: cachedAvatar + } + }) + return changed ? merged : sourceContacts + }, []) + + const upsertAvatarCacheFromContacts = useCallback(( + scopeKey: string, + sourceContacts: ContactInfo[], + options?: { prune?: boolean; markCheckedUsernames?: string[] } + ) => { + if (!scopeKey) return + const nextCache = { ...contactsAvatarCacheRef.current } + const now = Date.now() + const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean)) + const usernamesInSource = new Set() + let changed = false + + for (const contact of sourceContacts) { + const username = String(contact.username || '').trim() + if (!username) continue + usernamesInSource.add(username) + const prev = nextCache[username] + const avatarUrl = String(contact.avatarUrl || '').trim() + if (!avatarUrl) continue + const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt + const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now) + if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) { + nextCache[username] = { + avatarUrl, + updatedAt, + checkedAt + } + changed = true + } + } + + for (const username of markCheckedSet) { + const prev = nextCache[username] + if (!prev) continue + if (prev.checkedAt !== now) { + nextCache[username] = { + ...prev, + checkedAt: now + } + changed = true + } + } + + if (options?.prune) { + for (const username of Object.keys(nextCache)) { + if (usernamesInSource.has(username)) continue + delete nextCache[username] + changed = true + } + } + + if (!changed) return + contactsAvatarCacheRef.current = nextCache + setAvatarCacheUpdatedAt(now) + void configService.setContactsAvatarCache(scopeKey, nextCache).catch((error) => { + console.error('写入通讯录头像缓存失败:', error) + }) + }, []) + + const applyEnrichedContacts = useCallback((enrichedMap: Record) => { + if (!enrichedMap || Object.keys(enrichedMap).length === 0) return + + setContacts(prev => { + let changed = false + const next = prev.map(contact => { + const enriched = enrichedMap[contact.username] + if (!enriched) return contact + const displayName = enriched.displayName || contact.displayName + const avatarUrl = enriched.avatarUrl || contact.avatarUrl + if (displayName === contact.displayName && avatarUrl === contact.avatarUrl) { + return contact + } + changed = true + return { + ...contact, + displayName, + avatarUrl + } + }) + return changed ? next : prev + }) + + setSelectedContact(prev => { + if (!prev) return prev + const enriched = enrichedMap[prev.username] + if (!enriched) return prev + const displayName = enriched.displayName || prev.displayName + const avatarUrl = enriched.avatarUrl || prev.avatarUrl + if (displayName === prev.displayName && avatarUrl === prev.avatarUrl) { + return prev + } + return { + ...prev, + displayName, + avatarUrl + } + }) + }, []) + + const enrichContactsInBackground = useCallback(async ( + sourceContacts: ContactInfo[], + loadVersion: number, + scopeKey: string + ) => { + const sourceByUsername = new Map() + for (const contact of sourceContacts) { + if (!contact.username) continue + sourceByUsername.set(contact.username, contact) + } + const now = Date.now() + const usernames = sourceContacts + .map(contact => contact.username) + .filter(Boolean) + .filter((username) => { + const currentContact = sourceByUsername.get(username) + if (!currentContact) return false + const cacheEntry = contactsAvatarCacheRef.current[username] + if (!cacheEntry || !cacheEntry.avatarUrl) { + return !currentContact.avatarUrl + } + if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) { + return true + } + const checkedAt = cacheEntry.checkedAt || 0 + return now - checkedAt >= AVATAR_RECHECK_INTERVAL_MS + }) + + const total = usernames.length + setAvatarEnrichProgress({ + loaded: 0, + total, + running: total > 0 + }) + if (total === 0) return + + for (let i = 0; i < total; i += AVATAR_ENRICH_BATCH_SIZE) { + if (loadVersionRef.current !== loadVersion) return + const batch = usernames.slice(i, i + AVATAR_ENRICH_BATCH_SIZE) + if (batch.length === 0) continue + + try { + const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch) + if (loadVersionRef.current !== loadVersion) return + if (avatarResult.success && avatarResult.contacts) { + applyEnrichedContacts(avatarResult.contacts) + for (const [username, enriched] of Object.entries(avatarResult.contacts)) { + const prev = sourceByUsername.get(username) + if (!prev) continue + sourceByUsername.set(username, { + ...prev, + displayName: enriched.displayName || prev.displayName, + avatarUrl: enriched.avatarUrl || prev.avatarUrl + }) + } + } + const batchContacts = batch + .map(username => sourceByUsername.get(username)) + .filter((contact): contact is ContactInfo => Boolean(contact)) + upsertAvatarCacheFromContacts(scopeKey, batchContacts, { + markCheckedUsernames: batch + }) + } catch (e) { + console.error('分批补全头像失败:', e) + } + + const loaded = Math.min(i + batch.length, total) + setAvatarEnrichProgress({ + loaded, + total, + running: loaded < total + }) + + await new Promise(resolve => setTimeout(resolve, 0)) + } + }, [applyEnrichedContacts, upsertAvatarCacheFromContacts]) + + // 加载通讯录 + const loadContacts = useCallback(async (options?: { scopeKey?: string }) => { + const scopeKey = options?.scopeKey || await ensureContactsCacheScope() + const loadVersion = loadVersionRef.current + 1 + loadVersionRef.current = loadVersion + loadAttemptRef.current += 1 + const startedAt = Date.now() + const timeoutMs = contactsLoadTimeoutMsRef.current + const requestId = `contacts-${startedAt}-${loadAttemptRef.current}` + setLoadSession({ + requestId, + startedAt, + attempt: loadAttemptRef.current, + timeoutMs + }) + setLoadIssue(null) + setShowDiagnostics(false) + if (loadTimeoutTimerRef.current) { + window.clearTimeout(loadTimeoutTimerRef.current) + loadTimeoutTimerRef.current = null + } + const timeoutTimerId = window.setTimeout(() => { + if (loadVersionRef.current !== loadVersion) return + const elapsedMs = Date.now() - startedAt + setLoadIssue({ + kind: 'timeout', + title: '通讯录加载超时', + message: `等待超过 ${timeoutMs}ms,联系人列表仍未返回。`, + reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。', + occurredAt: Date.now(), + elapsedMs + }) + }, timeoutMs) + loadTimeoutTimerRef.current = timeoutTimerId + + setIsLoading(true) + setAvatarEnrichProgress({ + loaded: 0, + total: 0, + running: false + }) + try { + const contactsResult = await window.electronAPI.chat.getContacts() + + if (loadVersionRef.current !== loadVersion) return + if (contactsResult.success && contactsResult.contacts) { + if (loadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(loadTimeoutTimerRef.current) + loadTimeoutTimerRef.current = null + } + const contactsWithAvatarCache = mergeAvatarCacheIntoContacts(contactsResult.contacts) + setContacts(contactsWithAvatarCache) + syncContactTypeCounts(contactsWithAvatarCache) + setSelectedUsernames(new Set()) + setSelectedContact(prev => { + if (!prev) return prev + return contactsWithAvatarCache.find(contact => contact.username === prev.username) || null + }) + const now = Date.now() + setContactsDataSource('network') + setContactsUpdatedAt(now) + setLoadIssue(null) + setIsLoading(false) + upsertAvatarCacheFromContacts(scopeKey, contactsWithAvatarCache, { prune: true }) + void configService.setContactsListCache( + scopeKey, + contactsWithAvatarCache.map(contact => ({ + username: contact.username, + displayName: contact.displayName, + remark: contact.remark, + nickname: contact.nickname, + type: contact.type + })) + ).catch((error) => { + console.error('写入通讯录缓存失败:', error) + }) + void enrichContactsInBackground(contactsWithAvatarCache, loadVersion, scopeKey) + return + } + const elapsedMs = Date.now() - startedAt + setLoadIssue({ + kind: 'error', + title: '通讯录加载失败', + message: '联系人接口返回失败,未拿到联系人列表。', + reason: 'chat.getContacts 返回 success=false。', + errorDetail: contactsResult.error || '未知错误', + occurredAt: Date.now(), + elapsedMs + }) + } catch (e) { + console.error('加载通讯录失败:', e) + const elapsedMs = Date.now() - startedAt + setLoadIssue({ + kind: 'error', + title: '通讯录加载失败', + message: '联系人请求执行异常。', + reason: '调用 chat.getContacts 发生异常。', + errorDetail: String(e), + occurredAt: Date.now(), + elapsedMs + }) + } finally { + if (loadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(loadTimeoutTimerRef.current) + loadTimeoutTimerRef.current = null + } + if (loadVersionRef.current === loadVersion) { + setIsLoading(false) + } + } + }, [ + ensureContactsCacheScope, + enrichContactsInBackground, + mergeAvatarCacheIntoContacts, + syncContactTypeCounts, + upsertAvatarCacheFromContacts + ]) - // 搜索和类型过滤 useEffect(() => { - let filtered = contacts + let cancelled = false + void (async () => { + const scopeKey = await ensureContactsCacheScope() + if (cancelled) return + try { + const [cacheItem, avatarCacheItem] = await Promise.all([ + configService.getContactsListCache(scopeKey), + configService.getContactsAvatarCache(scopeKey) + ]) + const avatarCacheMap = avatarCacheItem?.avatars || {} + contactsAvatarCacheRef.current = avatarCacheMap + setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null) + if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) { + const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({ + ...contact, + avatarUrl: avatarCacheMap[contact.username]?.avatarUrl + })) + setContacts(cachedContacts) + syncContactTypeCounts(cachedContacts) + setContactsDataSource('cache') + setContactsUpdatedAt(cacheItem.updatedAt || null) + setIsLoading(false) + } + } catch (error) { + console.error('读取通讯录缓存失败:', error) + } + if (!cancelled) { + void loadContacts({ scopeKey }) + } + })() + return () => { + cancelled = true + } + }, [ensureContactsCacheScope, loadContacts, syncContactTypeCounts]) - // 类型过滤 - filtered = filtered.filter(c => { - if (c.type === 'friend' && !contactTypes.friends) return false - if (c.type === 'group' && !contactTypes.groups) return false - if (c.type === 'official' && !contactTypes.officials) return false - if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false + useEffect(() => { + return () => { + if (loadTimeoutTimerRef.current) { + window.clearTimeout(loadTimeoutTimerRef.current) + loadTimeoutTimerRef.current = null + } + loadVersionRef.current += 1 + } + }, []) + + useEffect(() => { + if (!loadIssue || contacts.length > 0) return + if (!(isLoading && loadIssue.kind === 'timeout')) return + const timer = window.setInterval(() => { + setDiagnosticTick(Date.now()) + }, 500) + return () => window.clearInterval(timer) + }, [contacts.length, isLoading, loadIssue]) + + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase()) + }, SEARCH_DEBOUNCE_MS) + return () => window.clearTimeout(timer) + }, [searchKeyword]) + + const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => { + if (!options?.force && (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'ready')) { + return + } + + setSnsUserPostCountsStatus('loading') + try { + const result = await window.electronAPI.sns.getUserPostCounts() + if (!result.success || !result.counts) { + setSnsUserPostCountsStatus('error') + return + } + + const normalizedCounts: Record = {} + for (const [rawUsername, rawCount] of Object.entries(result.counts)) { + const username = String(rawUsername || '').trim() + if (!username) continue + const value = Number(rawCount) + normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 + } + + setSnsUserPostCounts(normalizedCounts) + setSnsUserPostCountsStatus('ready') + } catch (error) { + console.error('加载通讯录联系人朋友圈条数失败:', error) + setSnsUserPostCountsStatus('error') + } + }, [snsUserPostCountsStatus]) + + useEffect(() => { + if (!selectedContact || !isSingleContactSession(selectedContact.username)) return + if (snsUserPostCountsStatus !== 'idle') return + void loadSnsUserPostCounts() + }, [loadSnsUserPostCounts, selectedContact, snsUserPostCountsStatus]) + + const filteredContacts = useMemo(() => { + let filtered = contacts.filter(contact => { + if (contact.type === 'friend' && !contactTypes.friends) return false + if (contact.type === 'group' && !contactTypes.groups) return false + if (contact.type === 'official' && !contactTypes.officials) return false + if (contact.type === 'former_friend' && !contactTypes.deletedFriends) return false return true }) - // 关键词过滤 - if (searchKeyword.trim()) { - const lower = searchKeyword.toLowerCase() - filtered = filtered.filter(c => - c.displayName?.toLowerCase().includes(lower) || - c.remark?.toLowerCase().includes(lower) || - c.username.toLowerCase().includes(lower) + if (debouncedSearchKeyword) { + filtered = filtered.filter(contact => + contact.displayName?.toLowerCase().includes(debouncedSearchKeyword) || + contact.remark?.toLowerCase().includes(debouncedSearchKeyword) || + contact.username.toLowerCase().includes(debouncedSearchKeyword) ) } - setFilteredContacts(filtered) - }, [searchKeyword, contacts, contactTypes]) + return filtered + }, [contacts, contactTypes, debouncedSearchKeyword]) - // 点击外部关闭下拉菜单 + const contactTypeCounts = useMemo(() => toContactTypeCardCounts(sharedTabCounts), [sharedTabCounts]) + + useEffect(() => { + if (!listRef.current) return + listRef.current.scrollTop = 0 + setScrollTop(0) + }, [debouncedSearchKeyword, contactTypes]) + + useEffect(() => { + const node = listRef.current + if (!node) return + + const updateViewportHeight = () => { + setListViewportHeight(Math.max(node.clientHeight, VIRTUAL_ROW_HEIGHT)) + } + updateViewportHeight() + + const observer = new ResizeObserver(() => updateViewportHeight()) + observer.observe(node) + return () => observer.disconnect() + }, [filteredContacts.length, isLoading]) + + useEffect(() => { + const maxScroll = Math.max(0, filteredContacts.length * VIRTUAL_ROW_HEIGHT - listViewportHeight) + if (scrollTop <= maxScroll) return + setScrollTop(maxScroll) + if (listRef.current) { + listRef.current.scrollTop = maxScroll + } + }, [filteredContacts.length, listViewportHeight, scrollTop]) + + // 搜索和类型过滤 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node @@ -123,11 +604,117 @@ function ContactsPage() { return () => document.removeEventListener('mousedown', handleClickOutside) }, [showFormatSelect]) - const selectedInFilteredCount = filteredContacts.reduce((count, contact) => { - return selectedUsernames.has(contact.username) ? count + 1 : count - }, 0) + const selectedInFilteredCount = useMemo(() => { + return filteredContacts.reduce((count, contact) => { + return selectedUsernames.has(contact.username) ? count + 1 : count + }, 0) + }, [filteredContacts, selectedUsernames]) const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length + const selectedContactSupportsSns = useMemo(() => { + return Boolean(selectedContact && isSingleContactSession(selectedContact.username)) + }, [selectedContact]) + + const selectedContactSnsCount = useMemo(() => { + if (!selectedContactSupportsSns || !selectedContact) return null + if (snsUserPostCountsStatus !== 'ready') return null + const rawCount = Number(snsUserPostCounts[selectedContact.username] || 0) + return Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0 + }, [selectedContact, selectedContactSupportsSns, snsUserPostCounts, snsUserPostCountsStatus]) + + const selectedContactSnsEntryLabel = useMemo(() => { + if (!selectedContactSupportsSns) return '' + if (selectedContactSnsCount !== null) { + return `朋友圈:${selectedContactSnsCount.toLocaleString('zh-CN')}条` + } + if (snsUserPostCountsStatus === 'error') return '朋友圈:查看' + return '朋友圈:统计中...' + }, [selectedContactSupportsSns, selectedContactSnsCount, snsUserPostCountsStatus]) + + const openSelectedContactSnsTimeline = useCallback(() => { + if (!selectedContact || !selectedContactSupportsSns) return + if (snsUserPostCountsStatus === 'idle') { + void loadSnsUserPostCounts() + } + setSnsTimelineTarget({ + username: selectedContact.username, + displayName: selectedContact.displayName || selectedContact.remark || selectedContact.nickname || selectedContact.username, + avatarUrl: selectedContact.avatarUrl + }) + }, [loadSnsUserPostCounts, selectedContact, selectedContactSupportsSns, snsUserPostCountsStatus]) + + const { startIndex, endIndex } = useMemo(() => { + if (filteredContacts.length === 0) { + return { startIndex: 0, endIndex: 0 } + } + const baseStart = Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) + const visibleCount = Math.ceil(listViewportHeight / VIRTUAL_ROW_HEIGHT) + const nextStart = Math.max(0, baseStart - VIRTUAL_OVERSCAN) + const nextEnd = Math.min(filteredContacts.length, nextStart + visibleCount + VIRTUAL_OVERSCAN * 2) + return { + startIndex: nextStart, + endIndex: nextEnd + } + }, [filteredContacts.length, listViewportHeight, scrollTop]) + + const visibleContacts = useMemo(() => { + return filteredContacts.slice(startIndex, endIndex) + }, [filteredContacts, startIndex, endIndex]) + + const onContactsListScroll = useCallback((event: UIEvent) => { + setScrollTop(event.currentTarget.scrollTop) + }, []) + + const issueElapsedMs = useMemo(() => { + if (!loadIssue) return 0 + if (isLoading && loadSession) { + return Math.max(loadIssue.elapsedMs, diagnosticTick - loadSession.startedAt) + } + return loadIssue.elapsedMs + }, [diagnosticTick, isLoading, loadIssue, loadSession]) + + const diagnosticsText = useMemo(() => { + if (!loadIssue || !loadSession) return '' + return [ + `请求ID: ${loadSession.requestId}`, + `请求序号: 第 ${loadSession.attempt} 次`, + `阈值配置: ${loadSession.timeoutMs}ms`, + `当前状态: ${loadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`, + `累计耗时: ${(issueElapsedMs / 1000).toFixed(1)}s`, + `发生时间: ${new Date(loadIssue.occurredAt).toLocaleString()}`, + `阶段: chat.getContacts`, + `原因: ${loadIssue.reason}`, + `错误详情: ${loadIssue.errorDetail || '无'}` + ].join('\n') + }, [issueElapsedMs, loadIssue, loadSession]) + + const copyDiagnostics = useCallback(async () => { + if (!diagnosticsText) return + try { + await navigator.clipboard.writeText(diagnosticsText) + alert('诊断信息已复制') + } catch (error) { + console.error('复制诊断信息失败:', error) + alert('复制失败,请手动复制诊断信息') + } + }, [diagnosticsText]) + + const contactsUpdatedAtLabel = useMemo(() => { + if (!contactsUpdatedAt) return '' + return new Date(contactsUpdatedAt).toLocaleString() + }, [contactsUpdatedAt]) + + const avatarCachedCount = useMemo(() => { + return contacts.reduce((count, contact) => ( + contact.avatarUrl ? count + 1 : count + ), 0) + }, [contacts]) + + const avatarCacheUpdatedAtLabel = useMemo(() => { + if (!avatarCacheUpdatedAt) return '' + return new Date(avatarCacheUpdatedAt).toLocaleString() + }, [avatarCacheUpdatedAt]) + const toggleContactSelected = (username: string, checked: boolean) => { setSelectedUsernames(prev => { const next = new Set(prev) @@ -256,7 +843,7 @@ function ContactsPage() { > -
@@ -280,25 +867,30 @@ function ContactsPage() {
-
- 共 {filteredContacts.length} 个联系人 -
{exportMode && (
@@ -315,61 +907,105 @@ function ContactsPage() {
)} - {isLoading ? ( + {contacts.length === 0 && loadIssue ? ( +
+
+
+ + {loadIssue.title} +
+

{loadIssue.message}

+

{loadIssue.reason}

+
    +
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • +
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • +
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • +
+
+ + + +
+ {showDiagnostics && ( +
{diagnosticsText}
+ )} +
+
+ ) : isLoading && contacts.length === 0 ? (
- 加载中... + 联系人加载中...
) : filteredContacts.length === 0 ? (
暂无联系人
) : ( -
- {filteredContacts.map(contact => { +
+
+ {visibleContacts.map((contact, idx) => { + const absoluteIndex = startIndex + idx + const top = absoluteIndex * VIRTUAL_ROW_HEIGHT const isChecked = selectedUsernames.has(contact.username) const isActive = !exportMode && selectedContact?.username === contact.username return (
{ - if (exportMode) { - toggleContactSelected(contact.username, !isChecked) - } else { - setSelectedContact(isActive ? null : contact) - } - }} + className="contact-row" + style={{ transform: `translateY(${top}px)` }} > - {exportMode && ( - - )} -
- {contact.avatarUrl ? ( - - ) : ( - {getAvatarLetter(contact.displayName)} +
{ + if (exportMode) { + toggleContactSelected(contact.username, !isChecked) + } else { + setSelectedContact(isActive ? null : contact) + } + }} + > + {exportMode && ( + )} -
-
-
{contact.displayName}
- {contact.remark && contact.remark !== contact.displayName && ( -
备注: {contact.remark}
- )} -
-
- {getContactTypeIcon(contact.type)} - {getContactTypeName(contact.type)} +
+ {contact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(contact.displayName)} + )} +
+
+
{contact.displayName}
+ {contact.remark && contact.remark !== contact.displayName && ( +
备注: {contact.remark}
+ )} +
+
+ {getContactTypeIcon(contact.type)} + {getContactTypeName(contact.type)} +
) - })} + })} +
)}
@@ -475,6 +1111,19 @@ function ContactsPage() {
昵称{selectedContact.nickname || selectedContact.displayName}
{selectedContact.remark &&
备注{selectedContact.remark}
}
类型{getContactTypeName(selectedContact.type)}
+ {selectedContactSupportsSns && ( +
+ 朋友圈 + +
+ )}
)} + setSnsTimelineTarget(null)} + initialTotalPosts={selectedContact && snsTimelineTarget?.username === selectedContact.username ? selectedContactSnsCount : null} + initialTotalPostsLoading={selectedContact && snsTimelineTarget?.username === selectedContact.username + ? snsUserPostCountsStatus === 'idle' || snsUserPostCountsStatus === 'loading' + : false} + />
) } diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index 9d155b7..585fb1e 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -107,7 +107,16 @@ function DualReportWindow() { setLoadingStage('完成') if (result.success && result.data) { - setReportData(result.data) + const normalizedResponse = result.data.response + ? { + ...result.data.response, + slowest: result.data.response.slowest ?? result.data.response.avg + } + : undefined + setReportData({ + ...result.data, + response: normalizedResponse + }) setIsLoading(false) } else { setError(result.error || '生成报告失败') diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 1d7e414..ab340f4 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1,151 +1,1790 @@ -.export-page { - display: flex; - height: calc(100% + 48px); +.export-board-page { + min-height: calc(100% + 48px); + height: auto; margin: -24px; + padding: 20px; background: var(--bg-primary); + display: flex; + flex-direction: column; + gap: 16px; + overflow-x: hidden; + overflow-y: visible; + + .spin { + animation: exportSpin 1s linear infinite; + } +} + +.export-top-panel { + display: block; + flex-shrink: 0; +} + +.export-top-bar { + display: flex; + align-items: stretch; + gap: 12px; +} + +.export-section-title-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 6px; +} + +.session-load-detail-entry { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 5px 10px; + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-secondary); + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color)); + color: var(--text-primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); + } + + &.active { + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); + color: var(--text-primary); + } +} + +.session-load-detail-entry-icon { + width: 16px; + height: 14px; + display: inline-flex; + align-items: flex-end; + justify-content: center; + gap: 2px; + flex-shrink: 0; +} + +.session-load-detail-entry-bar { + width: 3px; + border-radius: 999px; + background: color-mix(in srgb, var(--primary) 78%, var(--text-tertiary)); + opacity: 0.82; + animation: sessionLoadDetailBars 1.8s ease-in-out infinite; + + &:nth-child(1) { + height: 7px; + animation-delay: 0s; + } + + &:nth-child(2) { + height: 11px; + animation-delay: 0.18s; + } + + &:nth-child(3) { + height: 9px; + animation-delay: 0.36s; + } +} + +.session-load-detail-entry.active .session-load-detail-entry-bar { + animation-duration: 1.1s; + opacity: 1; +} + +@keyframes sessionLoadDetailBars { + 0%, 100% { + transform: scaleY(0.72); + opacity: 0.5; + } + + 50% { + transform: scaleY(1.18); + opacity: 1; + } +} + +.export-section-title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.section-info-tooltip { + position: relative; + flex-shrink: 0; +} + +.section-info-trigger { + width: 24px; + height: 24px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-tertiary); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; + + &:hover, + &.active { + border-color: rgba(var(--primary-rgb), 0.45); + color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + } +} + +.section-info-popover { + position: absolute; + top: calc(100% + 8px); + left: 0; + width: min(340px, calc(100vw - 40px)); + border-radius: 10px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + box-shadow: 0 14px 30px rgba(0, 0, 0, 0.2); + padding: 10px 12px; + z-index: 70; + + h4 { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + } + + p { + margin: 6px 0 0; + font-size: 12px; + line-height: 1.55; + color: var(--text-secondary); + } +} + +.session-load-detail-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.42); + display: flex; + align-items: center; + justify-content: center; + z-index: 2200; + padding: 20px; +} + +.session-load-detail-modal { + width: min(820px, 100%); + max-height: min(78vh, 860px); overflow: hidden; + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28); + display: flex; + flex-direction: column; +} - // 左侧会话选择面板 - .session-panel { - width: 380px; - min-width: 380px; - display: flex; - flex-direction: column; - border-right: 1px solid var(--border-color); - background: var(--card-bg); +.session-load-detail-header { + padding: 14px 16px 10px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + + h4 { + margin: 0; + font-size: 15px; + color: var(--text-primary); } - .panel-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 24px; - border-bottom: 1px solid var(--border-color); + p { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-tertiary); + } +} - h2 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0; - } +.session-load-detail-close { + border: 1px solid var(--border-color); + border-radius: 8px; + width: 28px; + height: 28px; + background: var(--bg-secondary); + color: var(--text-secondary); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; - .icon-btn { - width: 32px; - height: 32px; - border: none; - background: var(--bg-tertiary); - border-radius: 8px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-secondary); - transition: all 0.2s; + &:hover { + color: var(--text-primary); + border-color: var(--text-tertiary); + } +} - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } +.session-load-detail-body { + padding: 12px 16px 16px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 14px; +} - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } +.session-load-detail-block { + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--card-bg); - .spin { - animation: exportSpin 1s linear infinite; - } - } + h5 { + margin: 0; + padding: 10px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + font-size: 13px; + color: var(--text-primary); + } +} + +.session-load-detail-summary { + padding: 12px 12px 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.session-load-detail-summary-text { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); + + strong { + font-size: 18px; + color: var(--text-primary); } - .search-bar { - display: flex; - align-items: center; - gap: 10px; - margin: 16px 20px; - padding: 10px 14px; - background: var(--bg-secondary); + em { + font-style: normal; + color: var(--text-tertiary); + } +} + +.session-load-detail-note { + margin: 8px 12px 0; + font-size: 12px; + line-height: 1.6; + color: var(--text-tertiary); +} + +.session-load-detail-stop-btn, +.session-load-detail-task-stop-btn { + border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color)); + border-radius: 8px; + background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--bg-secondary)); + color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary)); + cursor: pointer; + white-space: nowrap; + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.session-load-detail-stop-btn { + padding: 8px 12px; + font-size: 12px; +} + +.session-load-detail-task-list { + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.session-load-detail-task-item { + border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary)); + padding: 10px 12px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + + &.status-cancel_requested { + border-color: color-mix(in srgb, var(--warning, #f59e0b) 36%, var(--border-color)); + } + + &.status-failed { + border-color: color-mix(in srgb, var(--danger, #ef4444) 36%, var(--border-color)); + } +} + +.session-load-detail-task-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + + p { + margin: 0; + font-size: 12px; + line-height: 1.55; + color: var(--text-secondary); + } +} + +.session-load-detail-task-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); + + strong { + font-size: 13px; + color: var(--text-primary); + } +} + +.session-load-detail-task-source { + padding: 2px 8px; + border-radius: 999px; + background: var(--bg-secondary); + color: var(--text-secondary); +} + +.session-load-detail-task-status { + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--bg-secondary) 80%, transparent); + color: var(--text-secondary); + + &.status-running { + color: var(--primary); + background: rgba(var(--primary-rgb), 0.1); + } + + &.status-cancel_requested { + color: #b45309; + background: rgba(245, 158, 11, 0.14); + } + + &.status-completed { + color: #166534; + background: rgba(34, 197, 94, 0.14); + } + + &.status-failed { + color: #b91c1c; + background: rgba(239, 68, 68, 0.14); + } +} + +.session-load-detail-task-meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 11px; + color: var(--text-tertiary); +} + +.session-load-detail-task-stop-btn { + padding: 7px 10px; + font-size: 12px; + flex-shrink: 0; +} + +.session-load-detail-empty { + padding: 12px; + font-size: 12px; + color: var(--text-tertiary); +} + +.session-load-detail-table { + display: flex; + flex-direction: column; + overflow-x: auto; +} + +.session-load-detail-row { + display: grid; + grid-template-columns: minmax(76px, 0.78fr) minmax(260px, 1.55fr) minmax(84px, 0.74fr) minmax(84px, 0.74fr); + gap: 10px; + align-items: center; + padding: 9px 12px; + font-size: 12px; + color: var(--text-secondary); + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 66%, transparent); + min-width: 620px; + + &:last-child { + border-bottom: none; + } + + > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &.header { + font-size: 11px; + color: var(--text-tertiary); + font-weight: 600; + background: color-mix(in srgb, var(--bg-secondary) 75%, transparent); + } +} + +.session-load-detail-status-cell { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 6px; + min-width: 0; + overflow: visible !important; + text-overflow: clip !important; + white-space: normal !important; +} + +.session-load-detail-status-icon { + color: var(--text-tertiary); + flex-shrink: 0; +} + +.session-load-detail-progress-pulse { + color: var(--text-tertiary); + font-size: 11px; + font-variant-numeric: tabular-nums; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + letter-spacing: 0.1px; + flex-shrink: 0; +} + +.session-mutual-friends-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.42); + display: flex; + align-items: center; + justify-content: center; + z-index: 2250; + padding: 20px; +} + +.session-mutual-friends-modal { + width: min(760px, 100%); + max-height: min(82vh, 900px); + overflow: hidden; + border-radius: 16px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28); + display: flex; + flex-direction: column; +} + +.session-mutual-friends-header { + padding: 16px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.session-mutual-friends-header-main { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.session-mutual-friends-avatar { + width: 44px; + height: 44px; + border-radius: 12px; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 16px; + font-weight: 700; + } +} + +.session-mutual-friends-meta { + min-width: 0; + + h4 { + margin: 0; + font-size: 16px; + color: var(--text-primary); + } +} + +.session-mutual-friends-stats { + margin-top: 4px; + font-size: 12px; + color: var(--text-secondary); +} + +.session-mutual-friends-close { + border: 1px solid var(--border-color); + border-radius: 8px; + width: 30px; + height: 30px; + background: var(--bg-secondary); + color: var(--text-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + color: var(--text-primary); + border-color: var(--text-tertiary); + } +} + +.session-mutual-friends-tip { + margin: 14px 16px 0; + padding: 11px 12px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--primary) 28%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary)); + color: var(--text-primary); + font-size: 14px; + line-height: 1.5; + font-weight: 700; +} + +.session-mutual-friends-toolbar { + padding: 12px 16px 0; + + input { + width: 100%; + height: 38px; border-radius: 10px; border: 1px solid var(--border-color); - transition: border-color 0.2s; + background: var(--bg-secondary); + color: var(--text-primary); + padding: 0 12px; + font-size: 13px; - &:focus-within { + &:focus { + outline: none; + border-color: color-mix(in srgb, var(--primary) 58%, var(--border-color)); + } + } +} + +.session-mutual-friends-body { + padding: 14px 16px 16px; + overflow: auto; + min-height: 220px; +} + +.session-mutual-friends-list { + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; +} + +.session-mutual-friends-row { + display: grid; + grid-template-columns: 36px minmax(120px, 0.82fr) max-content 56px 96px minmax(0, 1.28fr); + gap: 10px; + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 68%, transparent); + font-size: 12px; + color: var(--text-secondary); + min-height: 42px; + + &:last-child { + border-bottom: none; + } +} + +.session-mutual-friends-rank, +.session-mutual-friends-count, +.session-mutual-friends-latest { + font-variant-numeric: tabular-nums; +} + +.session-mutual-friends-rank { + color: var(--text-tertiary); + text-align: center; +} + +.session-mutual-friends-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); + font-weight: 600; +} + +.session-mutual-friends-source { + justify-self: start; + border-radius: 999px; + padding: 3px 8px; + font-size: 10px; + line-height: 1; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + white-space: nowrap; + + &.incoming { + color: #065f46; + border-color: color-mix(in srgb, #10b981 38%, var(--border-color)); + background: color-mix(in srgb, #10b981 10%, var(--bg-secondary)); + } + + &.outgoing { + color: #92400e; + border-color: color-mix(in srgb, #d97706 38%, var(--border-color)); + background: color-mix(in srgb, #f59e0b 12%, var(--bg-secondary)); + } + + &.bidirectional { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary)); + } +} + +.session-mutual-friends-desc { + min-width: 0; + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-mutual-friends-count, +.session-mutual-friends-latest { + text-align: right; + white-space: nowrap; +} + +.session-mutual-friends-empty { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + font-size: 13px; +} + +.global-export-controls { + --top-inline-control-height: 34px; + flex: 0 1 980px; + width: min(980px, 100%); + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 12px; + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(240px, 1fr) auto; + gap: 10px; + align-items: stretch; + + .control-label { + font-size: 11px; + color: var(--text-secondary); + font-weight: 600; + letter-spacing: 0.2px; + width: 78px; + flex: 0 0 78px; + line-height: 1.2; + } + + .path-control { + min-width: 0; + display: flex; + align-items: center; + gap: 4px; + + .control-label { + width: 48px; + flex-basis: 48px; + flex-shrink: 0; + } + } + + .path-inline-row { + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: nowrap; + } + + .path-value { + border: 1px dashed var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + display: flex; + align-items: stretch; + min-width: 0; + flex: 1; + overflow: hidden; + } + + .path-link { + border: none; + background: transparent; + font-size: 12px; + color: var(--text-primary); + text-align: left; + padding: 8px 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; + cursor: pointer; + + &:hover { + color: var(--primary); + } + + &:focus-visible { + outline: none; + } + } + + .path-change-btn { + border: none; + border-left: 1px dashed var(--border-color); + background: transparent; + color: var(--text-secondary); + font-size: 11px; + font-weight: 600; + padding: 0 9px; + cursor: pointer; + flex-shrink: 0; + + &:hover { + border-color: var(--primary); + color: var(--primary); + } + } + + .write-layout-control { + position: relative; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + width: 100%; + max-width: 100%; + z-index: 40; + } + + .more-export-settings-control { + display: flex; + align-items: center; + justify-content: flex-end; + } + + .more-export-settings-btn { + min-height: var(--top-inline-control-height); + border-radius: 10px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + padding: 0 14px; + font-size: 12px; + font-weight: 600; + line-height: 1; + white-space: nowrap; + cursor: pointer; + transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease; + + &:hover { + border-color: var(--primary); + color: var(--primary); + background: color-mix(in srgb, var(--primary) 6%, var(--bg-secondary)); + } + } + + .layout-trigger { + width: 100%; + min-height: var(--top-inline-control-height); + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; + cursor: pointer; + transition: border-color 0.12s ease; + + &:hover { border-color: var(--primary); } - svg { - color: var(--text-tertiary); - flex-shrink: 0; + &.active { + border-color: var(--primary); + } + } + + .layout-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: auto; + width: clamp(300px, 36vw, 420px); + max-width: calc(100vw - 40px); + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.28); + padding: 6px; + z-index: 3000; + max-height: 260px; + overflow-y: auto; + opacity: 0; + transform: translateY(-4px); + pointer-events: none; + visibility: hidden; + transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s step-end; + backdrop-filter: none; + will-change: opacity, transform; + + &.open { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + visibility: visible; + transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s step-start; + } + } + + .layout-option { + width: 100%; + border: none; + background: transparent; + color: var(--text-primary); + text-align: left; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; + + &:hover { + background: var(--bg-hover); } - input { - flex: 1; - border: none; - background: none; - outline: none; - font-size: 14px; - color: var(--text-primary); + &.active { + background: rgba(var(--primary-rgb), 0.12); + color: var(--primary); + } + } - &::placeholder { - color: var(--text-tertiary); - } + .layout-option-label { + font-size: 13px; + font-weight: 600; + color: inherit; + } + + .layout-option-desc { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.45; + } + + .layout-prefix-toggle { + margin-top: 4px; + padding: 10px; + border-top: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent); + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .layout-prefix-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + .layout-prefix-label { + font-size: 12px; + color: var(--text-primary); + font-weight: 600; + line-height: 1.35; + } + + .layout-prefix-desc { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.4; + } + + .layout-prefix-switch { + width: 38px; + height: 22px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + cursor: pointer; + padding: 2px; + display: inline-flex; + align-items: center; + transition: background 0.15s ease, border-color 0.15s ease; + flex-shrink: 0; + + .layout-prefix-switch-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.22); + transition: transform 0.15s ease; } - .clear-btn { - background: none; - border: none; - padding: 4px; - cursor: pointer; - color: var(--text-tertiary); - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; + &.on { + background: rgba(var(--primary-rgb), 0.9); + border-color: rgba(var(--primary-rgb), 0.95); - &:hover { - background: var(--bg-hover); - color: var(--text-primary); + .layout-prefix-switch-thumb { + transform: translateX(16px); } } } - .select-actions { + .secondary-btn { + border-radius: 7px; + padding: 6px 9px; + font-size: 11px; + gap: 4px; + flex-shrink: 0; + } +} + +.task-center-card { + min-width: 92px; + min-height: 42px; + margin-left: auto; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--card-bg); + color: var(--text-primary); + padding: 10px 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + flex-shrink: 0; + align-self: stretch; + transition: border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; + + &:hover { + border-color: var(--primary); + color: var(--primary); + transform: translateY(-1px); + } + + &.has-alert { + border-color: rgba(255, 77, 79, 0.28); + box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.08); + } +} + +.export-defaults-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.42); + display: flex; + align-items: center; + justify-content: center; + z-index: 2300; + padding: 20px; +} + +.export-defaults-modal { + width: min(720px, 100%); + max-height: min(80vh, 860px); + overflow: hidden; + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28); + display: flex; + flex-direction: column; +} + +.export-defaults-modal-header { + padding: 14px 16px 10px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + + h3 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + } + + p { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-tertiary); + } +} + +.export-defaults-modal-body { + padding: 16px; + overflow: auto; +} + +.export-defaults-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 0 16px 16px; +} + +.task-center-card-label { + line-height: 1; + white-space: nowrap; +} + +.task-center-card-badge { + min-width: 18px; + height: 18px; + border-radius: 999px; + background: #ff4d4f; + color: #fff; + font-size: 10px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 5px; + line-height: 1; + box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.16); + animation: exportTaskBadgePulse 1.2s ease-in-out infinite; +} + +.content-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 8px; + flex-shrink: 0; +} + +.content-card { + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--card-bg); + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; + + .card-header { display: flex; align-items: center; justify-content: space-between; - padding: 0 20px 12px; + gap: 8px; + } - .select-all-btn { - background: none; - border: none; - padding: 6px 12px; - font-size: 13px; - color: var(--primary); - cursor: pointer; - border-radius: 6px; + .card-title { + display: flex; + align-items: center; + gap: 5px; + font-size: 13px; + color: var(--text-primary); + font-weight: 600; + } - &:hover { - background: rgba(var(--primary-rgb), 0.1); + .card-title-meta { + color: var(--text-secondary); + font-size: 12px; + white-space: nowrap; + font-weight: 500; + } + + .card-refresh-hint { + color: var(--text-tertiary); + font-size: 11px; + white-space: nowrap; + } + + .card-stats { + display: grid; + grid-template-columns: 1fr; + gap: 3px; + + .stat-item { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + color: var(--text-secondary); + + strong { + color: var(--text-primary); + font-size: 13px; } } + } - .selected-count { - font-size: 13px; - color: var(--text-secondary); - padding: 4px 12px; - background: var(--bg-secondary); - border-radius: 12px; + .card-export-btn { + margin-top: auto; + border: 1px solid transparent; + border-radius: 7px; + padding: 7px 9px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + + &.primary { + background: var(--primary); + color: #fff; + border-color: transparent; } + + &.primary:hover { + background: var(--primary-hover); + } + + &.secondary { + background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary)); + color: var(--text-secondary); + border-color: color-mix(in srgb, var(--border-color) 85%, transparent); + } + + &.secondary:hover { + border-color: color-mix(in srgb, var(--primary) 28%, transparent); + color: var(--text-primary); + background: color-mix(in srgb, var(--bg-primary) 94%, var(--primary) 6%); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.86; + } + + &.running { + opacity: 0.65; + } + + &.primary.running { + background: var(--primary-hover); + } + } + + &.skeleton-card { + pointer-events: none; + + .card-stats { + gap: 10px; + } + } +} + +.count-loading { + color: var(--text-tertiary); + font-size: 12px; + font-weight: 500; + display: inline-flex; + align-items: baseline; + gap: 1px; +} + +.task-center-modal-overlay { + position: fixed; + top: 40px; + right: 0; + bottom: 0; + left: 0; + z-index: 1180; + background: rgba(15, 23, 42, 0.28); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 24px 20px; +} + +.task-center-modal { + width: min(980px, calc(100vw - 40px)); + max-height: calc(100vh - 72px); + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-secondary-solid, #ffffff); + box-shadow: 0 20px 48px rgba(0, 0, 0, 0.24); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.task-center-modal-header { + padding: 12px 14px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.task-center-modal-title { + min-width: 0; + + h3 { + margin: 0; + font-size: 16px; + color: var(--text-primary); + } + + span { + display: block; + margin-top: 3px; + font-size: 12px; + color: var(--text-secondary); + } +} + +.task-center-modal-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px 14px 14px; + background: var(--bg-secondary-solid, #ffffff); +} + +.task-empty { + padding: 12px; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 13px; + color: var(--text-secondary); +} + +.task-list { + display: grid; + gap: 8px; +} + +.task-card { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 10px; + display: flex; + gap: 10px; + align-items: flex-start; + background: var(--bg-secondary-solid, #ffffff); + + &.running { + border-color: var(--primary); + } + + &.paused { + border-color: rgba(250, 173, 20, 0.55); + } + + &.stopped { + border-color: rgba(148, 163, 184, 0.46); + } + + &.error { + border-color: rgba(255, 77, 79, 0.45); + } + + &.success { + border-color: rgba(82, 196, 26, 0.4); + } +} + +.task-main { + flex: 1; + min-width: 0; +} + +.task-title { + font-size: 13px; + color: var(--text-primary); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.task-meta { + margin-top: 2px; + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 11px; + color: var(--text-secondary); +} + +.task-status { + border-radius: 999px; + padding: 2px 8px; + font-weight: 600; + + &.queued { + background: rgba(var(--primary-rgb), 0.14); + color: var(--primary); + } + + &.running { + background: rgba(var(--primary-rgb), 0.2); + color: var(--primary); + } + + &.paused { + background: rgba(250, 173, 20, 0.2); + color: #d48806; + } + + &.stopped { + background: rgba(148, 163, 184, 0.2); + color: #64748b; + } + + &.success { + background: rgba(82, 196, 26, 0.18); + color: #52c41a; + } + + &.error { + background: rgba(255, 77, 79, 0.15); + color: #ff4d4f; + } +} + +.task-progress-bar { + margin-top: 8px; + height: 6px; + background: rgba(0, 0, 0, 0.08); + border-radius: 3px; + overflow: hidden; +} + +.task-progress-fill { + height: 100%; + background: var(--primary); + transition: width 0.2s ease; +} + +.task-progress-text { + margin-top: 4px; + font-size: 11px; + color: var(--text-secondary); +} + +.task-perf-summary { + margin-top: 6px; + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 11px; + color: var(--text-secondary); +} + +.task-perf-panel { + margin-top: 8px; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 8px; + background: var(--bg-secondary); + display: grid; + gap: 8px; +} + +.task-perf-title { + font-size: 12px; + color: var(--text-primary); + font-weight: 600; +} + +.task-perf-row { + display: grid; + gap: 4px; +} + +.task-perf-row-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--text-secondary); +} + +.task-perf-row-track { + height: 6px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +.task-perf-row-fill { + height: 100%; + background: var(--primary); +} + +.task-perf-empty { + font-size: 11px; + color: var(--text-secondary); +} + +.task-perf-session-list { + display: grid; + gap: 4px; +} + +.task-perf-session-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + font-size: 11px; + color: var(--text-secondary); +} + +.task-perf-session-rank { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-perf-session-time { + font-variant-numeric: tabular-nums; +} + +.task-error { + margin-top: 6px; + font-size: 12px; + color: #ff4d4f; +} + +.task-actions { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} + +.task-action-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-secondary); + min-height: 30px; + padding: 0 10px; + font-size: 12px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + white-space: nowrap; + + &:hover:not(:disabled) { + border-color: var(--primary); + color: var(--primary); + } + + &:disabled { + opacity: 0.65; + cursor: not-allowed; + } + + &.primary { + border-color: rgba(var(--primary-rgb), 0.35); + color: var(--primary); + } + + &.danger { + border-color: rgba(255, 77, 79, 0.36); + color: #ff4d4f; + } +} + +.session-table-section { + flex: 0 0 auto; + min-height: 420px; + display: flex; + flex-direction: column; + overflow: visible; +} + +.table-stage-hint { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 8px 12px 0; + padding: 6px 10px; + border-radius: 999px; + background: rgba(var(--primary-rgb), 0.1); + border: 1px solid rgba(var(--primary-rgb), 0.2); + color: var(--primary); + font-size: 12px; + width: fit-content; +} + +.table-toolbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; + padding: 10px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent); + background: color-mix(in srgb, var(--bg-primary) 82%, var(--bg-secondary)); +} + +.table-cache-meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + font-size: 12px; + + .meta-item { + color: var(--text-tertiary); + } + + .meta-item.syncing { + color: var(--primary); + display: inline-flex; + align-items: center; + gap: 4px; + } +} + +.table-tabs { + display: flex; + gap: 8px; + flex-wrap: nowrap; + align-items: center; + + .tab-btn { + flex: 0 0 auto; + width: auto; + max-width: max-content; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + padding: 7px 6px; + border-radius: 999px; + cursor: pointer; + font-size: 13px; + white-space: nowrap; + display: inline-flex; + align-items: center; + justify-content: center; + + .tab-btn-content { + display: inline-flex; + align-items: center; + gap: 4px; + line-height: 1; + } + + &.active { + border-color: var(--primary); + color: var(--primary); + background: rgba(var(--primary-rgb), 0.12); + } + } +} + +.animated-ellipsis { + display: inline-block; + width: 0; + overflow: hidden; + vertical-align: bottom; + animation: exportDots 1s steps(4, end) infinite; +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.search-input-wrap { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + min-width: 220px; + + input { + border: none; + background: transparent; + color: var(--text-primary); + font-size: 13px; + outline: none; + width: 180px; + } + + .clear-search { + border: none; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + display: flex; + } +} + +.selected-batch-actions { + display: flex; + align-items: center; + gap: 8px; + border: 1px dashed rgba(var(--primary-rgb), 0.45); + background: rgba(var(--primary-rgb), 0.06); + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + color: var(--text-secondary); +} + +.session-table-layout { + display: flex; + min-height: 0; + + .table-wrap { + flex: 1; + min-width: 0; + } +} + +.table-wrap { + --contacts-native-scrollbar-compensation: 18px; + --contacts-row-height: 76px; + --contacts-default-visible-rows: 10; + --contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows)); + --contacts-select-col-width: 34px; + --contacts-avatar-col-width: 44px; + --contacts-inline-padding: 12px; + --contacts-column-gap: 12px; + --contacts-name-text-width: 10em; + --contacts-main-col-width: calc(var(--contacts-avatar-col-width) + var(--contacts-column-gap) + var(--contacts-name-text-width)); + --contacts-left-sticky-width: calc(var(--contacts-select-col-width) + var(--contacts-main-col-width) + var(--contacts-column-gap)); + --contacts-message-col-width: 120px; + --contacts-media-col-width: 72px; + --contacts-action-col-width: 140px; + --contacts-table-min-width: 1200px; + overflow: hidden; + border: 1px solid var(--border-color); + border-radius: 10px; + min-height: 320px; + height: auto; + flex: 1; + display: flex; + flex-direction: column; + background: var(--bg-secondary); +} + +.table-wrap { + .table-scroll-shell { + overflow: hidden; + } + + .table-scroll-viewport { + min-height: 0; + overflow-x: auto; + overflow-y: visible; + scrollbar-width: none; + -ms-overflow-style: none; + background: var(--bg-secondary); + padding-bottom: var(--contacts-native-scrollbar-compensation); + margin-bottom: calc(-1 * var(--contacts-native-scrollbar-compensation)); + + &::-webkit-scrollbar { + display: none; + } + } + + .table-scroll-content { + min-width: max(100%, var(--contacts-table-min-width)); + } + + .session-table-sticky { + position: sticky; + top: 0; + z-index: 20; + background: var(--bg-secondary); } .loading-state, .empty-state { + width: 100%; + min-width: max(100%, var(--contacts-table-min-width)); flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; + background: var(--bg-secondary); color: var(--text-tertiary); font-size: 14px; @@ -154,61 +1793,913 @@ } } - .export-session-list { + .load-issue-state { + width: 100%; + min-width: max(100%, var(--contacts-table-min-width)); flex: 1; + padding: 14px; overflow-y: auto; - padding: 0 12px 12px; + background: var(--bg-secondary); + } + + .issue-card { + border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color)); + background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--bg-secondary)); + border-radius: 12px; + padding: 14px; + color: var(--text-primary); + } + + .issue-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary)); + margin-bottom: 8px; + } + + .issue-message { + margin: 0 0 8px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .issue-reason { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .issue-hints { + margin: 10px 0 0; + padding-left: 18px; + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.6; + } + + .issue-actions { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .issue-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; + padding: 7px 10px; + font-size: 12px; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + color: var(--text-primary); + border-color: var(--text-tertiary); + background: var(--bg-hover); + } + + &.primary { + background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary)); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + color: var(--primary); + } + } + + .issue-diagnostics { + margin-top: 12px; + border-radius: 8px; + background: color-mix(in srgb, var(--bg-secondary) 92%, var(--bg-primary)); + border: 1px dashed var(--border-color); + padding: 10px; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + } + + .contacts-list-header { + --contacts-header-bg: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary)); + display: flex; + align-items: center; + gap: var(--contacts-column-gap); + padding: 10px var(--contacts-inline-padding) 8px; + min-width: max(100%, var(--contacts-table-min-width)); + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent); + background: var(--contacts-header-bg); + font-size: 12px; + color: var(--text-tertiary); + font-weight: 600; + letter-spacing: 0.01em; + flex-shrink: 0; + + &.is-draggable { + cursor: grab; + } + + &.is-dragging { + cursor: grabbing; + user-select: none; + } + } + + .contacts-list-header-left { + display: flex; + align-items: center; + gap: var(--contacts-column-gap); + width: var(--contacts-left-sticky-width); + min-width: var(--contacts-left-sticky-width); + max-width: var(--contacts-left-sticky-width); + } + + .contacts-list-header-select { + width: var(--contacts-select-col-width); + min-width: var(--contacts-select-col-width); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .contacts-list-header-main { + flex: 0 0 var(--contacts-main-col-width); + width: var(--contacts-main-col-width); + min-width: var(--contacts-main-col-width); + max-width: var(--contacts-main-col-width); + display: flex; + align-items: center; + gap: 8px; + } + + .contacts-list-header-main-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .contacts-list-header-count { + width: var(--contacts-message-col-width); + min-width: var(--contacts-message-col-width); + display: flex; + align-items: center; + justify-content: center; + text-align: center; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + } + + .contacts-list-header-media { + width: var(--contacts-media-col-width); + min-width: var(--contacts-media-col-width); + display: flex; + align-items: center; + justify-content: center; + text-align: center; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + } + + .contacts-list-header-actions { + width: max(var(--contacts-action-col-width), 184px); + min-width: max(var(--contacts-action-col-width), 184px); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: nowrap; + flex-shrink: 0; + position: sticky; + right: 0; + z-index: 13; + background: var(--contacts-header-bg); + white-space: nowrap; + + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -8px; + width: 8px; + pointer-events: none; + background: linear-gradient(to right, transparent, var(--contacts-header-bg)); + } + } + + .contacts-list { + width: 100%; + min-width: max(100%, var(--contacts-table-min-width)); + flex: 1; + min-height: var(--contacts-default-list-height); + height: var(--contacts-default-list-height); + position: relative; + overflow-x: clip; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0 0 12px; + background: var(--bg-secondary); &::-webkit-scrollbar { width: 6px; } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { - background: var(--text-tertiary); - border-radius: 3px; - opacity: 0.3; + background: color-mix(in srgb, var(--text-tertiary) 72%, transparent); + border-radius: 999px; } } - .export-session-item { - display: flex; - align-items: center; - gap: 12px; - padding: 12px; - border-radius: 10px; + .contacts-virtuoso { + width: 100%; + } + + .table-bottom-scrollbar { + flex: 0 0 auto; + overflow-x: auto; + overflow-y: hidden; + background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary)); + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--text-tertiary) 70%, transparent) transparent; + + &::-webkit-scrollbar { + height: 10px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--text-tertiary) 70%, transparent); + } + } + + .table-bottom-scrollbar-inner { + height: 1px; + } + + .table-bottom-scrollbar { + height: 16px; + border-top: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + } + + .selection-clear-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 12px; + padding: 6px 10px; cursor: pointer; - transition: all 0.2s; + white-space: nowrap; - &:hover { - background: var(--bg-hover); + &:hover:not(:disabled) { + border-color: var(--text-tertiary); + color: var(--text-primary); } - &.selected { - background: rgba(var(--primary-rgb), 0.08); + &:disabled { + opacity: 0.65; + cursor: not-allowed; + } + } - .check-box { - background: var(--primary); - border-color: var(--primary); - color: #fff; - } + .selection-export-btn { + border: none; + border-radius: 8px; + padding: 6px 10px; + background: var(--primary); + color: #fff; + font-size: 12px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + flex-shrink: 0; + + &:hover:not(:disabled) { + background: var(--primary-hover); } - .check-box { - width: 20px; - height: 20px; - border: 2px solid var(--border-color); - border-radius: 6px; + .selection-export-count { + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.2); + color: #fff; + font-size: 11px; + font-weight: 700; display: flex; align-items: center; justify-content: center; - flex-shrink: 0; - transition: all 0.2s; + font-variant-numeric: tabular-nums; + } + } + + .contact-row { + position: static; + height: auto; + padding-bottom: 4px; + + &.selected .contact-item { + background: var(--contacts-row-bg); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 52%, transparent); } - .export-avatar { - width: 44px; - height: 44px; - border-radius: 10px; + &.selected .contact-item:hover { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 60%, transparent); + } + } + + .contact-item { + --contacts-row-bg: var(--bg-secondary); + display: flex; + align-items: center; + gap: var(--contacts-column-gap); + padding: 12px 6px 12px var(--contacts-inline-padding); + min-width: max(100%, var(--contacts-table-min-width)); + height: 72px; + box-sizing: border-box; + border-radius: 10px; + transition: all 0.2s; + cursor: default; + background: var(--contacts-row-bg); + box-shadow: inset 0 0 0 1px transparent; + + &:hover { + background: var(--contacts-row-bg); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-tertiary) 24%, transparent); + } + } + + .row-left-sticky { + position: sticky; + left: 0; + z-index: 11; + display: flex; + align-items: center; + align-self: stretch; + gap: var(--contacts-column-gap); + width: var(--contacts-left-sticky-width); + min-width: var(--contacts-left-sticky-width); + max-width: var(--contacts-left-sticky-width); + background: var(--contacts-row-bg); + } + + .row-select-cell { + width: var(--contacts-select-col-width); + min-width: var(--contacts-select-col-width); + display: flex; + justify-content: center; + flex-shrink: 0; + } + + .contact-avatar { + width: 44px; + height: 44px; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 16px; + font-weight: 600; + } + } + + .contact-info { + flex: 0 0 var(--contacts-name-text-width); + width: var(--contacts-name-text-width); + min-width: var(--contacts-name-text-width); + max-width: var(--contacts-name-text-width); + } + + .contact-name { + font-size: 14px; + color: var(--text-primary); + margin-bottom: 2px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .contact-remark { + font-size: 12px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .contact-type { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + padding: 4px 8px; + border-radius: 999px; + background: var(--bg-secondary); + color: var(--text-secondary); + flex-shrink: 0; + } + + .row-message-count { + width: var(--contacts-message-col-width); + min-width: var(--contacts-message-col-width); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + flex-shrink: 0; + text-align: center; + box-sizing: border-box; + } + + .row-media-metric { + width: var(--contacts-media-col-width); + min-width: var(--contacts-media-col-width); + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + text-align: center; + box-sizing: border-box; + } + + .row-media-metric-value { + margin: 0; + font-size: 12px; + line-height: 1.2; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 14px; + } + + .row-media-metric-icon { + color: var(--text-tertiary); + } + + .row-sns-metric-btn { + border: none; + background: transparent; + margin: 0; + padding: 0; + width: 100%; + min-height: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + line-height: 1.2; + color: var(--primary); + font-variant-numeric: tabular-nums; + cursor: pointer; + + &:hover { + color: var(--primary-hover); + text-decoration: underline; + text-underline-offset: 2px; + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 48%, transparent); + outline-offset: 2px; + border-radius: 4px; + } + + &.loading { + color: var(--text-tertiary); + } + + &:disabled { + color: var(--text-secondary); + cursor: default; + text-decoration: none; + opacity: 0.78; + } + } + + .row-mutual-friends-btn.ready { + color: #0f766e; + + &:hover:not(:disabled) { + color: #115e59; + } + } + + .row-message-stats { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + white-space: nowrap; + } + + .row-message-stat { + display: inline-flex; + align-items: baseline; + gap: 3px; + font-size: 11px; + color: var(--text-secondary); + min-width: 0; + + .label { + color: var(--text-tertiary); + flex-shrink: 0; + } + + &.total .label { + color: var(--text-secondary); + } + } + + .row-message-count-value { + margin: 0; + font-size: 12px; + line-height: 1.1; + color: var(--text-primary); + font-weight: 600; + font-variant-numeric: tabular-nums; + + &.muted { + font-size: 11px; + font-weight: 500; + color: var(--text-tertiary); + } + } + + .row-message-stat.total .row-message-count-value { + font-size: 13px; + } + + .row-open-chat-link { + border: none; + padding: 0; + margin: 0; + background: transparent; + color: var(--primary); + font-size: 12px; + line-height: 1.2; + font-weight: 600; + cursor: pointer; + + &:hover { + color: var(--primary-hover); + text-decoration: underline; + text-underline-offset: 2px; + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 30%, transparent); + outline-offset: 2px; + border-radius: 4px; + } + } +} + +.table-virtuoso { + height: 100%; +} + +.session-table, +.table-wrap table { + width: 100%; + min-width: 1300px; + border-collapse: separate; + border-spacing: 0; + background: var(--bg-secondary); + + thead th { + position: sticky; + top: 0; + background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary)); + z-index: 4; + font-size: 12px; + text-align: left; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + padding: 10px 10px; + white-space: nowrap; + } + + tbody td { + padding: 10px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); + font-size: 13px; + color: var(--text-primary); + vertical-align: middle; + white-space: nowrap; + } + + tbody tr:hover { + background: rgba(var(--primary-rgb), 0.03); + } + + .selected-row, + tbody tr:has(.select-icon-btn.checked) { + background: rgba(var(--primary-rgb), 0.08); + } + + .sticky-col { + position: sticky; + left: 0; + z-index: 5; + background: inherit; + } + + .sticky-right { + position: sticky; + right: 0; + z-index: 5; + background: inherit; + } +} + +.select-icon-btn { + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + + &:hover { + background: rgba(var(--primary-rgb), 0.1); + color: var(--primary); + } + + &.checked { + color: var(--primary); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.session-cell { + display: flex; + align-items: center; + gap: 10px; + min-width: 230px; + + .session-avatar { + width: 36px; + height: 36px; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 14px; + font-weight: 600; + } + } + + .session-name { + font-size: 13px; + color: var(--text-primary); + font-weight: 600; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + } + + .session-id { + margin-top: 2px; + font-size: 11px; + color: var(--text-tertiary); + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.row-action-cell { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: center; + align-self: stretch; + gap: 4px; + width: var(--contacts-action-col-width); + min-width: var(--contacts-action-col-width); + flex-shrink: 0; + position: sticky; + right: 0; + z-index: 10; + background: var(--contacts-row-bg); + + .row-action-main { + display: inline-flex; + align-items: flex-start; + gap: 6px; + position: relative; + z-index: 1; + + &.single-line { + align-items: center; + } + } + + .row-detail-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 7px 10px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + white-space: nowrap; + + &:hover { + border-color: var(--text-tertiary); + color: var(--text-primary); + background: var(--bg-hover); + } + + &.active { + border-color: var(--primary); + color: var(--primary); + background: rgba(var(--primary-rgb), 0.12); + } + } + + .row-export-action-stack { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 2px; + min-width: 84px; + + &.single-line { + min-height: 28px; + justify-content: center; + } + } + + .row-export-link { + border: none; + padding: 0; + margin: 0; + background: transparent; + color: var(--primary); + font-size: 12px; + cursor: pointer; + line-height: 1.2; + font-weight: 600; + white-space: nowrap; + + &:hover:not(:disabled) { + color: var(--primary-hover); + text-decoration: underline; + text-underline-offset: 2px; + } + + &:disabled { + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 30%, transparent); + outline-offset: 2px; + border-radius: 4px; + } + + &.state-running { + cursor: progress; + } + + &.state-disabled { + color: var(--text-tertiary); + text-decoration: none; + } + } + + .row-export-time { + font-size: 11px; + line-height: 1.2; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + text-align: center; + } + + .row-export-link.state-running + .row-export-time { + color: var(--primary); + font-weight: 600; + } + + .row-export-link.state-running:hover:not(:disabled), + .row-export-link.state-running:focus-visible { + color: var(--primary); + text-decoration: none; + } + + .row-export-link.state-disabled:hover:not(:disabled), + .row-export-link.state-disabled:focus-visible { + color: var(--text-tertiary); + text-decoration: none; + } +} + +.export-session-detail-overlay { + position: fixed; + top: 40px; + right: 0; + bottom: 0; + left: 0; + z-index: 1100; + display: flex; + justify-content: flex-end; + background: rgba(15, 23, 42, 0.24); +} + +.export-session-detail-panel { + width: min(360px, calc(100vw - 16px)); + height: calc(100vh - 40px); + border-left: 1px solid var(--border-color); + border-radius: 0; + background: var(--bg-secondary-solid, #ffffff); + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: -12px 0 30px rgba(0, 0, 0, 0.18); + + .detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px; + border-bottom: 1px solid var(--border-color); + + .detail-header-main { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + } + + .detail-header-avatar { + width: 32px; + height: 32px; + border-radius: 8px; background: linear-gradient(135deg, var(--primary), var(--primary-hover)); display: flex; align-items: center; @@ -224,228 +2715,843 @@ span { color: #fff; - font-size: 16px; + font-size: 13px; font-weight: 600; } } - .export-session-info { - flex: 1; + .detail-header-meta { min-width: 0; + + h4 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } - .export-session-name { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .export-session-summary { - font-size: 12px; + .detail-header-id { + margin-top: 3px; + font-size: 11px; color: var(--text-tertiary); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - margin-top: 2px; + white-space: nowrap; + } + + .close-btn { + border: none; + background: transparent; + color: var(--text-secondary); + width: 26px; + height: 26px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } } } - // 右侧设置面板 - .settings-panel { + .detail-loading, + .detail-empty { flex: 1; display: flex; - flex-direction: column; - overflow: hidden; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--text-secondary); + font-size: 13px; + padding: 14px; } - .settings-content { + .detail-content { flex: 1; + min-height: 0; overflow-y: auto; - padding: 20px 24px; - - &::-webkit-scrollbar { - width: 6px; - } - - &::-webkit-scrollbar-thumb { - background: var(--text-tertiary); - border-radius: 3px; - } + padding: 14px; } - .setting-section { - margin-bottom: 28px; + .detail-section { + margin-bottom: 18px; - h3 { - font-size: 13px; + &:last-child { + margin-bottom: 0; + } + + .section-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; font-weight: 600; color: var(--text-secondary); + margin-bottom: 10px; text-transform: uppercase; - letter-spacing: 0.5px; - margin: 0 0 14px; + letter-spacing: 0.4px; + } + + .detail-stats-meta { + margin-top: -4px; + margin-bottom: 10px; + font-size: 12px; + color: var(--text-tertiary); } } - .format-options { + .detail-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; + border-bottom: 1px solid var(--border-color); + font-size: 13px; + + &:last-child { + border-bottom: none; + } + + .label { + color: var(--text-secondary); + flex-shrink: 0; + } + + .value { + flex: 1; + text-align: right; + color: var(--text-primary); + word-break: break-all; + user-select: text; + + &.highlight { + color: var(--primary); + font-weight: 600; + } + } + + .detail-inline-btn { + border: none; + background: var(--bg-secondary); + color: var(--primary); + border-radius: 6px; + padding: 4px 8px; + font-size: 12px; + line-height: 1; + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 0.7; + } + + &:hover:not(:disabled) { + background: var(--bg-hover); + } + } + + .detail-sns-entry-btn { + white-space: nowrap; + } + + .copy-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; + + &:hover { + background: var(--bg-secondary); + color: var(--text-primary); + } + } + + &:hover .copy-btn { + opacity: 1; + } + } + + .detail-record-empty { + padding: 10px 12px; + border-radius: 8px; + background: var(--bg-secondary); + font-size: 12px; + color: var(--text-secondary); + } + + .detail-record-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .detail-record-item { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 8px 10px; + background: var(--bg-primary); + + .record-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + font-size: 12px; + + .label { + color: var(--text-secondary); + width: 56px; + flex-shrink: 0; + } + + .value { + color: var(--text-primary); + flex: 1; + text-align: right; + word-break: break-all; + + &.path { + text-align: left; + } + } + + .detail-inline-btn { + flex-shrink: 0; + } + } + } + + .table-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .detail-table-placeholder { + padding: 10px 12px; + border-radius: 8px; + background: var(--bg-secondary); + font-size: 12px; + color: var(--text-secondary); + } + + .table-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-radius: 8px; + background: var(--bg-secondary); + font-size: 12px; + + .db-name { + color: var(--text-primary); + font-weight: 500; + } + + .table-count { + color: var(--text-secondary); + } + } +} + +.export-session-sns-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; + background: rgba(15, 23, 42, 0.38); +} + +.export-session-sns-dialog { + width: min(760px, 100%); + max-height: min(86vh, 860px); + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-secondary-solid, #ffffff); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24); + display: flex; + flex-direction: column; + overflow: hidden; + + .sns-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-color); + } + + .sns-dialog-header-main { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + } + + .sns-dialog-avatar { + width: 42px; + height: 42px; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + overflow: hidden; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 14px; + font-weight: 600; + } + } + + .sns-dialog-meta { + min-width: 0; + + h4 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .sns-dialog-username { + margin-top: 2px; + font-size: 12px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .sns-dialog-stats { + margin-top: 4px; + font-size: 12px; + color: var(--text-secondary); + } + + .sns-dialog-header-actions { + display: flex; + align-items: flex-start; + gap: 8px; + flex-shrink: 0; + } + + .sns-dialog-rank-switch { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + } + + .sns-dialog-rank-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-secondary); + height: 28px; + padding: 0 10px; + font-size: 12px; + line-height: 1; + cursor: pointer; + white-space: nowrap; + + &:hover { + color: var(--text-primary); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + } + + &.active { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 52%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + } + } + + .sns-dialog-rank-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 248px; + max-height: calc((28px * 15) + 16px); + overflow-y: auto; + border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color)); + border-radius: 10px; + background: var(--bg-primary); + box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18); + padding: 8px; + z-index: 12; + } + + .sns-dialog-rank-empty { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; + text-align: center; + padding: 6px 0; + } + + .sns-dialog-rank-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 28px; + padding: 4px 0 8px; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + } + + .sns-dialog-rank-row { display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 0 4px; + border-radius: 7px; + + &:hover { + background: var(--bg-hover); + } + } + + .sns-dialog-rank-index { + font-size: 12px; + color: var(--text-tertiary); + text-align: right; + font-variant-numeric: tabular-nums; + } + + .sns-dialog-rank-name { + font-size: 12px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .sns-dialog-rank-count { + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .close-btn { + border: none; + background: transparent; + color: var(--text-secondary); + width: 28px; + height: 28px; + border-radius: 7px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .sns-dialog-tip { + padding: 10px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent); + background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary)); + font-size: 12px; + line-height: 1.6; + color: var(--text-secondary); + word-break: break-word; + } + + .sns-dialog-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px 16px 14px; + } + + .export-session-sns-posts-list { + gap: 14px; + } + + .post-header-actions { + display: none; + } + + .sns-post-list { + display: flex; + flex-direction: column; gap: 12px; } - .format-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - padding: 20px 16px; - background: var(--bg-secondary); - border: 2px solid transparent; - border-radius: 12px; - cursor: pointer; - transition: all 0.2s; - text-align: center; - - &:hover { - background: var(--bg-hover); - } - - &.active { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.05); - - svg { - color: var(--primary); - } - } - - svg { - color: var(--text-secondary); - } - - .format-label { - font-size: 14px; - font-weight: 600; - color: var(--text-primary); - } - - .format-desc { - font-size: 11px; - color: var(--text-tertiary); - line-height: 1.4; - } - } - - .time-range-picker-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; - cursor: pointer; - transition: background 0.2s; - background: transparent; - - &:hover { - background: var(--bg-hover); - } - - .time-picker-info { - display: flex; - align-items: center; - gap: 10px; - font-size: 14px; - color: var(--text-primary); - - svg { - color: var(--primary); - } - } - - svg { - color: var(--text-tertiary); - } - } - - .select-field { - position: relative; - } - - .select-trigger { - width: 100%; - padding: 10px 16px; + .sns-post-card { + border: 1px solid var(--border-color); + background: var(--bg-primary); + border-radius: 10px; + padding: 10px 11px; + } + + .sns-post-time { + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 6px; + } + + .sns-post-content { + white-space: pre-wrap; + word-break: break-word; + font-size: 13px; + color: var(--text-primary); + line-height: 1.55; + } + + .sns-post-media-grid { + margin-top: 8px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + } + + .sns-post-media-item { + border: none; + padding: 0; + border-radius: 8px; + overflow: hidden; + background: var(--bg-secondary); + position: relative; + cursor: pointer; + aspect-ratio: 1 / 1; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } + + .sns-post-media-video-tag { + position: absolute; + right: 6px; + bottom: 6px; + background: rgba(0, 0, 0, 0.64); + color: #fff; + border-radius: 5px; + font-size: 11px; + line-height: 1; + padding: 3px 5px; + } + + .sns-dialog-status { + padding: 16px 0; + text-align: center; + color: var(--text-secondary); + font-size: 13px; + + &.empty { + color: var(--text-tertiary); + } + } + + .sns-dialog-load-more { + display: block; + margin: 12px auto 0; border: 1px solid var(--border-color); - border-radius: 9999px; - font-size: 14px; background: var(--bg-primary); color: var(--text-primary); - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; + border-radius: 8px; + padding: 8px 16px; + font-size: 13px; cursor: pointer; - transition: all 0.2s; - &:hover { - border-color: var(--text-tertiary); + &:disabled { + opacity: 0.7; + cursor: not-allowed; } - &.open { - border-color: var(--primary); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); + &:hover:not(:disabled) { + background: var(--bg-hover); } } +} - .select-value { - flex: 1; - min-width: 0; - text-align: left; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } +.table-state { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 120px; + color: var(--text-secondary); +} - .select-dropdown { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; - background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); - border: 1px solid var(--border-color); - border-radius: 12px; - padding: 6px; - box-shadow: var(--shadow-md); - z-index: 20; - max-height: 260px; - overflow-y: auto; - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); - } +.table-skeleton-list { + display: grid; + gap: 8px; + padding: 4px 0; +} - .select-option { - width: 100%; - text-align: left; - display: flex; - flex-direction: column; - gap: 4px; - padding: 10px 12px; - border: none; - border-radius: 10px; - background: transparent; - cursor: pointer; - transition: all 0.15s; +.table-skeleton-item { + display: grid; + grid-template-columns: 20px 36px minmax(160px, 2fr) repeat(3, minmax(80px, 1fr)); + align-items: center; + gap: 12px; + padding: 10px 8px; + border-radius: 8px; + background: color-mix(in srgb, var(--bg-secondary) 80%, transparent); +} + +.skeleton-shimmer { + position: relative; + overflow: hidden; + border-radius: 8px; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.35) 50%, + rgba(255, 255, 255, 0.08) 100% + ); + background-size: 220% 100%; + animation: exportSkeletonShimmer 1.2s linear infinite; +} + +.skeleton-dot { + width: 16px; + height: 16px; + border-radius: 6px; +} + +.skeleton-avatar { + width: 36px; + height: 36px; + border-radius: 8px; +} + +.skeleton-line { + display: inline-block; + height: 12px; +} + +.skeleton-line.w-12 { width: 48%; min-width: 42px; } +.skeleton-line.w-20 { width: 22%; min-width: 36px; } +.skeleton-line.w-30 { width: 32%; min-width: 120px; } +.skeleton-line.w-40 { width: 45%; min-width: 80px; } +.skeleton-line.w-60 { width: 62%; min-width: 110px; } +.skeleton-line.w-100 { width: 100%; } +.skeleton-line.h-32 { height: 32px; border-radius: 10px; } + +.export-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 1000; +} + +.export-dialog { + width: min(1080px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 14px 14px 12px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.dialog-body { + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 10px; + padding-right: 14px; +} + +.dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + + h3 { + margin: 0; color: var(--text-primary); - font-size: 14px; + font-size: 18px; + } +} - &:hover { - background: var(--bg-tertiary); - } +.dialog-header-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} - &.active { - background: color-mix(in srgb, var(--primary) 12%, transparent); - color: var(--primary); - } +.dialog-header-note { + font-size: 12px; + line-height: 1.45; + color: var(--text-secondary); +} + +.close-icon-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-secondary); +} + +.dialog-section { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 12px; + background: var(--bg-secondary); + + h4 { + margin: 0 0 8px; + font-size: 13px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.4px; + } +} + +.section-header-action { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + + h4 { + margin-bottom: 0; + } +} + +.time-range-trigger { + border: 1px solid var(--border-color); + background: var(--bg-primary); + border-radius: 999px; + color: var(--text-primary); + font-size: 12px; + min-height: 32px; + padding: 0 10px; + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + + &:hover { + border-color: rgba(var(--primary-rgb), 0.45); + color: var(--primary); + } + + .time-range-arrow { + color: var(--text-tertiary); + font-weight: 700; + line-height: 1; + } +} + +.dialog-format-select { + position: relative; + margin-left: auto; +} + +.dialog-format-trigger { + justify-content: flex-end; +} + +.dialog-format-trigger-label { + text-align: right; +} + +.dialog-format-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + width: min(360px, calc(100vw - 64px)); + background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 6px; + box-shadow: var(--shadow-md); + z-index: 120; + max-height: 320px; + overflow-y: auto; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); +} + +.dialog-format-option { + width: 100%; + text-align: left; + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border: none; + border-radius: 10px; + background: transparent; + cursor: pointer; + transition: all 0.15s; + color: var(--text-primary); + font-size: 14px; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); } .option-label { @@ -457,679 +3563,522 @@ color: var(--text-tertiary); } - .select-option.active .option-desc { + &.active .option-desc { color: var(--primary); } +} - .media-options { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin-top: 12px; - padding-left: 28px; - } +.scope-tag-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} - .folder-select { - display: flex; - align-items: center; - gap: 12px; - padding: 14px 16px; - background: var(--bg-secondary); - border: 1px dashed var(--border-color); - border-radius: 10px; - cursor: pointer; - transition: all 0.2s; +.scope-tag { + border-radius: 999px; + background: rgba(var(--primary-rgb), 0.15); + color: var(--primary); + padding: 4px 10px; + font-size: 12px; + font-weight: 600; +} - &:hover { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.02); - } +.scope-count { + font-size: 12px; + color: var(--text-secondary); +} - svg { - color: var(--primary); - } +.scope-list { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 120px; + overflow: auto; +} - .folder-path { - flex: 1; - font-size: 13px; - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } +.scope-item { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 999px; + padding: 4px 9px; + font-size: 12px; + color: var(--text-primary); +} - .export-path-display { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 16px; - background: var(--bg-secondary); - border-radius: 10px; +.format-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 6px; +} + +.format-note { + margin: 0 0 8px; + font-size: 12px; + line-height: 1.45; + color: var(--text-secondary); +} + +.format-card { + width: 100%; + min-height: 0; + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 8px 10px; + text-align: left; + background: var(--bg-primary); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + + .format-label { font-size: 13px; - color: var(--text-primary); - - svg { - color: var(--primary); - flex-shrink: 0; - } - - span { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .path-hint { - font-size: 12px; - color: var(--text-tertiary); - margin: 8px 0 0; - } - - .select-folder-btn { - width: 100%; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 10px 16px; - margin-top: 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - font-size: 13px; - font-weight: 500; - color: var(--text-primary); - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - border-color: var(--primary); - color: var(--primary); - - svg { - color: var(--primary); - } - } - - &:active { - transform: scale(0.98); - } - - svg { - color: var(--text-secondary); - transition: color 0.2s; - } - } - - .export-action { - padding: 20px 24px; - border-top: 1px solid var(--border-color); - } - - .export-btn { - width: 100%; - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - padding: 14px 24px; - background: var(--primary); - color: #fff; - border: none; - border-radius: 12px; - font-size: 15px; font-weight: 600; - cursor: pointer; - transition: all 0.2s; - - &:hover:not(:disabled) { - background: var(--primary-hover); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .spin { - animation: exportSpin 1s linear infinite; - } + color: var(--text-primary); + line-height: 1.35; } - // 导出进度弹窗 - .export-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); + .format-desc { + margin-top: 1px; + font-size: 11px; + color: var(--text-tertiary); + line-height: 1.35; + } + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + } +} + +.switch-row { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; + color: var(--text-primary); +} + +.date-range-row { + margin-top: 10px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + + label { display: flex; + flex-direction: column; + gap: 5px; + font-size: 12px; + color: var(--text-secondary); + } + + input { + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + padding: 8px; + } +} + +.media-check-grid { + margin-top: 10px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 16px; + + label { + display: inline-flex; align-items: center; - justify-content: center; - z-index: 1000; + gap: 6px; + font-size: 12px; + color: var(--text-primary); + white-space: nowrap; } - .export-progress-modal { - background: var(--card-bg); - padding: 32px 40px; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + input[type='checkbox'] { + accent-color: var(--primary); + } +} + +.dialog-switch-row { + margin-top: 2px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.dialog-switch-copy { + min-width: 0; + + h4 { + margin: 0; + } + + .format-note { + margin-top: 4px; + margin-bottom: 0; + } +} + +.dialog-switch { + position: relative; + flex-shrink: 0; + width: 46px; + height: 26px; + border: none; + border-radius: 999px; + background: color-mix(in srgb, var(--text-tertiary) 45%, transparent); + cursor: pointer; + transition: background 0.2s ease; + + &.on { + background: var(--primary); + } +} + +.dialog-switch-thumb { + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); + transition: transform 0.2s ease; +} + +.dialog-switch.on .dialog-switch-thumb { + transform: translateX(20px); +} + +.display-name-options { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; +} + +.display-name-item { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 8px; + width: 100%; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--bg-primary); + text-align: left; + cursor: pointer; + color: inherit; + font: inherit; + appearance: none; + -webkit-appearance: none; + + &:focus-visible { + outline: 2px solid rgba(var(--primary-rgb), 0.35); + outline-offset: 1px; + } + + span { + font-size: 12px; + color: var(--text-primary); + font-weight: 600; + } + + small { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.4; + } + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + } +} + +.dialog-actions { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: 8px; + flex-shrink: 0; + background: var(--card-bg); +} + +.primary-btn, +.secondary-btn { + border-radius: 8px; + padding: 7px 12px; + font-size: 12px; + font-weight: 600; + border: 1px solid var(--border-color); + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.primary-btn { + border-color: var(--primary); + background: var(--primary); + color: #fff; + + &:hover { + background: var(--primary-hover); + } + + &:disabled { + opacity: 0.65; + cursor: not-allowed; + } +} + +.secondary-btn { + background: var(--bg-secondary); + color: var(--text-primary); + + &:hover { + border-color: var(--primary); + color: var(--primary); + } +} + +.time-range-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 1015; +} + +.time-range-dialog { + width: min(480px, calc(100vw - 32px)); + max-height: calc(100vh - 64px); + overflow-y: auto; + border-radius: 12px; + border: 1px solid var(--border-color); + background: var(--bg-secondary-solid, var(--bg-primary)); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.time-range-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + + h4 { + margin: 0; + font-size: 14px; + color: var(--text-primary); + } +} + +.time-range-preset-list { + display: flex; + flex-wrap: nowrap; + gap: 4px; + overflow-x: auto; + padding-bottom: 2px; + + &::-webkit-scrollbar { + height: 4px; + } +} + +.time-range-preset-item { + flex: 0 0 auto; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + min-height: 30px; + padding: 0 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + font-size: 11px; + cursor: pointer; + white-space: nowrap; + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + color: var(--primary); + } +} + +.time-range-calendar-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.time-range-mode-banner { + border-radius: 8px; + padding: 6px 8px; + font-size: 11px; + line-height: 1.4; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + + &.range { + border-color: rgba(var(--primary-rgb), 0.4); + background: rgba(var(--primary-rgb), 0.1); + color: var(--primary); + } +} + +.time-range-calendar-panel { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + padding: 7px; +} + +.time-range-calendar-panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; +} + +.time-range-calendar-date-label { + display: flex; + flex-direction: column; + gap: 2px; + + span { + font-size: 11px; + color: var(--text-secondary); + } + + small { + font-size: 10px; + color: var(--text-tertiary); + } +} + +.time-range-date-input { + width: 100%; + min-width: 0; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + height: 24px; + padding: 0 7px; + font-size: 11px; + + &.invalid { + border-color: #e84d4d; + box-shadow: 0 0 0 1px rgba(232, 77, 77, 0.2); + } +} + +.time-range-calendar-nav { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--text-primary); + + button { + width: 20px; + height: 20px; + border-radius: 5px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + padding: 0; + line-height: 1; + } +} + +.time-range-calendar-weekdays { + margin-top: 6px; + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + + span { text-align: center; - min-width: 320px; + font-size: 10px; + color: var(--text-tertiary); + } +} - .progress-spinner { - margin-bottom: 20px; - color: var(--primary); +.time-range-calendar-days { + margin-top: 4px; + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} - .spin { - animation: exportSpin 1s linear infinite; - } - } +.time-range-calendar-day { + border: 1px solid transparent; + border-radius: 6px; + min-height: 20px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 10px; + cursor: pointer; + padding: 0; - h3 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 8px; - } - - .progress-text { - font-size: 14px; - color: var(--text-secondary); - margin: 0 0 20px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .progress-bar { - height: 6px; - background: var(--bg-secondary); - border-radius: 3px; - overflow: hidden; - margin-bottom: 12px; - - .progress-fill { - height: 100%; - background: var(--primary); - border-radius: 3px; - transition: width 0.3s ease; - } - } - - .progress-count { - font-size: 13px; - color: var(--text-tertiary); - margin: 0; - } + &.outside { + color: var(--text-quaternary); + opacity: 0.75; } - .export-layout-modal { - background: var(--card-bg); - padding: 28px 32px; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - text-align: center; - width: min(520px, 90vw); + &.selected { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.14); + color: var(--primary); + font-weight: 600; + } +} - h3 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 8px; - } +.time-range-custom-row { + display: none; - .layout-subtitle { - font-size: 14px; - color: var(--text-secondary); - margin: 0 0 20px; - } - - .layout-options { - display: grid; - gap: 12px; - } - - .layout-option-btn { - display: flex; - flex-direction: column; - gap: 6px; - padding: 14px 18px; - border-radius: 12px; - border: 1px solid var(--border-color); - background: var(--bg-secondary); - text-align: left; - cursor: pointer; - transition: all 0.2s; - - &:hover { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.08); - } - - &.primary { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.12); - } - - .layout-title { - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - } - - .layout-desc { - font-size: 12px; - color: var(--text-tertiary); - } - } - - .layout-actions { - margin-top: 18px; - display: flex; - justify-content: center; - } - - .layout-cancel-btn { - padding: 8px 20px; - border-radius: 8px; - border: 1px solid var(--border-color); - background: var(--bg-secondary); - color: var(--text-primary); - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - } - } + label { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 12px; + color: var(--text-secondary); } - .export-result-modal { - background: var(--card-bg); - padding: 32px 40px; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - text-align: center; - min-width: 320px; - - .result-icon { - margin-bottom: 16px; - - &.success { - color: #52c41a; - } - - &.error { - color: #ff4d4f; - } - } - - h3 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 8px; - } - - .result-text { - font-size: 14px; - color: var(--text-secondary); - margin: 0 0 24px; - - &.error { - color: #ff4d4f; - } - } - - .result-actions { - display: flex; - gap: 12px; - justify-content: center; - - button { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 10px 20px; - border-radius: 8px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - } - - .open-folder-btn { - background: var(--primary); - color: #fff; - border: none; - - &:hover { - background: var(--primary-hover); - } - } - - .close-btn { - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border-color); - - &:hover { - background: var(--bg-hover); - } - } - } + input { + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + padding: 8px; } +} - .date-picker-modal { - background: var(--card-bg); - padding: 28px 32px; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - width: 420px; - - h3 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 20px; - } - - .quick-select { - display: flex; - gap: 8px; - margin-bottom: 20px; - - .quick-btn { - flex: 1; - padding: 10px 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - font-size: 13px; - font-weight: 500; - color: var(--text-primary); - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - border-color: var(--primary); - color: var(--primary); - } - - &:active { - transform: scale(0.98); - } - } - } - - .date-display { - display: flex; - align-items: center; - gap: 16px; - padding: 20px; - background: var(--bg-secondary); - border-radius: 12px; - margin-bottom: 24px; - - .date-display-item { - flex: 1; - display: flex; - flex-direction: column; - gap: 6px; - padding: 8px 12px; - border-radius: 8px; - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: rgba(var(--primary-rgb), 0.05); - } - - &.active { - background: rgba(var(--primary-rgb), 0.1); - border: 1px solid var(--primary); - } - - .date-label { - font-size: 12px; - color: var(--text-tertiary); - font-weight: 500; - } - - .date-value { - font-size: 15px; - color: var(--text-primary); - font-weight: 600; - } - } - - .date-separator { - font-size: 14px; - color: var(--text-tertiary); - padding: 0 4px; - } - } - - .calendar-container { - margin-bottom: 20px; - } - - .calendar-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; - padding: 0 4px; - - .calendar-nav-btn { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - cursor: pointer; - color: var(--text-secondary); - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - border-color: var(--primary); - color: var(--primary); - } - - &:active { - transform: scale(0.95); - } - } - - .calendar-month { - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - - &.clickable { - cursor: pointer; - border-radius: 6px; - padding: 2px 8px; - transition: all 0.15s; - - &:hover { - background: var(--bg-hover); - color: var(--primary); - } - } - } - } - - .calendar-weekdays { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 4px; - margin-bottom: 8px; - - .calendar-weekday { - text-align: center; - font-size: 12px; - font-weight: 500; - color: var(--text-tertiary); - padding: 8px 0; - } - } - - .calendar-days { - display: grid; - grid-template-columns: repeat(7, 1fr); - grid-template-rows: repeat(6, 40px); - gap: 4px; - - .calendar-day { - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - color: var(--text-primary); - border-radius: 8px; - cursor: pointer; - transition: all 0.2s; - position: relative; - - &.empty { - cursor: default; - } - - &:not(.empty):hover { - background: var(--bg-hover); - } - - &.in-range { - background: rgba(var(--primary-rgb), 0.08); - } - - &.start, - &.end { - background: var(--primary); - color: #fff; - font-weight: 600; - - &:hover { - background: var(--primary-hover); - } - } - } - } - - .year-month-picker { - padding: 4px 0; - - .year-selector { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; - - .year-label { - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - } - - .calendar-nav-btn { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - cursor: pointer; - color: var(--text-secondary); - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - border-color: var(--primary); - color: var(--primary); - } - } - } - - .month-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; - - .month-btn { - padding: 10px 0; - border: none; - background: transparent; - border-radius: 8px; - cursor: pointer; - font-size: 13px; - color: var(--text-secondary); - transition: all 0.15s; - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - &.active { - background: var(--primary); - color: #fff; - } - } - } - } - - .date-picker-actions { - display: flex; - gap: 12px; - justify-content: flex-end; - - button { - padding: 10px 20px; - border-radius: 8px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - - &:active { - transform: scale(0.98); - } - } - - .cancel-btn { - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border-color); - - &:hover { - background: var(--bg-hover); - } - } - - .confirm-btn { - background: var(--primary); - color: #fff; - border: none; - - &:hover { - background: var(--primary-hover); - } - } - } - } +.time-range-dialog-actions { + display: flex; + justify-content: flex-end; } @keyframes exportSpin { @@ -1142,93 +4091,276 @@ } } -// 媒体导出选项卡片样式 -.setting-subtitle { - font-size: 12px; - color: var(--text-tertiary); - margin: 4px 0 12px 0; +@keyframes exportSkeletonShimmer { + 0% { + background-position: 220% 0; + } + 100% { + background-position: -20% 0; + } } -.media-options-card { - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 12px; - overflow: hidden; +@keyframes exportDots { + 0% { + width: 0; + } + 100% { + width: 1.8em; + } } -.media-switch-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; +@keyframes exportTaskBadgePulse { + 0% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.35); + } + 70% { + transform: scale(1.02); + box-shadow: 0 0 0 6px rgba(255, 77, 79, 0); + } + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(255, 77, 79, 0); + } } -.media-switch-info { - display: flex; - flex-direction: column; - gap: 2px; -} - -.media-switch-title { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); -} - -.media-switch-desc { - font-size: 11px; - color: var(--text-tertiary); -} - -.media-option-divider { - height: 1px; - background: var(--border-color); - margin-left: 16px; -} - -.media-checkbox-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - cursor: pointer; - transition: background 0.2s; - - &:hover:not(.disabled) { - background: var(--bg-hover); +@media (max-width: 1360px) { + .export-top-bar { + gap: 10px; } - &.disabled { - opacity: 0.5; - cursor: not-allowed; + .global-export-controls { + padding: 10px; + gap: 8px; + flex-basis: 920px; + width: min(920px, 100%); + grid-template-columns: minmax(0, 1.35fr) minmax(220px, 1fr) auto; } - input[type="checkbox"] { - width: 18px; - height: 18px; - accent-color: var(--primary); - cursor: pointer; + .format-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + } - &:disabled { - cursor: not-allowed; + .display-name-options { + grid-template-columns: 1fr; + } + + .media-check-grid { + gap: 8px 12px; + } +} + +@media (max-width: 760px) { + .export-top-bar { + flex-direction: column; + align-items: stretch; + } + + .global-export-controls { + flex: 1 1 auto; + width: 100%; + grid-template-columns: 1fr; + align-items: stretch; + } + + .global-export-controls .path-control, + .global-export-controls .write-layout-control { + align-items: stretch; + flex-direction: column; + gap: 4px; + } + + .global-export-controls .control-label { + width: auto; + flex-basis: auto; + } + + .task-center-card { + align-self: auto; + width: 100%; + justify-content: space-between; + } + + .dialog-switch-row { + align-items: flex-start; + } + + .export-defaults-modal { + width: min(92vw, 720px); + } +} + +@media (max-width: 720px) { + .export-section-title { + font-size: 14px; + } + + .session-load-detail-entry { + margin-left: 0; + } + + .session-load-detail-modal { + width: min(94vw, 820px); + } + + .session-mutual-friends-modal { + width: min(94vw, 760px); + max-height: 86vh; + } + + .session-mutual-friends-row { + grid-template-columns: 30px minmax(88px, 0.9fr) max-content 44px 74px; + gap: 8px; + font-size: 12px; + } + + .session-mutual-friends-desc { + display: none; + } + + .session-load-detail-row { + grid-template-columns: minmax(68px, 0.72fr) minmax(232px, 1.6fr) minmax(80px, 0.72fr) minmax(80px, 0.72fr); + min-width: 560px; + } + + .table-wrap { + --contacts-inline-padding: 10px; + --contacts-name-text-width: 10em; + --contacts-main-col-width: calc(44px + 10px + var(--contacts-name-text-width)); + --contacts-message-col-width: 104px; + --contacts-media-col-width: 62px; + --contacts-action-col-width: 140px; + } + + .table-wrap .contacts-list-header { + gap: 8px; + padding: 8px var(--contacts-inline-padding) 6px; + } + + .table-wrap .contacts-list-header-main { + gap: 6px; + } + + .table-wrap .contacts-list-header-actions { + gap: 6px; + } + + .table-wrap .contacts-list { + padding: 0 0 10px; + min-height: 300px; + height: min(56vh, 560px); + } + + .table-wrap .row-message-count { + min-width: var(--contacts-message-col-width); + } + + .table-wrap .row-media-metric { + min-width: var(--contacts-media-col-width); + } + + .table-wrap .row-message-stats { + gap: 6px; + } + + .table-wrap .row-message-stat { + font-size: 10px; + } + + .table-wrap .row-message-count-value { + font-size: 11px; + } + + .table-wrap .row-media-metric-value { + font-size: 11px; + } + + .table-wrap .row-message-stat.total .row-message-count-value { + font-size: 12px; + } + + .table-wrap .row-open-chat-link, + .table-wrap .row-export-link { + font-size: 11px; + } + + .export-dialog-overlay { + padding: 10px; + } + + .export-dialog { + width: calc(100vw - 20px); + max-height: calc(100vh - 20px); + padding: 12px 10px 10px; + } + + .format-grid { + grid-template-columns: 1fr; + } + + .date-range-row { + grid-template-columns: 1fr; + } + + .time-range-preset-list { + gap: 4px; + } + + .time-range-calendar-grid { + grid-template-columns: 1fr; + } + + .task-center-modal-overlay { + padding: 12px 10px; + } + + .task-center-modal { + width: calc(100vw - 20px); + max-height: calc(100vh - 56px); + } + + .task-actions { + width: 84px; + } + + .export-session-detail-panel { + width: calc(100vw - 12px); + } + + .export-session-sns-overlay { + padding: 12px 8px; + } + + .export-session-sns-dialog { + width: min(100vw - 16px, 760px); + max-height: calc(100vh - 24px); + + .sns-dialog-header { + padding: 12px; + } + + .sns-dialog-header-actions { + gap: 6px; + } + + .sns-dialog-rank-btn { + height: 26px; + padding: 0 8px; + font-size: 11px; + } + + .sns-dialog-rank-panel { + width: min(78vw, 232px); + max-height: calc((28px * 15) + 16px); + } + + .sns-dialog-tip { + padding: 10px 12px; + line-height: 1.55; + } + + .sns-dialog-body { + padding: 10px 10px 12px; } } } - -.media-checkbox-info { - display: flex; - flex-direction: column; - gap: 2px; -} - -.media-checkbox-title { - font-size: 14px; - color: var(--text-primary); -} - -.media-checkbox-desc { - font-size: 11px; - color: var(--text-tertiary); -} - -// 全局样式已在 main.scss 中定义 \ No newline at end of file diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index fce60b4..1f4a6cc 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,19 +1,85 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react' -import { useLocation } from 'react-router-dom' -import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react' +import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type UIEvent, type WheelEvent } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' +import { createPortal } from 'react-dom' +import { + Aperture, + Calendar, + Check, + CheckSquare, + CircleHelp, + Copy, + Database, + Download, + ExternalLink, + FolderOpen, + Hash, + Image as ImageIcon, + Loader2, + AlertTriangle, + ClipboardList, + MessageSquare, + MessageSquareText, + Mic, + RefreshCw, + Search, + Square, + Video, + WandSparkles, + X +} from 'lucide-react' +import type { ChatSession as AppChatSession, ContactInfo } from '../types/models' +import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron' +import type { BackgroundTaskRecord } from '../types/backgroundTask' import * as configService from '../services/config' +import { + emitExportSessionStatus, + emitSingleExportDialogStatus, + onExportSessionStatusRequest, + onOpenSingleExport +} from '../services/exportBridge' +import { + requestCancelBackgroundTask, + requestCancelBackgroundTasks, + subscribeBackgroundTasks +} from '../services/backgroundTaskMonitor' +import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' +import { useChatStore } from '../stores/chatStore' +import { SnsPostItem } from '../components/Sns/SnsPostItem' +import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' +import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' +import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '../components/Export/ExportDefaultsSettingsForm' +import { Avatar } from '../components/Avatar' +import type { SnsPost } from '../types/sns' +import { + cloneExportDateRange, + cloneExportDateRangeSelection, + createDefaultDateRange, + createDefaultExportDateRangeSelection, + getExportDateRangeLabel, + resolveExportDateRangeConfig, + startOfDay, + endOfDay, + type ExportDateRangeSelection +} from '../utils/exportDateRange' import './ExportPage.scss' -interface ChatSession { - username: string - displayName?: string - avatarUrl?: string - summary: string - lastTimestamp: number -} +type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' +type TaskStatus = 'queued' | 'running' | 'success' | 'error' +type TaskScope = 'single' | 'multi' | 'content' | 'sns' +type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' +type ContentCardType = ContentType | 'sns' +type SnsRankMode = 'likes' | 'comments' + +type SessionLayout = 'shared' | 'per-session' + +type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname' + +type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' +type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson' interface ExportOptions { - format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' + format: TextExportFormat dateRange: { start: Date; end: Date } | null useAllTime: boolean exportAvatars: boolean @@ -25,49 +91,2124 @@ interface ExportOptions { exportVoiceAsText: boolean excelCompactColumns: boolean txtColumns: string[] - displayNamePreference: 'group-nickname' | 'remark' | 'nickname' + displayNamePreference: DisplayNamePreference exportConcurrency: number + imageDeepSearchOnMiss: boolean } -interface ExportResult { - success: boolean - successCount?: number - failCount?: number +interface SessionRow extends AppChatSession { + kind: ConversationTab + wechatId?: string + hasSession: boolean +} + +interface TaskProgress { + current: number + total: number + currentName: string + phase: ExportProgress['phase'] | '' + phaseLabel: string + phaseProgress: number + phaseTotal: number + exportedMessages: number + estimatedTotalMessages: number + collectedMessages: number + writtenFiles: number + mediaDoneFiles: number + mediaCacheHitFiles: number + mediaCacheMissFiles: number + mediaCacheFillFiles: number + mediaDedupReuseFiles: number + mediaBytesWritten: number +} + +type TaskPerfStage = 'collect' | 'build' | 'write' | 'other' + +interface TaskSessionPerformance { + sessionId: string + sessionName: string + startedAt: number + finishedAt?: number + elapsedMs: number + lastPhase?: ExportProgress['phase'] + lastPhaseStartedAt?: number +} + +interface TaskPerformance { + stages: Record + sessions: Record +} + +interface ExportTaskPayload { + sessionIds: string[] + outputDir: string + options?: ElectronExportOptions + scope: TaskScope + contentType?: ContentType + sessionNames: string[] + snsOptions?: { + format: SnsTimelineExportFormat + exportImages?: boolean + exportLivePhotos?: boolean + exportVideos?: boolean + startTime?: number + endTime?: number + } +} + +interface ExportTask { + id: string + title: string + status: TaskStatus + settledSessionIds?: string[] + createdAt: number + startedAt?: number + finishedAt?: number + error?: string + payload: ExportTaskPayload + progress: TaskProgress + performance?: TaskPerformance +} + +interface ExportDialogState { + open: boolean + scope: TaskScope + contentType?: ContentType + sessionIds: string[] + sessionNames: string[] + title: string +} + +const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] +const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 +const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10 +const SESSION_MEDIA_METRIC_BATCH_SIZE = 8 +const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48 +const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120 +const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200 +const SNS_USER_POST_COUNT_BATCH_SIZE = 12 +const SNS_USER_POST_COUNT_BATCH_INTERVAL_MS = 120 +const SNS_RANK_PAGE_SIZE = 50 +const SNS_RANK_DISPLAY_LIMIT = 15 +const contentTypeLabels: Record = { + text: '聊天文本', + voice: '语音', + image: '图片', + video: '视频', + emoji: '表情包' +} + +const backgroundTaskSourceLabels: Record = { + export: '导出页', + chat: '聊天页', + analytics: '分析页', + sns: '朋友圈页', + groupAnalytics: '群分析页', + annualReport: '年度报告', + other: '其他页面' +} + +const backgroundTaskStatusLabels: Record = { + running: '运行中', + cancel_requested: '停止中', + completed: '已完成', + failed: '失败', + canceled: '已停止' +} + +const conversationTabLabels: Record = { + private: '私聊', + group: '群聊', + official: '公众号', + former_friend: '曾经的好友' +} + +const getContentTypeLabel = (type: ContentType): string => { + return contentTypeLabels[type] || type +} + +const formatOptions: Array<{ value: TextExportFormat; label: string; desc: string }> = [ + { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, + { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, + { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, + { value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' }, + { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, + { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, + { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' }, + { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }, + { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } +] + +const displayNameOptions: Array<{ value: DisplayNamePreference; label: string; desc: string }> = [ + { value: 'group-nickname', label: '群昵称优先', desc: '仅群聊有效,私聊显示备注/昵称' }, + { value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' }, + { value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' } +] + +const writeLayoutOptions: Array<{ value: configService.ExportWriteLayout; label: string; desc: string }> = [ + { + value: 'A', + label: 'A(类型分目录)', + desc: '聊天文本、语音、视频、表情包、图片分别创建文件夹' + }, + { + value: 'B', + label: 'B(文本根目录+媒体按会话)', + desc: '聊天文本在根目录;媒体按类型目录后再按会话分目录' + }, + { + value: 'C', + label: 'C(按会话分目录)', + desc: '每个会话一个目录,目录内包含文本与媒体文件' + } +] + +const createEmptyProgress = (): TaskProgress => ({ + current: 0, + total: 0, + currentName: '', + phase: '', + phaseLabel: '', + phaseProgress: 0, + phaseTotal: 0, + exportedMessages: 0, + estimatedTotalMessages: 0, + collectedMessages: 0, + writtenFiles: 0, + mediaDoneFiles: 0, + mediaCacheHitFiles: 0, + mediaCacheMissFiles: 0, + mediaCacheFillFiles: 0, + mediaDedupReuseFiles: 0, + mediaBytesWritten: 0 +}) + +const createEmptyTaskPerformance = (): TaskPerformance => ({ + stages: { + collect: 0, + build: 0, + write: 0, + other: 0 + }, + sessions: {} +}) + +const isTextBatchTask = (task: ExportTask): boolean => ( + task.payload.scope === 'content' && task.payload.contentType === 'text' +) + +const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => { + if (phase === 'preparing') return 'collect' + if (phase === 'writing') return 'write' + if (phase === 'exporting' || phase === 'exporting-media' || phase === 'exporting-voice') return 'build' + return 'other' +} + +const cloneTaskPerformance = (performance?: TaskPerformance): TaskPerformance => ({ + stages: { + collect: performance?.stages.collect || 0, + build: performance?.stages.build || 0, + write: performance?.stages.write || 0, + other: performance?.stages.other || 0 + }, + sessions: Object.fromEntries( + Object.entries(performance?.sessions || {}).map(([sessionId, session]) => [sessionId, { ...session }]) + ) +}) + +const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => { + const idx = task.payload.sessionIds.indexOf(sessionId) + if (idx >= 0) { + return task.payload.sessionNames[idx] || fallback || sessionId + } + return fallback || sessionId +} + +const applyProgressToTaskPerformance = ( + task: ExportTask, + payload: ExportProgress, + now: number +): TaskPerformance | undefined => { + if (!isTextBatchTask(task)) return task.performance + const sessionId = String(payload.currentSessionId || '').trim() + if (!sessionId) return task.performance || createEmptyTaskPerformance() + + const performance = cloneTaskPerformance(task.performance) + const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId) + const existing = performance.sessions[sessionId] + const session: TaskSessionPerformance = existing + ? { ...existing, sessionName: existing.sessionName || sessionName } + : { + sessionId, + sessionName, + startedAt: now, + elapsedMs: 0 + } + + if (!session.finishedAt && session.lastPhase && typeof session.lastPhaseStartedAt === 'number') { + const delta = Math.max(0, now - session.lastPhaseStartedAt) + performance.stages[resolvePerfStageByPhase(session.lastPhase)] += delta + } + + session.elapsedMs = Math.max(session.elapsedMs, now - session.startedAt) + + if (payload.phase === 'complete') { + session.finishedAt = now + session.lastPhase = undefined + session.lastPhaseStartedAt = undefined + } else { + session.lastPhase = payload.phase + session.lastPhaseStartedAt = now + } + + performance.sessions[sessionId] = session + return performance +} + +const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => { + if (!isTextBatchTask(task) || !task.performance) return task.performance + const performance = cloneTaskPerformance(task.performance) + for (const session of Object.values(performance.sessions)) { + if (session.finishedAt) continue + if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') { + const delta = Math.max(0, now - session.lastPhaseStartedAt) + performance.stages[resolvePerfStageByPhase(session.lastPhase)] += delta + } + session.elapsedMs = Math.max(session.elapsedMs, now - session.startedAt) + session.finishedAt = now + session.lastPhase = undefined + session.lastPhaseStartedAt = undefined + } + return performance +} + +const getTaskPerformanceStageTotals = ( + performance: TaskPerformance | undefined, + now: number +): Record => { + const totals: Record = { + collect: performance?.stages.collect || 0, + build: performance?.stages.build || 0, + write: performance?.stages.write || 0, + other: performance?.stages.other || 0 + } + if (!performance) return totals + for (const session of Object.values(performance.sessions)) { + if (session.finishedAt) continue + if (!session.lastPhase || typeof session.lastPhaseStartedAt !== 'number') continue + const delta = Math.max(0, now - session.lastPhaseStartedAt) + totals[resolvePerfStageByPhase(session.lastPhase)] += delta + } + return totals +} + +const getTaskPerformanceTopSessions = ( + performance: TaskPerformance | undefined, + now: number, + limit = 5 +): Array => { + if (!performance) return [] + return Object.values(performance.sessions) + .map((session) => { + const liveElapsedMs = session.finishedAt + ? session.elapsedMs + : Math.max(session.elapsedMs, now - session.startedAt) + return { + ...session, + liveElapsedMs + } + }) + .sort((a, b) => b.liveElapsedMs - a.liveElapsedMs) + .slice(0, limit) +} + +const formatDurationMs = (ms: number): string => { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + if (hours > 0) { + return `${hours}小时${minutes}分${seconds}秒` + } + if (minutes > 0) { + return `${minutes}分${seconds}秒` + } + return `${seconds}秒` +} + +const getTaskStatusLabel = (task: ExportTask): string => { + if (task.status === 'queued') return '排队中' + if (task.status === 'running') return '进行中' + if (task.status === 'success') return '已完成' + return '失败' +} + +const formatAbsoluteDate = (timestamp: number): string => { + const d = new Date(timestamp) + const y = d.getFullYear() + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + return `${y}-${m}-${day}` +} + +const formatYmdDateFromSeconds = (timestamp?: number): string => { + if (!timestamp || !Number.isFinite(timestamp)) return '—' + const d = new Date(timestamp * 1000) + const y = d.getFullYear() + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + return `${y}-${m}-${day}` +} + +const formatYmdHmDateTime = (timestamp?: number): string => { + if (!timestamp || !Number.isFinite(timestamp)) return '—' + const d = new Date(timestamp) + const y = d.getFullYear() + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + const h = `${d.getHours()}`.padStart(2, '0') + const min = `${d.getMinutes()}`.padStart(2, '0') + return `${y}-${m}-${day} ${h}:${min}` +} + +const isSingleContactSession = (sessionId: string): boolean => { + const normalized = String(sessionId || '').trim() + if (!normalized) return false + if (normalized.includes('@chatroom')) return false + if (normalized.startsWith('gh_')) return false + return true +} + +const formatPathBrief = (value: string, maxLength = 52): string => { + const normalized = String(value || '') + if (normalized.length <= maxLength) return normalized + const headLength = Math.max(10, Math.floor(maxLength * 0.55)) + const tailLength = Math.max(8, maxLength - headLength - 1) + return `${normalized.slice(0, headLength)}…${normalized.slice(-tailLength)}` +} + +const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { + if (!timestamp) return '' + const diff = Math.max(0, now - timestamp) + const minute = 60 * 1000 + const hour = 60 * minute + const day = 24 * hour + if (diff < hour) { + const minutes = Math.max(1, Math.floor(diff / minute)) + return `${minutes} 分钟前` + } + if (diff < day) { + const hours = Math.max(1, Math.floor(diff / hour)) + return `${hours} 小时前` + } + return formatAbsoluteDate(timestamp) +} + +const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => { + if (session.username.endsWith('@chatroom')) return 'group' + if (session.username.startsWith('gh_')) return 'official' + if (contact?.type === 'official') return 'official' + if (contact?.type === 'former_friend') return 'former_friend' + return 'private' +} + +const toKindByContact = (contact: ContactInfo): ConversationTab => { + if (contact.type === 'group') return 'group' + if (contact.type === 'official') return 'official' + if (contact.type === 'former_friend') return 'former_friend' + return 'private' +} + +const isContentScopeSession = (session: SessionRow): boolean => ( + session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend' +) + +const isExportConversationSession = (session: SessionRow): boolean => ( + session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend' +) + +const exportKindPriority: Record = { + private: 0, + group: 1, + former_friend: 2, + official: 3 +} + +const getAvatarLetter = (name: string): string => { + if (!name) return '?' + 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): Set => { + const set = new Set() + for (const value of values) { + const normalized = String(value || '').trim() + if (!normalized) continue + set.add(normalized) + } + return set +} + +const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean => { + if (tab === 'private') return contact.type === 'friend' + if (tab === 'group') return contact.type === 'group' + if (tab === 'official') return contact.type === 'official' + return contact.type === 'former_friend' +} + +const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +const CONTACT_ENRICH_TIMEOUT_MS = 7000 +const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 +const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80 +const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 +const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000 +const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000 +const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000 +type SessionDataSource = 'cache' | 'network' | null +type ContactsDataSource = 'cache' | 'network' | null + +interface ContactsLoadSession { + requestId: string + startedAt: number + attempt: number + timeoutMs: number +} + +interface ContactsLoadIssue { + kind: 'timeout' | 'error' + title: string + message: string + reason: string + errorDetail?: string + occurredAt: number + elapsedMs: number +} + +interface SessionDetail { + wxid: string + displayName: string + remark?: string + nickName?: string + alias?: string + avatarUrl?: string + messageCount: number + voiceMessages?: number + imageMessages?: number + videoMessages?: number + emojiMessages?: number + transferMessages?: number + redPacketMessages?: number + callMessages?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number + relationStatsLoaded?: boolean + statsUpdatedAt?: number + statsStale?: boolean + firstMessageTime?: number + latestMessageTime?: number + messageTables: { dbName: string; tableName: string; count: number }[] +} + +interface SessionSnsTimelineTarget { + username: string + displayName: string + avatarUrl?: string +} + +interface SessionSnsRankItem { + name: string + count: number + latestTime: number +} + +type SessionMutualFriendDirection = 'incoming' | 'outgoing' | 'bidirectional' +type SessionMutualFriendBehavior = 'likes' | 'comments' | 'both' + +interface SessionMutualFriendItem { + name: string + incomingLikeCount: number + incomingCommentCount: number + outgoingLikeCount: number + outgoingCommentCount: number + totalCount: number + latestTime: number + direction: SessionMutualFriendDirection + behavior: SessionMutualFriendBehavior +} + +interface SessionMutualFriendsMetric { + count: number + items: SessionMutualFriendItem[] + loadedPosts: number + totalPosts: number | null + computedAt: number +} + +interface SessionSnsRankCacheEntry { + likes: SessionSnsRankItem[] + comments: SessionSnsRankItem[] + totalPosts: number + computedAt: number +} + +const buildSessionSnsRankings = (posts: SnsPost[]): { likes: SessionSnsRankItem[]; comments: SessionSnsRankItem[] } => { + const likeMap = new Map() + const commentMap = new Map() + + for (const post of posts) { + const createTime = Number(post?.createTime) || 0 + const likes = Array.isArray(post?.likes) ? post.likes : [] + const comments = Array.isArray(post?.comments) ? post.comments : [] + + for (const likeNameRaw of likes) { + const name = String(likeNameRaw || '').trim() || '未知用户' + const current = likeMap.get(name) + if (current) { + current.count += 1 + if (createTime > current.latestTime) current.latestTime = createTime + continue + } + likeMap.set(name, { name, count: 1, latestTime: createTime }) + } + + for (const comment of comments) { + const name = String(comment?.nickname || '').trim() || '未知用户' + const current = commentMap.get(name) + if (current) { + current.count += 1 + if (createTime > current.latestTime) current.latestTime = createTime + continue + } + commentMap.set(name, { name, count: 1, latestTime: createTime }) + } + } + + const sorter = (a: SessionSnsRankItem, b: SessionSnsRankItem): number => { + if (b.count !== a.count) return b.count - a.count + if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime + return a.name.localeCompare(b.name, 'zh-CN') + } + + return { + likes: [...likeMap.values()].sort(sorter), + comments: [...commentMap.values()].sort(sorter) + } +} + +const buildSessionMutualFriendsMetric = ( + posts: SnsPost[], + totalPosts: number | null +): SessionMutualFriendsMetric => { + const friendMap = new Map() + + for (const post of posts) { + const createTime = Number(post?.createTime) || 0 + const likes = Array.isArray(post?.likes) ? post.likes : [] + const comments = Array.isArray(post?.comments) ? post.comments : [] + + for (const likeNameRaw of likes) { + const name = String(likeNameRaw || '').trim() || '未知用户' + const existing = friendMap.get(name) + if (existing) { + existing.incomingLikeCount += 1 + existing.totalCount += 1 + existing.behavior = existing.incomingCommentCount > 0 ? 'both' : 'likes' + if (createTime > existing.latestTime) existing.latestTime = createTime + continue + } + friendMap.set(name, { + name, + incomingLikeCount: 1, + incomingCommentCount: 0, + outgoingLikeCount: 0, + outgoingCommentCount: 0, + totalCount: 1, + latestTime: createTime, + direction: 'incoming', + behavior: 'likes' + }) + } + + for (const comment of comments) { + const name = String(comment?.nickname || '').trim() || '未知用户' + const existing = friendMap.get(name) + if (existing) { + existing.incomingCommentCount += 1 + existing.totalCount += 1 + existing.behavior = existing.incomingLikeCount > 0 ? 'both' : 'comments' + if (createTime > existing.latestTime) existing.latestTime = createTime + continue + } + friendMap.set(name, { + name, + incomingLikeCount: 0, + incomingCommentCount: 1, + outgoingLikeCount: 0, + outgoingCommentCount: 0, + totalCount: 1, + latestTime: createTime, + direction: 'incoming', + behavior: 'comments' + }) + } + } + + const items = [...friendMap.values()].sort((a, b) => { + if (b.totalCount !== a.totalCount) return b.totalCount - a.totalCount + if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime + return a.name.localeCompare(b.name, 'zh-CN') + }) + + return { + count: items.length, + items, + loadedPosts: posts.length, + totalPosts, + computedAt: Date.now() + } +} + +const getSessionMutualFriendDirectionLabel = (direction: SessionMutualFriendDirection): string => { + if (direction === 'incoming') return '对方赞/评TA' + if (direction === 'outgoing') return 'TA赞/评对方' + return '双方有互动' +} + +const getSessionMutualFriendBehaviorLabel = (behavior: SessionMutualFriendBehavior): string => { + if (behavior === 'likes') return '赞' + if (behavior === 'comments') return '评' + return '赞/评' +} + +const summarizeMutualFriendBehavior = (likeCount: number, commentCount: number): SessionMutualFriendBehavior => { + if (likeCount > 0 && commentCount > 0) return 'both' + if (likeCount > 0) return 'likes' + return 'comments' +} + +const describeSessionMutualFriendRelation = ( + item: SessionMutualFriendItem, + targetDisplayName: string +): string => { + if (item.direction === 'incoming') { + if (item.behavior === 'likes') return `${item.name} 给 ${targetDisplayName} 点过赞` + if (item.behavior === 'comments') return `${item.name} 给 ${targetDisplayName} 评论过` + return `${item.name} 给 ${targetDisplayName} 点过赞、评论过` + } + if (item.direction === 'outgoing') { + if (item.behavior === 'likes') return `${targetDisplayName} 给 ${item.name} 点过赞` + if (item.behavior === 'comments') return `${targetDisplayName} 给 ${item.name} 评论过` + return `${targetDisplayName} 给 ${item.name} 点过赞、评论过` + } + if (item.behavior === 'likes') return `${targetDisplayName} 和 ${item.name} 双方都有点赞互动` + if (item.behavior === 'comments') return `${targetDisplayName} 和 ${item.name} 双方都有评论互动` + return `${targetDisplayName} 和 ${item.name} 双方都有点赞或评论互动` +} + +interface SessionExportMetric { + totalMessages: number + voiceMessages: number + imageMessages: number + videoMessages: number + emojiMessages: number + transferMessages: number + redPacketMessages: number + callMessages: number + firstTimestamp?: number + lastTimestamp?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number +} + +interface SessionContentMetric { + totalMessages?: number + voiceMessages?: number + imageMessages?: number + videoMessages?: number + emojiMessages?: number + transferMessages?: number + redPacketMessages?: number + callMessages?: number + firstTimestamp?: number + lastTimestamp?: number +} + +interface TimeRangeBounds { + minDate: Date + maxDate: Date +} + +interface SessionExportCacheMeta { + updatedAt: number + stale: boolean + includeRelations: boolean + source: 'memory' | 'disk' | 'fresh' +} + +type SessionLoadStageStatus = 'pending' | 'loading' | 'done' | 'failed' + +interface SessionLoadStageState { + status: SessionLoadStageStatus + startedAt?: number + finishedAt?: number error?: string } -type SessionLayout = 'shared' | 'per-session' +interface SessionLoadTraceState { + messageCount: SessionLoadStageState + mediaMetrics: SessionLoadStageState + snsPostCounts: SessionLoadStageState + mutualFriends: SessionLoadStageState +} + +interface SessionLoadStageSummary { + total: number + loaded: number + statusLabel: string + startedAt?: number + finishedAt?: number + latestProgressAt?: number +} + +const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { + let timer: ReturnType | null = null + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs) + }) + ]) + } finally { + if (timer) { + clearTimeout(timer) + } + } +} + +const toContactMapFromCaches = ( + contacts: configService.ContactsListCacheContact[], + avatarEntries: Record +): Record => { + const map: Record = {} + for (const contact of contacts || []) { + if (!contact?.username) continue + map[contact.username] = { + ...contact, + avatarUrl: avatarEntries[contact.username]?.avatarUrl + } + } + return map +} + +const mergeAvatarCacheIntoContacts = ( + sourceContacts: ContactInfo[], + avatarEntries: Record +): ContactInfo[] => { + if (!sourceContacts.length || Object.keys(avatarEntries).length === 0) { + return sourceContacts + } + + let changed = false + const merged = sourceContacts.map((contact) => { + const cachedAvatar = avatarEntries[contact.username]?.avatarUrl + if (!cachedAvatar || contact.avatarUrl) { + return contact + } + changed = true + return { + ...contact, + avatarUrl: cachedAvatar + } + }) + + return changed ? merged : sourceContacts +} + +const upsertAvatarCacheFromContacts = ( + avatarEntries: Record, + sourceContacts: ContactInfo[], + options?: { prune?: boolean; markCheckedUsernames?: string[]; now?: number } +): { + avatarEntries: Record + changed: boolean + updatedAt: number | null +} => { + const nextCache = { ...avatarEntries } + const now = options?.now || Date.now() + const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean)) + const usernamesInSource = new Set() + let changed = false + + for (const contact of sourceContacts) { + const username = String(contact.username || '').trim() + if (!username) continue + usernamesInSource.add(username) + const prev = nextCache[username] + const avatarUrl = String(contact.avatarUrl || '').trim() + if (!avatarUrl) continue + const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt + const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now) + if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) { + nextCache[username] = { + avatarUrl, + updatedAt, + checkedAt + } + changed = true + } + } + + for (const username of markCheckedSet) { + const prev = nextCache[username] + if (!prev) continue + if (prev.checkedAt !== now) { + nextCache[username] = { + ...prev, + checkedAt: now + } + changed = true + } + } + + if (options?.prune) { + for (const username of Object.keys(nextCache)) { + if (usernamesInSource.has(username)) continue + delete nextCache[username] + changed = true + } + } + + return { + avatarEntries: nextCache, + changed, + updatedAt: changed ? now : null + } +} + +const toSessionRowsWithContacts = ( + sessions: AppChatSession[], + contactMap: Record +): SessionRow[] => { + const sessionMap = new Map() + for (const session of sessions || []) { + sessionMap.set(session.username, session) + } + + const contacts = Object.values(contactMap) + .filter((contact) => ( + contact.type === 'friend' || + contact.type === 'group' || + contact.type === 'official' || + contact.type === 'former_friend' + )) + + if (contacts.length > 0) { + return contacts + .map((contact) => { + const session = sessionMap.get(contact.username) + const latestTs = session?.sortTimestamp || session?.lastTimestamp || 0 + return { + ...(session || { + username: contact.username, + type: 0, + unreadCount: 0, + summary: '', + sortTimestamp: latestTs, + lastTimestamp: latestTs, + lastMsgType: 0 + }), + username: contact.username, + kind: toKindByContact(contact), + wechatId: contact.username, + displayName: contact.displayName || session?.displayName || contact.username, + avatarUrl: session?.avatarUrl || contact.avatarUrl, + hasSession: Boolean(session) + } as SessionRow + }) + .sort((a, b) => { + const latestA = a.sortTimestamp || a.lastTimestamp || 0 + const latestB = b.sortTimestamp || b.lastTimestamp || 0 + if (latestA !== latestB) return latestB - latestA + return (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN') + }) + } + + return sessions + .map((session) => { + const contact = contactMap[session.username] + return { + ...session, + kind: toKindByContactType(session, contact), + wechatId: contact?.username || session.username, + displayName: contact?.displayName || session.displayName || session.username, + avatarUrl: session.avatarUrl || contact?.avatarUrl, + hasSession: true + } as SessionRow + }) + .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) +} + +const normalizeMessageCount = (value: unknown): number | undefined => { + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed < 0) return undefined + return Math.floor(parsed) +} + +const normalizeTimestampSeconds = (value: unknown): number | undefined => { + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed <= 0) return undefined + return Math.floor(parsed) +} + +const clampExportSelectionToBounds = ( + selection: ExportDateRangeSelection, + bounds: TimeRangeBounds | null +): ExportDateRangeSelection => { + if (!bounds) return cloneExportDateRangeSelection(selection) + + const boundedStart = startOfDay(bounds.minDate) + const boundedEnd = endOfDay(bounds.maxDate) + const originalStart = selection.useAllTime ? boundedStart : startOfDay(selection.dateRange.start) + const originalEnd = selection.useAllTime ? boundedEnd : endOfDay(selection.dateRange.end) + const nextStart = new Date(Math.min(Math.max(originalStart.getTime(), boundedStart.getTime()), boundedEnd.getTime())) + const nextEndCandidate = new Date(Math.min(Math.max(originalEnd.getTime(), boundedStart.getTime()), boundedEnd.getTime())) + const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate + const rangeChanged = nextStart.getTime() !== originalStart.getTime() || nextEnd.getTime() !== originalEnd.getTime() + + return { + preset: selection.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset), + useAllTime: selection.useAllTime, + dateRange: { + start: nextStart, + end: nextEnd + } + } +} + +const areExportSelectionsEqual = (left: ExportDateRangeSelection, right: ExportDateRangeSelection): boolean => ( + left.preset === right.preset && + left.useAllTime === right.useAllTime && + left.dateRange.start.getTime() === right.dateRange.start.getTime() && + left.dateRange.end.getTime() === right.dateRange.end.getTime() +) + +const pickSessionMediaMetric = ( + metricRaw: SessionExportMetric | SessionContentMetric | undefined +): SessionContentMetric | null => { + if (!metricRaw) return null + const totalMessages = normalizeMessageCount(metricRaw.totalMessages) + const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages) + const imageMessages = normalizeMessageCount(metricRaw.imageMessages) + const videoMessages = normalizeMessageCount(metricRaw.videoMessages) + const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages) + const firstTimestamp = normalizeTimestampSeconds(metricRaw.firstTimestamp) + const lastTimestamp = normalizeTimestampSeconds(metricRaw.lastTimestamp) + if ( + typeof totalMessages !== 'number' && + typeof voiceMessages !== 'number' && + typeof imageMessages !== 'number' && + typeof videoMessages !== 'number' && + typeof emojiMessages !== 'number' && + typeof firstTimestamp !== 'number' && + typeof lastTimestamp !== 'number' + ) { + return null + } + return { + totalMessages, + voiceMessages, + imageMessages, + videoMessages, + emojiMessages, + firstTimestamp, + lastTimestamp + } +} + +const hasCompleteSessionMediaMetric = (metricRaw: SessionContentMetric | undefined): boolean => { + if (!metricRaw) return false + return ( + typeof normalizeMessageCount(metricRaw.voiceMessages) === 'number' && + typeof normalizeMessageCount(metricRaw.imageMessages) === 'number' && + typeof normalizeMessageCount(metricRaw.videoMessages) === 'number' && + typeof normalizeMessageCount(metricRaw.emojiMessages) === 'number' + ) +} + +const createDefaultSessionLoadStage = (): SessionLoadStageState => ({ status: 'pending' }) + +const createDefaultSessionLoadTrace = (): SessionLoadTraceState => ({ + messageCount: createDefaultSessionLoadStage(), + mediaMetrics: createDefaultSessionLoadStage(), + snsPostCounts: createDefaultSessionLoadStage(), + mutualFriends: createDefaultSessionLoadStage() +}) + +const WriteLayoutSelector = memo(function WriteLayoutSelector({ + writeLayout, + onChange, + sessionNameWithTypePrefix, + onSessionNameWithTypePrefixChange +}: { + writeLayout: configService.ExportWriteLayout + onChange: (value: configService.ExportWriteLayout) => Promise + sessionNameWithTypePrefix: boolean + onSessionNameWithTypePrefixChange: (enabled: boolean) => Promise +}) { + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + + const handleOutsideClick = (event: MouseEvent) => { + if (containerRef.current?.contains(event.target as Node)) return + setIsOpen(false) + } + + document.addEventListener('mousedown', handleOutsideClick) + return () => document.removeEventListener('mousedown', handleOutsideClick) + }, [isOpen]) + + const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)' + + return ( +
+ 写入目录方式 + +
+ {writeLayoutOptions.map(option => ( + + ))} +
+
+ 聊天文本文件和会话文件夹带前缀 + 开启后使用群聊_、私聊_、公众号_、曾经的好友_前缀 +
+ +
+
+
+ ) +}) + +const SectionInfoTooltip = memo(function SectionInfoTooltip({ + label, + heading, + messages +}: { + label: string + heading: string + messages: string[] +}) { + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + + const handleOutsideClick = (event: MouseEvent) => { + if (containerRef.current?.contains(event.target as Node)) return + setIsOpen(false) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleOutsideClick) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('mousedown', handleOutsideClick) + document.removeEventListener('keydown', handleKeyDown) + } + }, [isOpen]) + + return ( +
+ + {isOpen && ( +
+

{heading}

+ {messages.map(message => ( +

{message}

+ ))} +
+ )} +
+ ) +}) + +interface TaskCenterModalProps { + isOpen: boolean + tasks: ExportTask[] + taskRunningCount: number + taskQueuedCount: number + expandedPerfTaskId: string | null + nowTick: number + onClose: () => void + onTogglePerfTask: (taskId: string) => void +} + +const TaskCenterModal = memo(function TaskCenterModal({ + isOpen, + tasks, + taskRunningCount, + taskQueuedCount, + expandedPerfTaskId, + nowTick, + onClose, + onTogglePerfTask +}: TaskCenterModalProps) { + if (!isOpen) return null + + return ( +
+
event.stopPropagation()} + > +
+
+

任务中心

+ 进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 总计 {tasks.length} +
+ +
+
+ {tasks.length === 0 ? ( +
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
+ ) : ( +
+ {tasks.map(task => { + const canShowPerfDetail = isTextBatchTask(task) && Boolean(task.performance) + const isPerfExpanded = expandedPerfTaskId === task.id + const stageTotals = canShowPerfDetail + ? getTaskPerformanceStageTotals(task.performance, nowTick) + : null + const stageTotalMs = stageTotals + ? stageTotals.collect + stageTotals.build + stageTotals.write + stageTotals.other + : 0 + const topSessions = isPerfExpanded + ? getTaskPerformanceTopSessions(task.performance, nowTick, 5) + : [] + const normalizedProgressTotal = task.progress.total > 0 ? task.progress.total : 0 + const normalizedProgressCurrent = normalizedProgressTotal > 0 + ? Math.max(0, Math.min(normalizedProgressTotal, task.progress.current)) + : 0 + const completedSessionTotal = normalizedProgressTotal > 0 + ? normalizedProgressTotal + : task.payload.sessionIds.length + const completedSessionCount = Math.min( + completedSessionTotal, + (task.settledSessionIds || []).length + ) + const exportedMessages = Math.max(0, Math.floor(task.progress.exportedMessages || 0)) + const estimatedTotalMessages = Math.max(0, Math.floor(task.progress.estimatedTotalMessages || 0)) + const collectedMessages = Math.max(0, Math.floor(task.progress.collectedMessages || 0)) + const messageProgressLabel = estimatedTotalMessages > 0 + ? `已导出 ${Math.min(exportedMessages, estimatedTotalMessages)}/${estimatedTotalMessages} 条` + : `已导出 ${exportedMessages} 条` + const effectiveMessageProgressLabel = ( + exportedMessages > 0 || estimatedTotalMessages > 0 || collectedMessages <= 0 || task.progress.phase !== 'preparing' + ) + ? messageProgressLabel + : `已收集 ${collectedMessages.toLocaleString()} 条` + const phaseProgress = Math.max(0, Math.floor(task.progress.phaseProgress || 0)) + const phaseTotal = Math.max(0, Math.floor(task.progress.phaseTotal || 0)) + const mediaDoneFiles = Math.max(0, Math.floor(task.progress.mediaDoneFiles || 0)) + const mediaCacheHitFiles = Math.max(0, Math.floor(task.progress.mediaCacheHitFiles || 0)) + const mediaCacheMissFiles = Math.max(0, Math.floor(task.progress.mediaCacheMissFiles || 0)) + const mediaDedupReuseFiles = Math.max(0, Math.floor(task.progress.mediaDedupReuseFiles || 0)) + const mediaCacheTotal = mediaCacheHitFiles + mediaCacheMissFiles + const mediaCacheMetricLabel = mediaCacheTotal > 0 + ? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}` + : '' + const mediaDedupMetricLabel = mediaDedupReuseFiles > 0 + ? `复用 ${mediaDedupReuseFiles}` + : '' + const phaseMetricLabel = phaseTotal > 0 + ? ( + task.progress.phase === 'exporting-media' + ? `媒体 ${Math.min(phaseProgress, phaseTotal)}/${phaseTotal}` + : task.progress.phase === 'exporting-voice' + ? `语音 ${Math.min(phaseProgress, phaseTotal)}/${phaseTotal}` + : '' + ) + : '' + const mediaLiveMetricLabel = task.progress.phase === 'exporting-media' + ? (mediaDoneFiles > 0 ? `已处理 ${mediaDoneFiles}` : '') + : '' + const sessionProgressLabel = completedSessionTotal > 0 + ? `会话 ${completedSessionCount}/${completedSessionTotal}` + : '会话处理中' + const currentSessionRatio = task.progress.phaseTotal > 0 + ? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal)) + : null + return ( +
+
+
{task.title}
+
+ {getTaskStatusLabel(task)} + {new Date(task.createdAt).toLocaleString('zh-CN')} +
+ {task.status === 'running' && ( + <> +
+
0 ? (normalizedProgressCurrent / normalizedProgressTotal) * 100 : 0}%` }} + /> +
+
+ {`${sessionProgressLabel} · ${effectiveMessageProgressLabel}`} + {phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''} + {mediaLiveMetricLabel ? ` · ${mediaLiveMetricLabel}` : ''} + {mediaCacheMetricLabel ? ` · ${mediaCacheMetricLabel}` : ''} + {mediaDedupMetricLabel ? ` · ${mediaDedupMetricLabel}` : ''} + {task.status === 'running' && currentSessionRatio !== null + ? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)` + : ''} + {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''} +
+ + )} + {canShowPerfDetail && stageTotals && ( +
+ 累计耗时 {formatDurationMs(stageTotalMs)} + {task.progress.total > 0 && ( + 平均/会话 {formatDurationMs(Math.floor(stageTotalMs / Math.max(1, task.progress.total)))} + )} +
+ )} + {canShowPerfDetail && isPerfExpanded && stageTotals && ( +
+
阶段耗时分布
+ {[ + { key: 'collect' as const, label: '收集消息' }, + { key: 'build' as const, label: '构建消息' }, + { key: 'write' as const, label: '写入文件' }, + { key: 'other' as const, label: '其他' } + ].map(item => { + const value = stageTotals[item.key] + const ratio = stageTotalMs > 0 ? Math.min(100, (value / stageTotalMs) * 100) : 0 + return ( +
+
+ {item.label} + {formatDurationMs(value)} +
+
+
+
+
+ ) + })} +
最慢会话 Top5
+ {topSessions.length === 0 ? ( +
暂无会话耗时数据
+ ) : ( +
+ {topSessions.map((session, index) => ( +
+ + {index + 1}. {session.sessionName || session.sessionId} + {!session.finishedAt ? '(进行中)' : ''} + + {formatDurationMs(session.liveElapsedMs)} +
+ ))} +
+ )} +
+ )} + {task.status === 'error' &&
{task.error || '任务失败'}
} +
+
+ {canShowPerfDetail && ( + + )} + +
+
+ ) + })} +
+ )} +
+
+
+ ) +}) function ExportPage() { + const navigate = useNavigate() + const { setCurrentSession } = useChatStore() const location = useLocation() - const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] - const [sessions, setSessions] = useState([]) - const [filteredSessions, setFilteredSessions] = useState([]) - const [selectedSessions, setSelectedSessions] = useState>(new Set()) + const isExportRoute = location.pathname === '/export' + const [isLoading, setIsLoading] = useState(true) + const [isSessionEnriching, setIsSessionEnriching] = useState(false) + const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) + const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) + const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false) + const [expandedPerfTaskId, setExpandedPerfTaskId] = useState(null) + const [sessions, setSessions] = useState([]) + const [sessionDataSource, setSessionDataSource] = useState(null) + const [sessionContactsUpdatedAt, setSessionContactsUpdatedAt] = useState(null) + const [sessionAvatarUpdatedAt, setSessionAvatarUpdatedAt] = useState(null) const [searchKeyword, setSearchKeyword] = useState('') - const [exportFolder, setExportFolder] = useState('') - const [isExporting, setIsExporting] = useState(false) - const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 }) - const [exportResult, setExportResult] = useState(null) - const [showDatePicker, setShowDatePicker] = useState(false) - const [calendarDate, setCalendarDate] = useState(new Date()) - const [selectingStart, setSelectingStart] = useState(true) - const [showYearMonthPicker, setShowYearMonthPicker] = useState(false) - const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) - const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) - const [showPreExportDialog, setShowPreExportDialog] = useState(false) - const [preExportStats, setPreExportStats] = useState<{ - totalMessages: number; voiceMessages: number; cachedVoiceCount: number; - needTranscribeCount: number; mediaMessages: number; estimatedSeconds: number - } | null>(null) - const [isLoadingStats, setIsLoadingStats] = useState(false) - const [pendingLayout, setPendingLayout] = useState('shared') - const exportStartTime = useRef(0) - const [elapsedSeconds, setElapsedSeconds] = useState(0) - const displayNameDropdownRef = useRef(null) + const [activeTab, setActiveTab] = useState('private') + const [selectedSessions, setSelectedSessions] = useState>(new Set()) + const [contactsList, setContactsList] = useState([]) + const [isContactsListLoading, setIsContactsListLoading] = useState(true) + const [, setContactsDataSource] = useState(null) + const [contactsUpdatedAt, setContactsUpdatedAt] = useState(null) + const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState(null) + const [sessionMessageCounts, setSessionMessageCounts] = useState>({}) + const [isLoadingSessionCounts, setIsLoadingSessionCounts] = useState(false) + const [isSessionCountStageReady, setIsSessionCountStageReady] = useState(false) + const [sessionContentMetrics, setSessionContentMetrics] = useState>({}) + const [sessionLoadTraceMap, setSessionLoadTraceMap] = useState>({}) + const [sessionLoadProgressPulseMap, setSessionLoadProgressPulseMap] = useState>({}) + const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const [contactsLoadSession, setContactsLoadSession] = useState(null) + const [contactsLoadIssue, setContactsLoadIssue] = useState(null) + const [showContactsDiagnostics, setShowContactsDiagnostics] = useState(false) + const [contactsDiagnosticTick, setContactsDiagnosticTick] = useState(Date.now()) + const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false) + const [showSessionLoadDetailModal, setShowSessionLoadDetailModal] = useState(false) + const [sessionDetail, setSessionDetail] = useState(null) + const [isLoadingSessionDetail, setIsLoadingSessionDetail] = useState(false) + const [isLoadingSessionDetailExtra, setIsLoadingSessionDetailExtra] = useState(false) + const [isRefreshingSessionDetailStats, setIsRefreshingSessionDetailStats] = useState(false) + const [isLoadingSessionRelationStats, setIsLoadingSessionRelationStats] = useState(false) + const [copiedDetailField, setCopiedDetailField] = useState(null) + const [snsUserPostCounts, setSnsUserPostCounts] = useState>({}) + const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle') + const [sessionSnsTimelineTarget, setSessionSnsTimelineTarget] = useState(null) + const [sessionSnsTimelinePosts, setSessionSnsTimelinePosts] = useState([]) + const [sessionSnsTimelineLoading, setSessionSnsTimelineLoading] = useState(false) + const [sessionSnsTimelineLoadingMore, setSessionSnsTimelineLoadingMore] = useState(false) + const [sessionSnsTimelineHasMore, setSessionSnsTimelineHasMore] = useState(false) + const [sessionSnsTimelineTotalPosts, setSessionSnsTimelineTotalPosts] = useState(null) + const [sessionSnsTimelineStatsLoading, setSessionSnsTimelineStatsLoading] = useState(false) + const [sessionSnsRankMode, setSessionSnsRankMode] = useState(null) + const [sessionSnsLikeRankings, setSessionSnsLikeRankings] = useState([]) + const [sessionSnsCommentRankings, setSessionSnsCommentRankings] = useState([]) + const [sessionSnsRankLoading, setSessionSnsRankLoading] = useState(false) + const [sessionSnsRankError, setSessionSnsRankError] = useState(null) + const [sessionSnsRankLoadedPosts, setSessionSnsRankLoadedPosts] = useState(0) + const [sessionSnsRankTotalPosts, setSessionSnsRankTotalPosts] = useState(null) + const [sessionMutualFriendsMetrics, setSessionMutualFriendsMetrics] = useState>({}) + const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState(null) + const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('') + const [backgroundTasks, setBackgroundTasks] = useState([]) + + const [exportFolder, setExportFolder] = useState('') + const [writeLayout, setWriteLayout] = useState('B') + const [sessionNameWithTypePrefix, setSessionNameWithTypePrefix] = useState(true) + const [snsExportFormat, setSnsExportFormat] = useState('html') + const [snsExportImages, setSnsExportImages] = useState(false) + const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false) + const [snsExportVideos, setSnsExportVideos] = useState(false) + const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false) + const [isResolvingTimeRangeBounds, setIsResolvingTimeRangeBounds] = useState(false) + const [timeRangeBounds, setTimeRangeBounds] = useState(null) + const [isExportDefaultsModalOpen, setIsExportDefaultsModalOpen] = useState(false) + const [timeRangeSelection, setTimeRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') + const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true) + const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultMedia, setExportDefaultMedia] = useState({ + images: true, + videos: true, + voices: true, + emojis: true + }) + const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) + const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) + const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) + const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true) + + const [options, setOptions] = useState({ + format: 'json', + dateRange: { + start: new Date(new Date().setHours(0, 0, 0, 0)), + end: new Date() + }, + useAllTime: false, + exportAvatars: true, + exportMedia: true, + exportImages: true, + exportVoices: true, + exportVideos: true, + exportEmojis: true, + exportVoiceAsText: false, + excelCompactColumns: true, + txtColumns: defaultTxtColumns, + displayNamePreference: 'remark', + exportConcurrency: 2, + imageDeepSearchOnMiss: true + }) + + const [exportDialog, setExportDialog] = useState({ + open: false, + scope: 'single', + sessionIds: [], + sessionNames: [], + title: '' + }) + const [showSessionFormatSelect, setShowSessionFormatSelect] = useState(false) + + const [tasks, setTasks] = useState([]) + const [lastExportBySession, setLastExportBySession] = useState>({}) + const [lastExportByContent, setLastExportByContent] = useState>({}) + const [exportRecordsBySession, setExportRecordsBySession] = useState>({}) + const [lastSnsExportPostCount, setLastSnsExportPostCount] = useState(0) + const [snsStats, setSnsStats] = useState<{ totalPosts: number; totalFriends: number }>({ + totalPosts: 0, + totalFriends: 0 + }) + const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) + const [nowTick, setNowTick] = useState(Date.now()) + const [isContactsListAtTop, setIsContactsListAtTop] = useState(true) + const [isContactsHeaderDragging, setIsContactsHeaderDragging] = useState(false) + const [contactsListScrollParent, setContactsListScrollParent] = useState(null) + const [contactsHorizontalScrollMetrics, setContactsHorizontalScrollMetrics] = useState({ + viewportWidth: 0, + contentWidth: 0 + }) + const tabCounts = useContactTypeCountsStore(state => state.tabCounts) + const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading) + const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady) + const ensureSharedTabCountsLoaded = useContactTypeCountsStore(state => state.ensureLoaded) + const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts) + + const progressUnsubscribeRef = useRef<(() => void) | null>(null) + const runningTaskIdRef = useRef(null) + const tasksRef = useRef([]) + const hasSeededSnsStatsRef = useRef(false) + const sessionLoadTokenRef = useRef(0) const preselectAppliedRef = useRef(false) - const statsRequestIdRef = useRef(0) + const exportCacheScopeRef = useRef('default') + const exportCacheScopeReadyRef = useRef(false) + const contactsLoadVersionRef = useRef(0) + const contactsLoadAttemptRef = useRef(0) + const contactsLoadTimeoutTimerRef = useRef(null) + const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const contactsAvatarCacheRef = useRef>({}) + const contactsVirtuosoRef = useRef(null) + const sessionTableSectionRef = useRef(null) + const contactsHorizontalViewportRef = useRef(null) + const contactsHorizontalContentRef = useRef(null) + const contactsBottomScrollbarRef = useRef(null) + const contactsScrollSyncSourceRef = useRef<'viewport' | 'bottom' | null>(null) + const contactsHeaderDragStateRef = useRef({ + pointerId: -1, + startClientX: 0, + startScrollLeft: 0, + didDrag: false + }) + const sessionFormatDropdownRef = useRef(null) + const detailRequestSeqRef = useRef(0) + const sessionsRef = useRef([]) + const sessionContentMetricsRef = useRef>({}) + const contactsListSizeRef = useRef(0) + const contactsUpdatedAtRef = useRef(null) + const sessionsHydratedAtRef = useRef(0) + const snsStatsHydratedAtRef = useRef(0) + const inProgressSessionIdsRef = useRef([]) + const activeTaskCountRef = useRef(0) + const hasBaseConfigReadyRef = useRef(false) + const sessionCountRequestIdRef = useRef(0) + const isLoadingSessionCountsRef = useRef(false) + const activeTabRef = useRef('private') + const detailStatsPriorityRef = useRef(false) + const sessionSnsTimelinePostsRef = useRef([]) + const sessionSnsTimelineLoadingRef = useRef(false) + const sessionSnsTimelineRequestTokenRef = useRef(0) + const sessionSnsRankRequestTokenRef = useRef(0) + const sessionSnsRankLoadingRef = useRef(false) + const sessionSnsRankCacheRef = useRef>({}) + const snsUserPostCountsHydrationTokenRef = useRef(0) + const snsUserPostCountsBatchTimerRef = useRef(null) + const sessionPreciseRefreshAtRef = useRef>({}) + const sessionLoadProgressSnapshotRef = useRef>({}) + const sessionMediaMetricQueueRef = useRef([]) + const sessionMediaMetricQueuedSetRef = useRef>(new Set()) + const sessionMediaMetricLoadingSetRef = useRef>(new Set()) + const sessionMediaMetricReadySetRef = useRef>(new Set()) + const sessionMediaMetricRunIdRef = useRef(0) + const sessionMediaMetricWorkerRunningRef = useRef(false) + const sessionMediaMetricBackgroundFeedTimerRef = useRef(null) + const sessionMediaMetricPersistTimerRef = useRef(null) + const sessionMediaMetricPendingPersistRef = useRef>({}) + const sessionMediaMetricVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({ + startIndex: 0, + endIndex: -1 + }) + const avatarHydrationRequestedRef = useRef>(new Set()) + const sessionMutualFriendsMetricsRef = useRef>({}) + const sessionMutualFriendsDirectMetricsRef = useRef>({}) + const sessionMutualFriendsQueueRef = useRef([]) + const sessionMutualFriendsQueuedSetRef = useRef>(new Set()) + const sessionMutualFriendsLoadingSetRef = useRef>(new Set()) + const sessionMutualFriendsReadySetRef = useRef>(new Set()) + const sessionMutualFriendsRunIdRef = useRef(0) + const sessionMutualFriendsWorkerRunningRef = useRef(false) + const sessionMutualFriendsBackgroundFeedTimerRef = useRef(null) + const sessionMutualFriendsPersistTimerRef = useRef(null) + const sessionMutualFriendsVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({ + startIndex: 0, + endIndex: -1 + }) + + const handleContactsListScrollParentRef = useCallback((node: HTMLDivElement | null) => { + setContactsListScrollParent(prev => (prev === node ? prev : node)) + }, []) + + const ensureExportCacheScope = useCallback(async (): Promise => { + if (exportCacheScopeReadyRef.current) { + return exportCacheScopeRef.current + } + const [myWxid, dbPath] = await Promise.all([ + configService.getMyWxid(), + configService.getDbPath() + ]) + const scopeKey = dbPath || myWxid + ? `${dbPath || ''}::${myWxid || ''}` + : 'default' + exportCacheScopeRef.current = scopeKey + exportCacheScopeReadyRef.current = true + return scopeKey + }, []) + + const loadContactsCaches = useCallback(async (scopeKey: string) => { + const [contactsItem, avatarItem] = await Promise.all([ + configService.getContactsListCache(scopeKey), + configService.getContactsAvatarCache(scopeKey) + ]) + return { + contactsItem, + avatarItem + } + }, []) + + useEffect(() => { + let cancelled = false + void (async () => { + try { + const value = await configService.getContactsLoadTimeoutMs() + if (!cancelled) { + setContactsLoadTimeoutMs(value) + } + } catch (error) { + console.error('读取通讯录超时配置失败:', error) + } + })() + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs + }, [contactsLoadTimeoutMs]) + + useEffect(() => { + isLoadingSessionCountsRef.current = isLoadingSessionCounts + }, [isLoadingSessionCounts]) + + useEffect(() => { + sessionContentMetricsRef.current = sessionContentMetrics + }, [sessionContentMetrics]) + + useEffect(() => { + sessionMutualFriendsMetricsRef.current = sessionMutualFriendsMetrics + }, [sessionMutualFriendsMetrics]) + + const patchSessionLoadTraceStage = useCallback(( + sessionIds: string[], + stageKey: keyof SessionLoadTraceState, + status: SessionLoadStageStatus, + options?: { force?: boolean; error?: string } + ) => { + if (sessionIds.length === 0) return + const now = Date.now() + setSessionLoadTraceMap(prev => { + let changed = false + const next = { ...prev } + for (const sessionIdRaw of sessionIds) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId) continue + const prevTrace = next[sessionId] || createDefaultSessionLoadTrace() + const prevStage = prevTrace[stageKey] || createDefaultSessionLoadStage() + if (!options?.force && prevStage.status === 'done' && status !== 'done') { + continue + } + let stageChanged = false + const nextStage: SessionLoadStageState = { ...prevStage } + if (nextStage.status !== status) { + nextStage.status = status + stageChanged = true + } + if (status === 'loading') { + if (!nextStage.startedAt) { + nextStage.startedAt = now + stageChanged = true + } + if (nextStage.finishedAt) { + nextStage.finishedAt = undefined + stageChanged = true + } + if (nextStage.error) { + nextStage.error = undefined + stageChanged = true + } + } else if (status === 'done') { + if (!nextStage.startedAt) { + nextStage.startedAt = now + stageChanged = true + } + if (!nextStage.finishedAt) { + nextStage.finishedAt = now + stageChanged = true + } + if (nextStage.error) { + nextStage.error = undefined + stageChanged = true + } + } else if (status === 'failed') { + if (!nextStage.startedAt) { + nextStage.startedAt = now + stageChanged = true + } + if (!nextStage.finishedAt) { + nextStage.finishedAt = now + stageChanged = true + } + const nextError = options?.error || '加载失败' + if (nextStage.error !== nextError) { + nextStage.error = nextError + stageChanged = true + } + } else if (status === 'pending') { + if (nextStage.startedAt !== undefined) { + nextStage.startedAt = undefined + stageChanged = true + } + if (nextStage.finishedAt !== undefined) { + nextStage.finishedAt = undefined + stageChanged = true + } + if (nextStage.error !== undefined) { + nextStage.error = undefined + stageChanged = true + } + } + if (!stageChanged) continue + next[sessionId] = { + ...prevTrace, + [stageKey]: nextStage + } + changed = true + } + return changed ? next : prev + }) + }, []) + + const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => { + const scopeKey = options?.scopeKey || await ensureExportCacheScope() + const loadVersion = contactsLoadVersionRef.current + 1 + contactsLoadVersionRef.current = loadVersion + contactsLoadAttemptRef.current += 1 + const startedAt = Date.now() + const timeoutMs = contactsLoadTimeoutMsRef.current + const requestId = `export-contacts-${startedAt}-${contactsLoadAttemptRef.current}` + setContactsLoadSession({ + requestId, + startedAt, + attempt: contactsLoadAttemptRef.current, + timeoutMs + }) + setContactsLoadIssue(null) + setShowContactsDiagnostics(false) + if (contactsLoadTimeoutTimerRef.current) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + const timeoutTimerId = window.setTimeout(() => { + if (contactsLoadVersionRef.current !== loadVersion) return + const elapsedMs = Date.now() - startedAt + setContactsLoadIssue({ + kind: 'timeout', + title: '联系人列表加载超时', + message: `等待超过 ${timeoutMs}ms,联系人列表仍未返回。`, + reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。', + occurredAt: Date.now(), + elapsedMs + }) + }, timeoutMs) + contactsLoadTimeoutTimerRef.current = timeoutTimerId + + setIsContactsListLoading(true) + try { + const contactsResult = await window.electronAPI.chat.getContacts() + if (contactsLoadVersionRef.current !== loadVersion) return + + if (contactsResult.success && contactsResult.contacts) { + if (contactsLoadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + const contactsWithAvatarCache = mergeAvatarCacheIntoContacts( + contactsResult.contacts, + contactsAvatarCacheRef.current + ) + setContactsList(contactsWithAvatarCache) + syncContactTypeCounts(contactsWithAvatarCache) + setContactsDataSource('network') + setContactsUpdatedAt(Date.now()) + setContactsLoadIssue(null) + setIsContactsListLoading(false) + + const upsertResult = upsertAvatarCacheFromContacts( + contactsAvatarCacheRef.current, + contactsWithAvatarCache, + { prune: true } + ) + contactsAvatarCacheRef.current = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setAvatarCacheUpdatedAt(upsertResult.updatedAt) + } + + void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch((error) => { + console.error('写入导出页头像缓存失败:', error) + }) + void configService.setContactsListCache( + scopeKey, + contactsWithAvatarCache.map(contact => ({ + username: contact.username, + displayName: contact.displayName, + remark: contact.remark, + nickname: contact.nickname, + alias: contact.alias, + type: contact.type + })) + ).catch((error) => { + console.error('写入导出页通讯录缓存失败:', error) + }) + return + } + + const elapsedMs = Date.now() - startedAt + setContactsLoadIssue({ + kind: 'error', + title: '联系人列表加载失败', + message: '联系人接口返回失败,未拿到联系人列表。', + reason: 'chat.getContacts 返回 success=false。', + errorDetail: contactsResult.error || '未知错误', + occurredAt: Date.now(), + elapsedMs + }) + } catch (error) { + console.error('加载导出页联系人失败:', error) + const elapsedMs = Date.now() - startedAt + setContactsLoadIssue({ + kind: 'error', + title: '联系人列表加载失败', + message: '联系人请求执行异常。', + reason: '调用 chat.getContacts 发生异常。', + errorDetail: String(error), + occurredAt: Date.now(), + elapsedMs + }) + } finally { + if (contactsLoadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + if (contactsLoadVersionRef.current === loadVersion) { + setIsContactsListLoading(false) + } + } + }, [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() + for (const item of settled) { + if (item.status !== 'fulfilled') continue + const { username, avatarUrl, displayName } = item.value + if (!avatarUrl && !displayName) continue + avatarPatches.set(username, { avatarUrl, displayName }) + } + if (avatarPatches.size === 0) return + + const now = Date.now() + setContactsList((prev) => prev.map((contact) => { + const patch = avatarPatches.get(contact.username) + if (!patch) return contact + return { + ...contact, + displayName: patch.displayName || contact.displayName, + avatarUrl: patch.avatarUrl || contact.avatarUrl + } + })) + setSessions((prev) => prev.map((session) => { + const patch = avatarPatches.get(session.username) + if (!patch) return session + return { + ...session, + displayName: patch.displayName || session.displayName, + avatarUrl: patch.avatarUrl || session.avatarUrl + } + })) + setSessionDetail((prev) => { + if (!prev) return prev + const patch = avatarPatches.get(prev.wxid) + if (!patch) return prev + return { + ...prev, + displayName: patch.displayName || prev.displayName, + avatarUrl: patch.avatarUrl || prev.avatarUrl + } + }) + + let avatarCacheChanged = false + for (const [username, patch] of avatarPatches.entries()) { + if (!patch.avatarUrl) continue + const previous = contactsAvatarCacheRef.current[username] + if (previous?.avatarUrl === patch.avatarUrl) continue + contactsAvatarCacheRef.current[username] = { + avatarUrl: patch.avatarUrl, + updatedAt: now, + checkedAt: now + } + avatarCacheChanged = true + } + if (avatarCacheChanged) { + setAvatarCacheUpdatedAt(now) + const scopeKey = exportCacheScopeRef.current + if (scopeKey) { + void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch(() => {}) + } + } + }, [contactsList, sessions]) + + + useEffect(() => { + if (!isExportRoute) return + let cancelled = false + void (async () => { + const scopeKey = await ensureExportCacheScope() + if (cancelled) return + let cachedContactsCount = 0 + let cachedContactsUpdatedAt = 0 + try { + const [cacheItem, avatarCacheItem] = await Promise.all([ + configService.getContactsListCache(scopeKey), + configService.getContactsAvatarCache(scopeKey) + ]) + cachedContactsCount = Array.isArray(cacheItem?.contacts) ? cacheItem.contacts.length : 0 + cachedContactsUpdatedAt = Number(cacheItem?.updatedAt || 0) + const avatarCacheMap = avatarCacheItem?.avatars || {} + contactsAvatarCacheRef.current = avatarCacheMap + setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null) + if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) { + const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({ + ...contact, + avatarUrl: avatarCacheMap[contact.username]?.avatarUrl + })) + setContactsList(cachedContacts) + syncContactTypeCounts(cachedContacts) + setContactsDataSource('cache') + setContactsUpdatedAt(cacheItem.updatedAt || null) + setIsContactsListLoading(false) + } + } catch (error) { + console.error('读取导出页联系人缓存失败:', error) + } + + const latestContactsUpdatedAt = Math.max( + Number(contactsUpdatedAtRef.current || 0), + cachedContactsUpdatedAt + ) + const hasFreshContactSnapshot = (contactsListSizeRef.current > 0 || cachedContactsCount > 0) && + latestContactsUpdatedAt > 0 && + Date.now() - latestContactsUpdatedAt <= EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS + + if (!cancelled && !hasFreshContactSnapshot) { + void loadContactsList({ scopeKey }) + } + })() + return () => { + cancelled = true + } + }, [isExportRoute, ensureExportCacheScope, loadContactsList, syncContactTypeCounts]) + + useEffect(() => { + if (isExportRoute) return + contactsLoadVersionRef.current += 1 + }, [isExportRoute]) + + useEffect(() => { + if (contactsLoadTimeoutTimerRef.current) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + return () => { + if (contactsLoadTimeoutTimerRef.current) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + } + }, []) + + useEffect(() => { + if (!contactsLoadIssue || contactsList.length > 0) return + if (!(isContactsListLoading && contactsLoadIssue.kind === 'timeout')) return + const timer = window.setInterval(() => { + setContactsDiagnosticTick(Date.now()) + }, 500) + return () => window.clearInterval(timer) + }, [contactsList.length, isContactsListLoading, contactsLoadIssue]) + + useEffect(() => { + return subscribeBackgroundTasks(setBackgroundTasks) + }, []) + + useEffect(() => { + tasksRef.current = tasks + }, [tasks]) + + useEffect(() => { + sessionsRef.current = sessions + }, [sessions]) + + useEffect(() => { + contactsListSizeRef.current = contactsList.length + }, [contactsList.length]) + + useEffect(() => { + contactsUpdatedAtRef.current = contactsUpdatedAt + }, [contactsUpdatedAt]) + + useEffect(() => { + if (!expandedPerfTaskId) return + const target = tasks.find(task => task.id === expandedPerfTaskId) + if (!target || !isTextBatchTask(target)) { + setExpandedPerfTaskId(null) + } + }, [tasks, expandedPerfTaskId]) + + useEffect(() => { + hasSeededSnsStatsRef.current = hasSeededSnsStats + }, [hasSeededSnsStats]) + + useEffect(() => { + sessionSnsTimelinePostsRef.current = sessionSnsTimelinePosts + }, [sessionSnsTimelinePosts]) const preselectSessionIds = useMemo(() => { const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null @@ -81,125 +2222,1795 @@ function ExportPage() { .filter(Boolean) }, [location.state]) - const [options, setOptions] = useState({ - format: 'excel', - dateRange: { - start: new Date(new Date().setHours(0, 0, 0, 0)), - end: new Date() - }, - useAllTime: false, - exportAvatars: true, - exportMedia: false, - exportImages: true, - exportVoices: true, - exportVideos: true, - exportEmojis: true, - exportVoiceAsText: false, - excelCompactColumns: true, - txtColumns: defaultTxtColumns, - displayNamePreference: 'remark', - exportConcurrency: 2 - }) + useEffect(() => { + if (!isExportRoute) return + const timer = setInterval(() => setNowTick(Date.now()), 60 * 1000) + return () => clearInterval(timer) + }, [isExportRoute]) - const buildDateRangeFromPreset = (preset: string) => { - const now = new Date() - if (preset === 'all') { - return { useAllTime: true, dateRange: { start: now, end: now } } - } - let rangeMs = 0 - if (preset === '7d') rangeMs = 7 * 24 * 60 * 60 * 1000 - if (preset === '30d') rangeMs = 30 * 24 * 60 * 60 * 1000 - if (preset === '90d') rangeMs = 90 * 24 * 60 * 60 * 1000 - if (preset === 'today' || rangeMs === 0) { - const start = new Date(now) - start.setHours(0, 0, 0, 0) - return { useAllTime: false, dateRange: { start, end: now } } - } - const start = new Date(now.getTime() - rangeMs) - start.setHours(0, 0, 0, 0) - return { useAllTime: false, dateRange: { start, end: now } } - } + useEffect(() => { + if (!isTaskCenterOpen || !expandedPerfTaskId) return + const target = tasks.find(task => task.id === expandedPerfTaskId) + if (!target || target.status !== 'running' || !isTextBatchTask(target)) return + const timer = window.setInterval(() => setNowTick(Date.now()), 1000) + return () => window.clearInterval(timer) + }, [isTaskCenterOpen, expandedPerfTaskId, tasks]) - const loadSessions = useCallback(async () => { - setIsLoading(true) + const loadBaseConfig = useCallback(async (): Promise => { + setIsBaseConfigLoading(true) + let isReady = true try { - const result = await window.electronAPI.chat.connect() - if (!result.success) { - console.error('连接失败:', result.error) - setIsLoading(false) - return - } - const sessionsResult = await window.electronAPI.chat.getSessions() - if (sessionsResult.success && sessionsResult.sessions) { - setSessions(sessionsResult.sessions) - setFilteredSessions(sessionsResult.sessions) - } - } catch (e) { - console.error('加载会话失败:', e) - } finally { - setIsLoading(false) - } - }, []) + const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([ + configService.getExportPath(), + configService.getExportDefaultFormat(), + configService.getExportDefaultAvatars(), + configService.getExportDefaultMedia(), + configService.getExportDefaultVoiceAsText(), + configService.getExportDefaultExcelCompactColumns(), + configService.getExportDefaultTxtColumns(), + configService.getExportDefaultConcurrency(), + configService.getExportDefaultImageDeepSearchOnMiss(), + configService.getExportLastSessionRunMap(), + configService.getExportLastContentRunMap(), + configService.getExportSessionRecordMap(), + configService.getExportLastSnsPostCount(), + configService.getExportWriteLayout(), + configService.getExportSessionNamePrefixEnabled(), + configService.getExportDefaultDateRange(), + ensureExportCacheScope() + ]) + + const cachedSnsStats = await configService.getExportSnsStatsCache(exportCacheScope) - const loadExportPath = useCallback(async () => { - try { - const savedPath = await configService.getExportPath() if (savedPath) { setExportFolder(savedPath) } else { const downloadsPath = await window.electronAPI.app.getDownloadsPath() setExportFolder(downloadsPath) } - } catch (e) { - console.error('加载导出路径失败:', e) - } - }, []) - const loadExportDefaults = useCallback(async () => { - try { - const [ - savedFormat, - savedRange, - savedMedia, - savedVoiceAsText, - savedExcelCompactColumns, - savedTxtColumns, - savedConcurrency - ] = await Promise.all([ - configService.getExportDefaultFormat(), - configService.getExportDefaultDateRange(), - configService.getExportDefaultMedia(), - configService.getExportDefaultVoiceAsText(), - configService.getExportDefaultExcelCompactColumns(), - configService.getExportDefaultTxtColumns(), - configService.getExportDefaultConcurrency() - ]) + setWriteLayout(savedWriteLayout) + setSessionNameWithTypePrefix(savedSessionNameWithTypePrefix) + setLastExportBySession(savedSessionMap) + setLastExportByContent(savedContentMap) + setExportRecordsBySession(savedSessionRecordMap) + setLastSnsExportPostCount(savedSnsPostCount) + setExportDefaultFormat((savedFormat as TextExportFormat) || 'excel') + setExportDefaultAvatars(savedAvatars ?? true) + setExportDefaultMedia(savedMedia ?? { + images: true, + videos: true, + voices: true, + emojis: true + }) + setExportDefaultVoiceAsText(savedVoiceAsText ?? false) + setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) + setExportDefaultConcurrency(savedConcurrency ?? 2) + setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true) + const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange) + setExportDefaultDateRangeSelection(resolvedDefaultDateRange) + setTimeRangeSelection(resolvedDefaultDateRange) + + if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) { + setSnsStats({ + totalPosts: cachedSnsStats.totalPosts || 0, + totalFriends: cachedSnsStats.totalFriends || 0 + }) + snsStatsHydratedAtRef.current = Date.now() + hasSeededSnsStatsRef.current = true + setHasSeededSnsStats(true) + } - const preset = savedRange || 'today' - const rangeDefaults = buildDateRangeFromPreset(preset) const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns - - setOptions((prev) => ({ + setOptions(prev => ({ ...prev, - format: (savedFormat as ExportOptions['format']) || 'excel', - useAllTime: rangeDefaults.useAllTime, - dateRange: rangeDefaults.dateRange, - exportMedia: savedMedia ?? false, - exportVoiceAsText: savedVoiceAsText ?? false, - excelCompactColumns: savedExcelCompactColumns ?? true, + format: ((savedFormat as TextExportFormat) || 'excel'), + exportAvatars: savedAvatars ?? true, + exportMedia: Boolean( + (savedMedia?.images ?? prev.exportImages) || + (savedMedia?.voices ?? prev.exportVoices) || + (savedMedia?.videos ?? prev.exportVideos) || + (savedMedia?.emojis ?? prev.exportEmojis) + ), + exportImages: savedMedia?.images ?? prev.exportImages, + exportVoices: savedMedia?.voices ?? prev.exportVoices, + exportVideos: savedMedia?.videos ?? prev.exportVideos, + exportEmojis: savedMedia?.emojis ?? prev.exportEmojis, + exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, + excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, txtColumns, - exportConcurrency: savedConcurrency ?? 2 + exportConcurrency: savedConcurrency ?? prev.exportConcurrency, + imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss })) - } catch (e) { - console.error('加载导出默认设置失败:', e) + } catch (error) { + isReady = false + console.error('加载导出配置失败:', error) + } finally { + setIsBaseConfigLoading(false) + } + if (isReady) { + hasBaseConfigReadyRef.current = true + } + return isReady + }, [ensureExportCacheScope]) + + const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => { + if (!options?.silent) { + setIsSnsStatsLoading(true) + } + + const applyStats = async (next: { totalPosts: number; totalFriends: number } | null) => { + if (!next) return + const normalized = { + totalPosts: Number.isFinite(next.totalPosts) ? Math.max(0, Math.floor(next.totalPosts)) : 0, + totalFriends: Number.isFinite(next.totalFriends) ? Math.max(0, Math.floor(next.totalFriends)) : 0 + } + setSnsStats(normalized) + snsStatsHydratedAtRef.current = Date.now() + hasSeededSnsStatsRef.current = true + setHasSeededSnsStats(true) + if (exportCacheScopeReadyRef.current) { + await configService.setExportSnsStatsCache(exportCacheScopeRef.current, normalized) + } + } + + try { + const fastResult = await withTimeout(window.electronAPI.sns.getExportStatsFast(), 2200) + if (fastResult?.success && fastResult.data) { + const fastStats = { + totalPosts: fastResult.data.totalPosts || 0, + totalFriends: fastResult.data.totalFriends || 0 + } + if (fastStats.totalPosts > 0 || hasSeededSnsStatsRef.current) { + await applyStats(fastStats) + } + } + + if (options?.full) { + const result = await withTimeout(window.electronAPI.sns.getExportStats(), 9000) + if (result?.success && result.data) { + await applyStats({ + totalPosts: result.data.totalPosts || 0, + totalFriends: result.data.totalFriends || 0 + }) + } + } + } catch (error) { + console.error('加载朋友圈导出统计失败:', error) + } finally { + if (!options?.silent) { + setIsSnsStatsLoading(false) + } } }, []) + const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => { + if (snsUserPostCountsStatus === 'loading') return + if (!options?.force && snsUserPostCountsStatus === 'ready') return + + const targetSessionIds = sessionsRef.current + .filter((session) => session.hasSession && isSingleContactSession(session.username)) + .map((session) => session.username) + + snsUserPostCountsHydrationTokenRef.current += 1 + const runToken = snsUserPostCountsHydrationTokenRef.current + if (snsUserPostCountsBatchTimerRef.current) { + window.clearTimeout(snsUserPostCountsBatchTimerRef.current) + snsUserPostCountsBatchTimerRef.current = null + } + + if (targetSessionIds.length === 0) { + setSnsUserPostCountsStatus('ready') + return + } + + const scopeKey = exportCacheScopeReadyRef.current + ? exportCacheScopeRef.current + : await ensureExportCacheScope() + const targetSet = new Set(targetSessionIds) + let cachedCounts: Record = {} + try { + const cached = await configService.getExportSnsUserPostCountsCache(scopeKey) + cachedCounts = cached?.counts || {} + } catch (cacheError) { + console.error('读取导出页朋友圈条数缓存失败:', cacheError) + } + + const cachedTargetCounts = Object.entries(cachedCounts).reduce>((acc, [sessionId, countRaw]) => { + if (!targetSet.has(sessionId)) return acc + const nextCount = Number(countRaw) + acc[sessionId] = Number.isFinite(nextCount) ? Math.max(0, Math.floor(nextCount)) : 0 + return acc + }, {}) + const cachedReadySessionIds = Object.keys(cachedTargetCounts) + if (cachedReadySessionIds.length > 0) { + setSnsUserPostCounts(prev => ({ ...prev, ...cachedTargetCounts })) + patchSessionLoadTraceStage(cachedReadySessionIds, 'snsPostCounts', 'done') + } + + const pendingSessionIds = options?.force + ? targetSessionIds + : targetSessionIds.filter((sessionId) => !(sessionId in cachedTargetCounts)) + if (pendingSessionIds.length === 0) { + setSnsUserPostCountsStatus('ready') + return + } + + patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'pending', { force: true }) + patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'loading') + setSnsUserPostCountsStatus('loading') + + let normalizedCounts: Record = {} + try { + const result = await window.electronAPI.sns.getUserPostCounts() + if (runToken !== snsUserPostCountsHydrationTokenRef.current) return + + if (!result.success || !result.counts) { + patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'failed', { + error: result.error || '朋友圈条数统计失败' + }) + setSnsUserPostCountsStatus('error') + return + } + + for (const [rawUsername, rawCount] of Object.entries(result.counts)) { + const username = String(rawUsername || '').trim() + if (!username) continue + const value = Number(rawCount) + normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 + } + + void (async () => { + try { + await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts) + } catch (cacheError) { + console.error('写入导出页朋友圈条数缓存失败:', cacheError) + } + })() + } catch (error) { + console.error('加载朋友圈用户条数失败:', error) + if (runToken !== snsUserPostCountsHydrationTokenRef.current) return + patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'failed', { + error: String(error) + }) + setSnsUserPostCountsStatus('error') + return + } + + let cursor = 0 + const applyBatch = () => { + if (runToken !== snsUserPostCountsHydrationTokenRef.current) return + + const batchSessionIds = pendingSessionIds.slice(cursor, cursor + SNS_USER_POST_COUNT_BATCH_SIZE) + if (batchSessionIds.length === 0) { + setSnsUserPostCountsStatus('ready') + snsUserPostCountsBatchTimerRef.current = null + return + } + + const batchCounts: Record = {} + for (const sessionId of batchSessionIds) { + const nextCount = normalizedCounts[sessionId] + batchCounts[sessionId] = Number.isFinite(nextCount) ? Math.max(0, Math.floor(nextCount)) : 0 + } + + setSnsUserPostCounts(prev => ({ ...prev, ...batchCounts })) + patchSessionLoadTraceStage(batchSessionIds, 'snsPostCounts', 'done') + + cursor += batchSessionIds.length + if (cursor < targetSessionIds.length) { + snsUserPostCountsBatchTimerRef.current = window.setTimeout(applyBatch, SNS_USER_POST_COUNT_BATCH_INTERVAL_MS) + } else { + setSnsUserPostCountsStatus('ready') + snsUserPostCountsBatchTimerRef.current = null + } + } + + applyBatch() + }, [ensureExportCacheScope, patchSessionLoadTraceStage, snsUserPostCountsStatus]) + + const loadSessionSnsTimelinePosts = useCallback(async (target: SessionSnsTimelineTarget, options?: { reset?: boolean }) => { + const reset = Boolean(options?.reset) + if (sessionSnsTimelineLoadingRef.current) return + + sessionSnsTimelineLoadingRef.current = true + if (reset) { + setSessionSnsTimelineLoading(true) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineHasMore(false) + } else { + setSessionSnsTimelineLoadingMore(true) + } + + const requestToken = ++sessionSnsTimelineRequestTokenRef.current + + try { + const limit = 20 + let endTime: number | undefined + if (!reset && sessionSnsTimelinePostsRef.current.length > 0) { + endTime = sessionSnsTimelinePostsRef.current[sessionSnsTimelinePostsRef.current.length - 1].createTime - 1 + } + + const result = await window.electronAPI.sns.getTimeline(limit, 0, [target.username], '', undefined, endTime) + if (requestToken !== sessionSnsTimelineRequestTokenRef.current) return + + if (!result.success || !Array.isArray(result.timeline)) { + if (reset) { + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineHasMore(false) + } + return + } + + const timeline = [...(result.timeline as SnsPost[])].sort((a, b) => b.createTime - a.createTime) + if (reset) { + setSessionSnsTimelinePosts(timeline) + setSessionSnsTimelineHasMore(timeline.length >= limit) + return + } + + const existingIds = new Set(sessionSnsTimelinePostsRef.current.map((post) => post.id)) + const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id)) + if (uniqueOlder.length > 0) { + const merged = [...sessionSnsTimelinePostsRef.current, ...uniqueOlder].sort((a, b) => b.createTime - a.createTime) + setSessionSnsTimelinePosts(merged) + } + if (timeline.length < limit) { + setSessionSnsTimelineHasMore(false) + } + } catch (error) { + console.error('加载联系人朋友圈失败:', error) + if (requestToken === sessionSnsTimelineRequestTokenRef.current && reset) { + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineHasMore(false) + } + } finally { + if (requestToken === sessionSnsTimelineRequestTokenRef.current) { + sessionSnsTimelineLoadingRef.current = false + setSessionSnsTimelineLoading(false) + setSessionSnsTimelineLoadingMore(false) + } + } + }, []) + + const closeSessionSnsTimeline = useCallback(() => { + sessionSnsTimelineRequestTokenRef.current += 1 + sessionSnsTimelineLoadingRef.current = false + sessionSnsRankRequestTokenRef.current += 1 + sessionSnsRankLoadingRef.current = false + setSessionSnsRankMode(null) + setSessionSnsLikeRankings([]) + setSessionSnsCommentRankings([]) + setSessionSnsRankLoading(false) + setSessionSnsRankError(null) + setSessionSnsRankLoadedPosts(0) + setSessionSnsRankTotalPosts(null) + setSessionSnsTimelineTarget(null) + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineLoading(false) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineHasMore(false) + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(false) + }, []) + + const sessionSnsTimelineInitialTotalPosts = useMemo(() => { + const username = String(sessionSnsTimelineTarget?.username || '').trim() + if (!username) return null + if (!Object.prototype.hasOwnProperty.call(snsUserPostCounts, username)) return null + const count = Number(snsUserPostCounts[username] || 0) + return Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0 + }, [sessionSnsTimelineTarget, snsUserPostCounts]) + + const sessionSnsTimelineInitialTotalPostsLoading = useMemo(() => { + const username = String(sessionSnsTimelineTarget?.username || '').trim() + if (!username) return false + if (Object.prototype.hasOwnProperty.call(snsUserPostCounts, username)) return false + return snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle' + }, [sessionSnsTimelineTarget, snsUserPostCounts, snsUserPostCountsStatus]) + + const openSessionSnsTimelineByTarget = useCallback((target: SessionSnsTimelineTarget) => { + sessionSnsRankRequestTokenRef.current += 1 + sessionSnsRankLoadingRef.current = false + setSessionSnsRankMode(null) + setSessionSnsLikeRankings([]) + setSessionSnsCommentRankings([]) + setSessionSnsRankLoading(false) + setSessionSnsRankError(null) + setSessionSnsRankLoadedPosts(0) + setSessionSnsTimelineTarget(target) + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineHasMore(false) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineLoading(false) + const hasKnownCount = Object.prototype.hasOwnProperty.call(snsUserPostCounts, target.username) + if (hasKnownCount) { + const count = Number(snsUserPostCounts[target.username] || 0) + const normalizedCount = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0 + setSessionSnsTimelineTotalPosts(normalizedCount) + setSessionSnsTimelineStatsLoading(false) + setSessionSnsRankTotalPosts(normalizedCount) + } else { + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') + setSessionSnsRankTotalPosts(null) + } + + void loadSnsUserPostCounts() + }, [ + loadSnsUserPostCounts, + snsUserPostCounts, + snsUserPostCountsStatus + ]) + + const openSessionSnsTimeline = useCallback(() => { + const normalizedSessionId = String(sessionDetail?.wxid || '').trim() + if (!isSingleContactSession(normalizedSessionId) || !sessionDetail) return + + const target: SessionSnsTimelineTarget = { + username: normalizedSessionId, + displayName: sessionDetail.displayName || sessionDetail.remark || sessionDetail.nickName || normalizedSessionId, + avatarUrl: sessionDetail.avatarUrl + } + + openSessionSnsTimelineByTarget(target) + }, [openSessionSnsTimelineByTarget, sessionDetail]) + + const openContactSnsTimeline = useCallback((contact: ContactInfo) => { + const normalizedSessionId = String(contact?.username || '').trim() + if (!isSingleContactSession(normalizedSessionId)) return + openSessionSnsTimelineByTarget({ + username: normalizedSessionId, + displayName: contact.displayName || contact.remark || contact.nickname || normalizedSessionId, + avatarUrl: contact.avatarUrl + }) + }, [openSessionSnsTimelineByTarget]) + + const openSessionMutualFriendsDialog = useCallback((contact: ContactInfo) => { + const normalizedSessionId = String(contact?.username || '').trim() + if (!normalizedSessionId || !isSingleContactSession(normalizedSessionId)) return + const metric = sessionMutualFriendsMetricsRef.current[normalizedSessionId] + if (!metric) return + setSessionMutualFriendsSearch('') + setSessionMutualFriendsDialogTarget({ + username: normalizedSessionId, + displayName: contact.displayName || contact.remark || contact.nickname || normalizedSessionId, + avatarUrl: contact.avatarUrl + }) + }, []) + + const closeSessionMutualFriendsDialog = useCallback(() => { + setSessionMutualFriendsDialogTarget(null) + setSessionMutualFriendsSearch('') + }, []) + + const loadMoreSessionSnsTimeline = useCallback(() => { + if (!sessionSnsTimelineTarget || sessionSnsTimelineLoading || sessionSnsTimelineLoadingMore || !sessionSnsTimelineHasMore) return + void loadSessionSnsTimelinePosts(sessionSnsTimelineTarget, { reset: false }) + }, [ + loadSessionSnsTimelinePosts, + sessionSnsTimelineHasMore, + sessionSnsTimelineLoading, + sessionSnsTimelineLoadingMore, + sessionSnsTimelineTarget + ]) + + const loadSessionSnsRankings = useCallback(async (target: SessionSnsTimelineTarget) => { + const normalizedUsername = String(target?.username || '').trim() + if (!normalizedUsername || sessionSnsRankLoadingRef.current) return + + const knownTotal = snsUserPostCountsStatus === 'ready' + ? Number(snsUserPostCounts[normalizedUsername] || 0) + : null + const normalizedKnownTotal = knownTotal !== null && Number.isFinite(knownTotal) + ? Math.max(0, Math.floor(knownTotal)) + : null + const cached = sessionSnsRankCacheRef.current[normalizedUsername] + + if (cached && (normalizedKnownTotal === null || cached.totalPosts === normalizedKnownTotal)) { + setSessionSnsLikeRankings(cached.likes) + setSessionSnsCommentRankings(cached.comments) + setSessionSnsRankLoadedPosts(cached.totalPosts) + setSessionSnsRankTotalPosts(cached.totalPosts) + setSessionSnsRankError(null) + setSessionSnsRankLoading(false) + return + } + + sessionSnsRankLoadingRef.current = true + const requestToken = ++sessionSnsRankRequestTokenRef.current + setSessionSnsRankLoading(true) + setSessionSnsRankError(null) + setSessionSnsRankLoadedPosts(0) + setSessionSnsRankTotalPosts(normalizedKnownTotal) + + try { + const allPosts: SnsPost[] = [] + let endTime: number | undefined + let hasMore = true + + while (hasMore) { + const result = await window.electronAPI.sns.getTimeline( + SNS_RANK_PAGE_SIZE, + 0, + [normalizedUsername], + '', + undefined, + endTime + ) + if (requestToken !== sessionSnsRankRequestTokenRef.current) return + + if (!result.success) { + throw new Error(result.error || '加载朋友圈排行失败') + } + + const pagePosts = Array.isArray(result.timeline) + ? [...(result.timeline as SnsPost[])].sort((a, b) => b.createTime - a.createTime) + : [] + if (pagePosts.length === 0) { + hasMore = false + break + } + + allPosts.push(...pagePosts) + setSessionSnsRankLoadedPosts(allPosts.length) + if (normalizedKnownTotal === null) { + setSessionSnsRankTotalPosts(allPosts.length) + } + + endTime = pagePosts[pagePosts.length - 1].createTime - 1 + hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE + } + + if (requestToken !== sessionSnsRankRequestTokenRef.current) return + + const rankings = buildSessionSnsRankings(allPosts) + const totalPosts = allPosts.length + sessionSnsRankCacheRef.current[normalizedUsername] = { + likes: rankings.likes, + comments: rankings.comments, + totalPosts, + computedAt: Date.now() + } + setSessionSnsLikeRankings(rankings.likes) + setSessionSnsCommentRankings(rankings.comments) + setSessionSnsRankLoadedPosts(totalPosts) + setSessionSnsRankTotalPosts(totalPosts) + setSessionSnsRankError(null) + } catch (error) { + if (requestToken !== sessionSnsRankRequestTokenRef.current) return + const message = error instanceof Error ? error.message : String(error) + setSessionSnsLikeRankings([]) + setSessionSnsCommentRankings([]) + setSessionSnsRankError(message || '加载朋友圈排行失败') + } finally { + if (requestToken === sessionSnsRankRequestTokenRef.current) { + sessionSnsRankLoadingRef.current = false + setSessionSnsRankLoading(false) + } + } + }, [snsUserPostCounts, snsUserPostCountsStatus]) + + const renderSessionSnsTimelineStats = useCallback((): string => { + const loadedCount = sessionSnsTimelinePosts.length + const loadPart = sessionSnsTimelineStatsLoading + ? `已加载 ${loadedCount} / 总数统计中...` + : sessionSnsTimelineTotalPosts === null + ? `已加载 ${loadedCount} 条` + : `已加载 ${loadedCount} / 共 ${sessionSnsTimelineTotalPosts} 条` + + if (sessionSnsTimelineLoading && loadedCount === 0) return `${loadPart} | 加载中...` + if (loadedCount === 0) return loadPart + + const latest = sessionSnsTimelinePosts[0]?.createTime + const earliest = sessionSnsTimelinePosts[sessionSnsTimelinePosts.length - 1]?.createTime + const rangeText = `${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}` + return `${loadPart} | ${rangeText}` + }, [ + sessionSnsTimelineLoading, + sessionSnsTimelinePosts, + sessionSnsTimelineStatsLoading, + sessionSnsTimelineTotalPosts + ]) + + const toggleSessionSnsRankMode = useCallback((mode: SnsRankMode) => { + setSessionSnsRankMode((prev) => (prev === mode ? null : mode)) + }, []) + + const sessionSnsActiveRankings = useMemo(() => { + if (sessionSnsRankMode === 'likes') return sessionSnsLikeRankings + if (sessionSnsRankMode === 'comments') return sessionSnsCommentRankings + return [] + }, [sessionSnsCommentRankings, sessionSnsLikeRankings, sessionSnsRankMode]) + + const mergeSessionContentMetrics = useCallback((input: Record) => { + const entries = Object.entries(input) + if (entries.length === 0) return + + const nextMessageCounts: Record = {} + const nextMetrics: Record = {} + + for (const [sessionIdRaw, metricRaw] of entries) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId || !metricRaw) continue + const totalMessages = normalizeMessageCount(metricRaw.totalMessages) + const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages) + const imageMessages = normalizeMessageCount(metricRaw.imageMessages) + const videoMessages = normalizeMessageCount(metricRaw.videoMessages) + const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages) + const transferMessages = normalizeMessageCount(metricRaw.transferMessages) + const redPacketMessages = normalizeMessageCount(metricRaw.redPacketMessages) + const callMessages = normalizeMessageCount(metricRaw.callMessages) + + if ( + typeof totalMessages !== 'number' && + typeof voiceMessages !== 'number' && + typeof imageMessages !== 'number' && + typeof videoMessages !== 'number' && + typeof emojiMessages !== 'number' && + typeof transferMessages !== 'number' && + typeof redPacketMessages !== 'number' && + typeof callMessages !== 'number' && + typeof normalizeTimestampSeconds(metricRaw.firstTimestamp) !== 'number' && + typeof normalizeTimestampSeconds(metricRaw.lastTimestamp) !== 'number' + ) { + continue + } + + nextMetrics[sessionId] = { + totalMessages, + voiceMessages, + imageMessages, + videoMessages, + emojiMessages, + transferMessages, + redPacketMessages, + callMessages, + firstTimestamp: normalizeTimestampSeconds(metricRaw.firstTimestamp), + lastTimestamp: normalizeTimestampSeconds(metricRaw.lastTimestamp) + } + if (typeof totalMessages === 'number') { + nextMessageCounts[sessionId] = totalMessages + } + } + + if (Object.keys(nextMessageCounts).length > 0) { + setSessionMessageCounts(prev => { + let changed = false + const merged = { ...prev } + for (const [sessionId, count] of Object.entries(nextMessageCounts)) { + if (merged[sessionId] === count) continue + merged[sessionId] = count + changed = true + } + return changed ? merged : prev + }) + } + + if (Object.keys(nextMetrics).length > 0) { + setSessionContentMetrics(prev => { + let changed = false + const merged = { ...prev } + for (const [sessionId, metric] of Object.entries(nextMetrics)) { + const previous = merged[sessionId] || {} + const nextMetric: SessionContentMetric = { + totalMessages: typeof metric.totalMessages === 'number' ? metric.totalMessages : previous.totalMessages, + voiceMessages: typeof metric.voiceMessages === 'number' ? metric.voiceMessages : previous.voiceMessages, + imageMessages: typeof metric.imageMessages === 'number' ? metric.imageMessages : previous.imageMessages, + videoMessages: typeof metric.videoMessages === 'number' ? metric.videoMessages : previous.videoMessages, + emojiMessages: typeof metric.emojiMessages === 'number' ? metric.emojiMessages : previous.emojiMessages, + transferMessages: typeof metric.transferMessages === 'number' ? metric.transferMessages : previous.transferMessages, + redPacketMessages: typeof metric.redPacketMessages === 'number' ? metric.redPacketMessages : previous.redPacketMessages, + callMessages: typeof metric.callMessages === 'number' ? metric.callMessages : previous.callMessages, + firstTimestamp: typeof metric.firstTimestamp === 'number' ? metric.firstTimestamp : previous.firstTimestamp, + lastTimestamp: typeof metric.lastTimestamp === 'number' ? metric.lastTimestamp : previous.lastTimestamp + } + if ( + previous.totalMessages === nextMetric.totalMessages && + previous.voiceMessages === nextMetric.voiceMessages && + previous.imageMessages === nextMetric.imageMessages && + previous.videoMessages === nextMetric.videoMessages && + previous.emojiMessages === nextMetric.emojiMessages && + previous.transferMessages === nextMetric.transferMessages && + previous.redPacketMessages === nextMetric.redPacketMessages && + previous.callMessages === nextMetric.callMessages && + previous.firstTimestamp === nextMetric.firstTimestamp && + previous.lastTimestamp === nextMetric.lastTimestamp + ) { + continue + } + merged[sessionId] = nextMetric + changed = true + } + return changed ? merged : prev + }) + } + }, []) + + const resetSessionMediaMetricLoader = useCallback(() => { + sessionMediaMetricRunIdRef.current += 1 + sessionMediaMetricQueueRef.current = [] + sessionMediaMetricQueuedSetRef.current.clear() + sessionMediaMetricLoadingSetRef.current.clear() + sessionMediaMetricReadySetRef.current.clear() + sessionMediaMetricWorkerRunningRef.current = false + sessionMediaMetricPendingPersistRef.current = {} + sessionMediaMetricVisibleRangeRef.current = { startIndex: 0, endIndex: -1 } + if (sessionMediaMetricBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current) + sessionMediaMetricBackgroundFeedTimerRef.current = null + } + if (sessionMediaMetricPersistTimerRef.current) { + window.clearTimeout(sessionMediaMetricPersistTimerRef.current) + sessionMediaMetricPersistTimerRef.current = null + } + }, []) + + const flushSessionMediaMetricCache = useCallback(async () => { + const pendingMetrics = sessionMediaMetricPendingPersistRef.current + sessionMediaMetricPendingPersistRef.current = {} + if (Object.keys(pendingMetrics).length === 0) return + + try { + const scopeKey = await ensureExportCacheScope() + const existing = await configService.getExportSessionContentMetricCache(scopeKey) + const nextMetrics = { + ...(existing?.metrics || {}), + ...pendingMetrics + } + await configService.setExportSessionContentMetricCache(scopeKey, nextMetrics) + } catch (error) { + console.error('写入导出页会话内容统计缓存失败:', error) + } + }, [ensureExportCacheScope]) + + const scheduleFlushSessionMediaMetricCache = useCallback(() => { + if (sessionMediaMetricPersistTimerRef.current) return + sessionMediaMetricPersistTimerRef.current = window.setTimeout(() => { + sessionMediaMetricPersistTimerRef.current = null + void flushSessionMediaMetricCache() + }, SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS) + }, [flushSessionMediaMetricCache]) + + const resetSessionMutualFriendsLoader = useCallback(() => { + sessionMutualFriendsRunIdRef.current += 1 + sessionMutualFriendsDirectMetricsRef.current = {} + sessionMutualFriendsQueueRef.current = [] + sessionMutualFriendsQueuedSetRef.current.clear() + sessionMutualFriendsLoadingSetRef.current.clear() + sessionMutualFriendsReadySetRef.current.clear() + sessionMutualFriendsWorkerRunningRef.current = false + sessionMutualFriendsVisibleRangeRef.current = { startIndex: 0, endIndex: -1 } + if (sessionMutualFriendsBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current) + sessionMutualFriendsBackgroundFeedTimerRef.current = null + } + if (sessionMutualFriendsPersistTimerRef.current) { + window.clearTimeout(sessionMutualFriendsPersistTimerRef.current) + sessionMutualFriendsPersistTimerRef.current = null + } + }, []) + + const flushSessionMutualFriendsCache = useCallback(async () => { + try { + const scopeKey = await ensureExportCacheScope() + await configService.setExportSessionMutualFriendsCache( + scopeKey, + sessionMutualFriendsDirectMetricsRef.current + ) + } catch (error) { + console.error('写入导出页共同好友缓存失败:', error) + } + }, [ensureExportCacheScope]) + + const scheduleFlushSessionMutualFriendsCache = useCallback(() => { + if (sessionMutualFriendsPersistTimerRef.current) return + sessionMutualFriendsPersistTimerRef.current = window.setTimeout(() => { + sessionMutualFriendsPersistTimerRef.current = null + void flushSessionMutualFriendsCache() + }, SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS) + }, [flushSessionMutualFriendsCache]) + + const isSessionMutualFriendsReady = useCallback((sessionId: string): boolean => { + if (!sessionId) return true + if (sessionMutualFriendsReadySetRef.current.has(sessionId)) return true + const existing = sessionMutualFriendsMetricsRef.current[sessionId] + if (existing && typeof existing.count === 'number' && Array.isArray(existing.items)) { + sessionMutualFriendsReadySetRef.current.add(sessionId) + return true + } + return false + }, []) + + const enqueueSessionMutualFriendsRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => { + if (activeTaskCountRef.current > 0) return + const front = options?.front === true + const incoming: string[] = [] + for (const sessionIdRaw of sessionIds) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId) continue + if (sessionMutualFriendsQueuedSetRef.current.has(sessionId)) continue + if (sessionMutualFriendsLoadingSetRef.current.has(sessionId)) continue + if (isSessionMutualFriendsReady(sessionId)) continue + sessionMutualFriendsQueuedSetRef.current.add(sessionId) + incoming.push(sessionId) + } + if (incoming.length === 0) return + patchSessionLoadTraceStage(incoming, 'mutualFriends', 'pending') + if (front) { + sessionMutualFriendsQueueRef.current = [...incoming, ...sessionMutualFriendsQueueRef.current] + } else { + sessionMutualFriendsQueueRef.current.push(...incoming) + } + }, [isSessionMutualFriendsReady, patchSessionLoadTraceStage]) + + const hasPendingMetricLoads = useCallback((): boolean => ( + isLoadingSessionCountsRef.current || + sessionMediaMetricQueuedSetRef.current.size > 0 || + sessionMediaMetricLoadingSetRef.current.size > 0 || + sessionMediaMetricWorkerRunningRef.current || + snsUserPostCountsStatus === 'loading' || + snsUserPostCountsStatus === 'idle' + ), [snsUserPostCountsStatus]) + + const getSessionMutualFriendProfile = useCallback((sessionId: string): { + displayName: string + candidateNames: Set + } => { + const normalizedSessionId = String(sessionId || '').trim() + const contact = contactsList.find(item => item.username === normalizedSessionId) + const session = sessionsRef.current.find(item => item.username === normalizedSessionId) + const displayName = contact?.displayName || contact?.remark || contact?.nickname || session?.displayName || normalizedSessionId + return { + displayName, + candidateNames: toComparableNameSet([ + displayName, + contact?.displayName, + contact?.remark, + contact?.nickname, + contact?.alias + ]) + } + }, [contactsList]) + + const rebuildSessionMutualFriendsMetric = useCallback((targetSessionId: string): SessionMutualFriendsMetric | null => { + const normalizedTargetSessionId = String(targetSessionId || '').trim() + if (!normalizedTargetSessionId) return null + + const directMetrics = sessionMutualFriendsDirectMetricsRef.current + const directMetric = directMetrics[normalizedTargetSessionId] + if (!directMetric) return null + + const { candidateNames } = getSessionMutualFriendProfile(normalizedTargetSessionId) + const mergedMap = new Map() + for (const item of directMetric.items) { + mergedMap.set(item.name, { ...item }) + } + + for (const [sourceSessionId, sourceMetric] of Object.entries(directMetrics)) { + if (!sourceMetric || sourceSessionId === normalizedTargetSessionId) continue + const sourceProfile = getSessionMutualFriendProfile(sourceSessionId) + if (!sourceProfile.displayName) continue + if (mergedMap.has(sourceProfile.displayName)) continue + + const reverseMatches = sourceMetric.items.filter(item => candidateNames.has(item.name)) + if (reverseMatches.length === 0) continue + + const reverseCount = reverseMatches.reduce((sum, item) => sum + item.totalCount, 0) + const reverseLikeCount = reverseMatches.reduce((sum, item) => sum + item.incomingLikeCount, 0) + const reverseCommentCount = reverseMatches.reduce((sum, item) => sum + item.incomingCommentCount, 0) + const reverseLatestTime = reverseMatches.reduce((latest, item) => Math.max(latest, item.latestTime), 0) + const existing = mergedMap.get(sourceProfile.displayName) + if (existing) { + existing.outgoingLikeCount += reverseLikeCount + existing.outgoingCommentCount += reverseCommentCount + existing.totalCount += reverseCount + existing.latestTime = Math.max(existing.latestTime, reverseLatestTime) + existing.direction = (existing.incomingLikeCount + existing.incomingCommentCount) > 0 + ? 'bidirectional' + : 'outgoing' + existing.behavior = summarizeMutualFriendBehavior( + existing.incomingLikeCount + existing.outgoingLikeCount, + existing.incomingCommentCount + existing.outgoingCommentCount + ) + } else { + mergedMap.set(sourceProfile.displayName, { + name: sourceProfile.displayName, + incomingLikeCount: 0, + incomingCommentCount: 0, + outgoingLikeCount: reverseLikeCount, + outgoingCommentCount: reverseCommentCount, + totalCount: reverseCount, + latestTime: reverseLatestTime, + direction: 'outgoing', + behavior: summarizeMutualFriendBehavior(reverseLikeCount, reverseCommentCount) + }) + } + } + + const items = [...mergedMap.values()].sort((a, b) => { + if (b.totalCount !== a.totalCount) return b.totalCount - a.totalCount + if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime + return a.name.localeCompare(b.name, 'zh-CN') + }) + + return { + ...directMetric, + count: items.length, + items + } + }, [getSessionMutualFriendProfile]) + + const rebuildSessionMutualFriendsStateFromDirectMetrics = useCallback((sessionIds?: string[]) => { + const targets = Array.isArray(sessionIds) && sessionIds.length > 0 + ? sessionIds + : Object.keys(sessionMutualFriendsDirectMetricsRef.current) + const nextMetrics: Record = {} + const readyIds: string[] = [] + for (const sessionIdRaw of targets) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId) continue + const rebuilt = rebuildSessionMutualFriendsMetric(sessionId) + if (!rebuilt) continue + nextMetrics[sessionId] = rebuilt + readyIds.push(sessionId) + } + sessionMutualFriendsMetricsRef.current = nextMetrics + setSessionMutualFriendsMetrics(nextMetrics) + if (readyIds.length > 0) { + for (const sessionId of readyIds) { + sessionMutualFriendsReadySetRef.current.add(sessionId) + } + patchSessionLoadTraceStage(readyIds, 'mutualFriends', 'done') + } + }, [patchSessionLoadTraceStage, rebuildSessionMutualFriendsMetric]) + + const applySessionMutualFriendsMetric = useCallback((sessionId: string, directMetric: SessionMutualFriendsMetric) => { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + sessionMutualFriendsDirectMetricsRef.current[normalizedSessionId] = directMetric + scheduleFlushSessionMutualFriendsCache() + + const impactedSessionIds = new Set([normalizedSessionId]) + const allSessionIds = sessionsRef.current + .filter(session => session.hasSession && isSingleContactSession(session.username)) + .map(session => session.username) + + for (const targetSessionId of allSessionIds) { + if (targetSessionId === normalizedSessionId) continue + const targetProfile = getSessionMutualFriendProfile(targetSessionId) + if (directMetric.items.some(item => targetProfile.candidateNames.has(item.name))) { + impactedSessionIds.add(targetSessionId) + } + } + + setSessionMutualFriendsMetrics(prev => { + const next = { ...prev } + let changed = false + for (const targetSessionId of impactedSessionIds) { + const rebuiltMetric = rebuildSessionMutualFriendsMetric(targetSessionId) + if (!rebuiltMetric) continue + const previousMetric = prev[targetSessionId] + const previousSerialized = previousMetric ? JSON.stringify(previousMetric) : '' + const nextSerialized = JSON.stringify(rebuiltMetric) + if (previousSerialized === nextSerialized) continue + next[targetSessionId] = rebuiltMetric + changed = true + } + return changed ? next : prev + }) + }, [getSessionMutualFriendProfile, rebuildSessionMutualFriendsMetric, scheduleFlushSessionMutualFriendsCache]) + + const isSessionMediaMetricReady = useCallback((sessionId: string): boolean => { + if (!sessionId) return true + if (sessionMediaMetricReadySetRef.current.has(sessionId)) return true + const existing = sessionContentMetricsRef.current[sessionId] + if (hasCompleteSessionMediaMetric(existing)) { + sessionMediaMetricReadySetRef.current.add(sessionId) + return true + } + return false + }, []) + + const enqueueSessionMediaMetricRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => { + if (activeTaskCountRef.current > 0) return + const front = options?.front === true + const incoming: string[] = [] + for (const sessionIdRaw of sessionIds) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId) continue + if (sessionMediaMetricQueuedSetRef.current.has(sessionId)) continue + if (sessionMediaMetricLoadingSetRef.current.has(sessionId)) continue + if (isSessionMediaMetricReady(sessionId)) continue + sessionMediaMetricQueuedSetRef.current.add(sessionId) + incoming.push(sessionId) + } + if (incoming.length === 0) return + patchSessionLoadTraceStage(incoming, 'mediaMetrics', 'pending') + if (front) { + sessionMediaMetricQueueRef.current = [...incoming, ...sessionMediaMetricQueueRef.current] + } else { + sessionMediaMetricQueueRef.current.push(...incoming) + } + }, [isSessionMediaMetricReady, patchSessionLoadTraceStage]) + + const applySessionMediaMetricsFromStats = useCallback((data?: Record) => { + if (!data) return + const nextMetrics: Record = {} + let hasPatch = false + for (const [sessionIdRaw, metricRaw] of Object.entries(data)) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId) continue + const metric = pickSessionMediaMetric(metricRaw) + if (!metric) continue + nextMetrics[sessionId] = metric + hasPatch = true + sessionMediaMetricPendingPersistRef.current[sessionId] = { + ...sessionMediaMetricPendingPersistRef.current[sessionId], + ...metric + } + if (hasCompleteSessionMediaMetric(metric)) { + sessionMediaMetricReadySetRef.current.add(sessionId) + } + } + + if (hasPatch) { + mergeSessionContentMetrics(nextMetrics) + scheduleFlushSessionMediaMetricCache() + } + }, [mergeSessionContentMetrics, scheduleFlushSessionMediaMetricCache]) + + const runSessionMediaMetricWorker = useCallback(async (runId: number) => { + if (sessionMediaMetricWorkerRunningRef.current) return + sessionMediaMetricWorkerRunningRef.current = true + const withTimeout = async (promise: Promise, timeoutMs: number, stage: string): Promise => { + let timer: number | null = null + try { + const timeoutPromise = new Promise((_, reject) => { + timer = window.setTimeout(() => { + reject(new Error(`会话多媒体统计超时(${stage}, ${timeoutMs}ms)`)) + }, timeoutMs) + }) + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timer !== null) { + window.clearTimeout(timer) + } + } + } + try { + while (runId === sessionMediaMetricRunIdRef.current) { + if (activeTaskCountRef.current > 0) { + await new Promise(resolve => window.setTimeout(resolve, 150)) + continue + } + if (sessionMediaMetricQueueRef.current.length === 0) break + + const batchSessionIds: string[] = [] + while (batchSessionIds.length < SESSION_MEDIA_METRIC_BATCH_SIZE && sessionMediaMetricQueueRef.current.length > 0) { + const nextId = sessionMediaMetricQueueRef.current.shift() + if (!nextId) continue + sessionMediaMetricQueuedSetRef.current.delete(nextId) + if (sessionMediaMetricLoadingSetRef.current.has(nextId)) continue + if (isSessionMediaMetricReady(nextId)) continue + sessionMediaMetricLoadingSetRef.current.add(nextId) + batchSessionIds.push(nextId) + } + if (batchSessionIds.length === 0) { + continue + } + patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'loading') + + try { + const cacheResult = await withTimeout( + window.electronAPI.chat.getExportSessionStats( + batchSessionIds, + { includeRelations: false, allowStaleCache: true, cacheOnly: true } + ), + 12000, + 'cacheOnly' + ) + if (runId !== sessionMediaMetricRunIdRef.current) return + if (cacheResult.success && cacheResult.data) { + applySessionMediaMetricsFromStats(cacheResult.data as Record) + } + + const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId)) + if (missingSessionIds.length > 0) { + const freshResult = await withTimeout( + window.electronAPI.chat.getExportSessionStats( + missingSessionIds, + { includeRelations: false, allowStaleCache: true } + ), + 45000, + 'fresh' + ) + if (runId !== sessionMediaMetricRunIdRef.current) return + if (freshResult.success && freshResult.data) { + applySessionMediaMetricsFromStats(freshResult.data as Record) + } + } + + const unresolvedSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId)) + if (unresolvedSessionIds.length > 0) { + patchSessionLoadTraceStage(unresolvedSessionIds, 'mediaMetrics', 'failed', { + error: '统计结果缺失,已跳过当前批次' + }) + } + } catch (error) { + console.error('导出页加载会话媒体统计失败:', error) + patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'failed', { + error: String(error) + }) + } finally { + const completedSessionIds: string[] = [] + for (const sessionId of batchSessionIds) { + sessionMediaMetricLoadingSetRef.current.delete(sessionId) + if (isSessionMediaMetricReady(sessionId)) { + sessionMediaMetricReadySetRef.current.add(sessionId) + completedSessionIds.push(sessionId) + } + } + if (completedSessionIds.length > 0) { + patchSessionLoadTraceStage(completedSessionIds, 'mediaMetrics', 'done') + } + } + + await new Promise(resolve => window.setTimeout(resolve, 0)) + } + } finally { + sessionMediaMetricWorkerRunningRef.current = false + if (runId === sessionMediaMetricRunIdRef.current && sessionMediaMetricQueueRef.current.length > 0) { + void runSessionMediaMetricWorker(runId) + } + } + }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage]) + + const scheduleSessionMediaMetricWorker = useCallback(() => { + if (activeTaskCountRef.current > 0) return + if (sessionMediaMetricWorkerRunningRef.current) return + const runId = sessionMediaMetricRunIdRef.current + void runSessionMediaMetricWorker(runId) + }, [runSessionMediaMetricWorker]) + + const loadSessionMutualFriendsMetric = useCallback(async (sessionId: string): Promise => { + const normalizedSessionId = String(sessionId || '').trim() + const hasKnownTotal = Object.prototype.hasOwnProperty.call(snsUserPostCounts, normalizedSessionId) + const knownTotalRaw = hasKnownTotal ? Number(snsUserPostCounts[normalizedSessionId] || 0) : NaN + const knownTotal = Number.isFinite(knownTotalRaw) ? Math.max(0, Math.floor(knownTotalRaw)) : null + const allPosts: SnsPost[] = [] + let endTime: number | undefined + let hasMore = true + + while (hasMore) { + const result = await window.electronAPI.sns.getTimeline( + SNS_RANK_PAGE_SIZE, + 0, + [normalizedSessionId], + '', + undefined, + endTime + ) + if (!result.success) { + throw new Error(result.error || '共同好友统计失败') + } + + const pagePosts = Array.isArray(result.timeline) + ? [...(result.timeline as SnsPost[])].sort((a, b) => b.createTime - a.createTime) + : [] + if (pagePosts.length === 0) { + hasMore = false + break + } + + allPosts.push(...pagePosts) + endTime = pagePosts[pagePosts.length - 1].createTime - 1 + hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE + } + + return buildSessionMutualFriendsMetric(allPosts, knownTotal) + }, [snsUserPostCounts]) + + const runSessionMutualFriendsWorker = useCallback(async (runId: number) => { + if (sessionMutualFriendsWorkerRunningRef.current) return + sessionMutualFriendsWorkerRunningRef.current = true + try { + while (runId === sessionMutualFriendsRunIdRef.current) { + if (activeTaskCountRef.current > 0) { + await new Promise(resolve => window.setTimeout(resolve, 150)) + continue + } + if (hasPendingMetricLoads()) { + await new Promise(resolve => window.setTimeout(resolve, 120)) + continue + } + + const sessionId = sessionMutualFriendsQueueRef.current.shift() + if (!sessionId) break + sessionMutualFriendsQueuedSetRef.current.delete(sessionId) + if (sessionMutualFriendsLoadingSetRef.current.has(sessionId)) continue + if (isSessionMutualFriendsReady(sessionId)) continue + + sessionMutualFriendsLoadingSetRef.current.add(sessionId) + patchSessionLoadTraceStage([sessionId], 'mutualFriends', 'loading') + + try { + const metric = await loadSessionMutualFriendsMetric(sessionId) + if (runId !== sessionMutualFriendsRunIdRef.current) return + applySessionMutualFriendsMetric(sessionId, metric) + sessionMutualFriendsReadySetRef.current.add(sessionId) + patchSessionLoadTraceStage([sessionId], 'mutualFriends', 'done') + } catch (error) { + console.error('导出页加载共同好友统计失败:', error) + patchSessionLoadTraceStage([sessionId], 'mutualFriends', 'failed', { + error: error instanceof Error ? error.message : String(error) + }) + } finally { + sessionMutualFriendsLoadingSetRef.current.delete(sessionId) + } + + await new Promise(resolve => window.setTimeout(resolve, 0)) + } + } finally { + sessionMutualFriendsWorkerRunningRef.current = false + if (runId === sessionMutualFriendsRunIdRef.current && sessionMutualFriendsQueueRef.current.length > 0) { + void runSessionMutualFriendsWorker(runId) + } + } + }, [ + applySessionMutualFriendsMetric, + hasPendingMetricLoads, + isSessionMutualFriendsReady, + loadSessionMutualFriendsMetric, + patchSessionLoadTraceStage + ]) + + const scheduleSessionMutualFriendsWorker = useCallback(() => { + if (activeTaskCountRef.current > 0) return + if (!isSessionCountStageReady) return + if (hasPendingMetricLoads()) return + if (sessionMutualFriendsWorkerRunningRef.current) return + const runId = sessionMutualFriendsRunIdRef.current + void runSessionMutualFriendsWorker(runId) + }, [hasPendingMetricLoads, isSessionCountStageReady, runSessionMutualFriendsWorker]) + + const loadSessionMessageCounts = useCallback(async ( + sourceSessions: SessionRow[], + priorityTab: ConversationTab, + options?: { + scopeKey?: string + seededCounts?: Record + } + ): Promise> => { + const requestId = sessionCountRequestIdRef.current + 1 + sessionCountRequestIdRef.current = requestId + const isStale = () => sessionCountRequestIdRef.current !== requestId + setIsSessionCountStageReady(false) + + const exportableSessions = sourceSessions.filter(session => session.hasSession) + const exportableSessionIds = exportableSessions.map(session => session.username) + const exportableSessionIdSet = new Set(exportableSessionIds) + patchSessionLoadTraceStage(exportableSessionIds, 'messageCount', 'pending', { force: true }) + const seededHintCounts = exportableSessions.reduce>((acc, session) => { + const nextCount = normalizeMessageCount(session.messageCountHint) + if (typeof nextCount === 'number') { + acc[session.username] = nextCount + } + return acc + }, {}) + const seededPersistentCounts = Object.entries(options?.seededCounts || {}).reduce>((acc, [sessionId, countRaw]) => { + if (!exportableSessionIdSet.has(sessionId)) return acc + const nextCount = normalizeMessageCount(countRaw) + if (typeof nextCount === 'number') { + acc[sessionId] = nextCount + } + return acc + }, {}) + const seededPersistentSessionIds = Object.keys(seededPersistentCounts) + if (seededPersistentSessionIds.length > 0) { + patchSessionLoadTraceStage(seededPersistentSessionIds, 'messageCount', 'done') + } + const seededCounts = { ...seededHintCounts, ...seededPersistentCounts } + const accumulatedCounts: Record = { ...seededCounts } + setSessionMessageCounts(seededCounts) + if (Object.keys(seededCounts).length > 0) { + mergeSessionContentMetrics( + Object.entries(seededCounts).reduce>((acc, [sessionId, count]) => { + acc[sessionId] = { totalMessages: count } + return acc + }, {}) + ) + } + + if (exportableSessions.length === 0) { + setIsLoadingSessionCounts(false) + if (!isStale()) { + setIsSessionCountStageReady(true) + } + return { ...accumulatedCounts } + } + + const prioritizedSessionIds = exportableSessions + .filter(session => session.kind === priorityTab) + .map(session => session.username) + const prioritizedSet = new Set(prioritizedSessionIds) + const remainingSessionIds = exportableSessions + .filter(session => !prioritizedSet.has(session.username)) + .map(session => session.username) + + const applyCounts = (input: Record | undefined) => { + if (!input || isStale()) return + const normalized = Object.entries(input).reduce>((acc, [sessionId, count]) => { + const nextCount = normalizeMessageCount(count) + if (typeof nextCount === 'number') { + acc[sessionId] = nextCount + } + return acc + }, {}) + if (Object.keys(normalized).length === 0) return + for (const [sessionId, count] of Object.entries(normalized)) { + accumulatedCounts[sessionId] = count + } + setSessionMessageCounts(prev => ({ ...prev, ...normalized })) + mergeSessionContentMetrics( + Object.entries(normalized).reduce>((acc, [sessionId, count]) => { + acc[sessionId] = { totalMessages: count } + return acc + }, {}) + ) + } + + setIsLoadingSessionCounts(true) + try { + if (prioritizedSessionIds.length > 0) { + patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading') + const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds) + if (isStale()) return { ...accumulatedCounts } + if (priorityResult.success) { + applyCounts(priorityResult.counts) + patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'done') + } else { + patchSessionLoadTraceStage( + prioritizedSessionIds, + 'messageCount', + 'failed', + { error: priorityResult.error || '总消息数加载失败' } + ) + } + } + + if (remainingSessionIds.length > 0) { + patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading') + const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds) + if (isStale()) return { ...accumulatedCounts } + if (remainingResult.success) { + applyCounts(remainingResult.counts) + patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'done') + } else { + patchSessionLoadTraceStage( + remainingSessionIds, + 'messageCount', + 'failed', + { error: remainingResult.error || '总消息数加载失败' } + ) + } + } + } catch (error) { + console.error('导出页加载会话消息总数失败:', error) + patchSessionLoadTraceStage(exportableSessionIds, 'messageCount', 'failed', { + error: String(error) + }) + } finally { + if (!isStale()) { + setIsLoadingSessionCounts(false) + setIsSessionCountStageReady(true) + if (options?.scopeKey && Object.keys(accumulatedCounts).length > 0) { + try { + await configService.setExportSessionMessageCountCache(options.scopeKey, accumulatedCounts) + } catch (cacheError) { + console.error('写入导出页会话总消息缓存失败:', cacheError) + } + } + } + } + return { ...accumulatedCounts } + }, [mergeSessionContentMetrics, patchSessionLoadTraceStage]) + + const loadSessions = useCallback(async () => { + const loadToken = Date.now() + sessionLoadTokenRef.current = loadToken + sessionsHydratedAtRef.current = 0 + sessionPreciseRefreshAtRef.current = {} + resetSessionMediaMetricLoader() + resetSessionMutualFriendsLoader() + setIsLoading(true) + setIsSessionEnriching(false) + sessionCountRequestIdRef.current += 1 + setSessionMessageCounts({}) + setSessionContentMetrics({}) + setSessionMutualFriendsMetrics({}) + sessionMutualFriendsMetricsRef.current = {} + setSessionMutualFriendsDialogTarget(null) + setSessionMutualFriendsSearch('') + setSessionLoadTraceMap({}) + setSessionLoadProgressPulseMap({}) + sessionLoadProgressSnapshotRef.current = {} + snsUserPostCountsHydrationTokenRef.current += 1 + if (snsUserPostCountsBatchTimerRef.current) { + window.clearTimeout(snsUserPostCountsBatchTimerRef.current) + snsUserPostCountsBatchTimerRef.current = null + } + setSnsUserPostCounts({}) + setSnsUserPostCountsStatus('idle') + setIsLoadingSessionCounts(false) + setIsSessionCountStageReady(false) + + const isStale = () => sessionLoadTokenRef.current !== loadToken + + try { + const scopeKey = await ensureExportCacheScope() + if (isStale()) return + + const [ + cachedContactsPayload, + cachedMessageCountsPayload, + cachedContentMetricsPayload, + cachedMutualFriendsPayload + ] = await Promise.all([ + loadContactsCaches(scopeKey), + configService.getExportSessionMessageCountCache(scopeKey), + configService.getExportSessionContentMetricCache(scopeKey), + configService.getExportSessionMutualFriendsCache(scopeKey) + ]) + if (isStale()) return + + const { + contactsItem: cachedContactsItem, + avatarItem: cachedAvatarItem + } = cachedContactsPayload + + const cachedContacts = cachedContactsItem?.contacts || [] + const cachedAvatarEntries = cachedAvatarItem?.avatars || {} + const cachedContactMap = toContactMapFromCaches(cachedContacts, cachedAvatarEntries) + if (cachedContacts.length > 0) { + syncContactTypeCounts(Object.values(cachedContactMap)) + setSessions(toSessionRowsWithContacts([], cachedContactMap).filter(isExportConversationSession)) + setSessionDataSource('cache') + setIsLoading(false) + } + setSessionContactsUpdatedAt(cachedContactsItem?.updatedAt || null) + setSessionAvatarUpdatedAt(cachedAvatarItem?.updatedAt || null) + + const connectResult = await window.electronAPI.chat.connect() + if (!connectResult.success) { + console.error('连接失败:', connectResult.error) + if (!isStale()) setIsLoading(false) + return + } + + const sessionsResult = await window.electronAPI.chat.getSessions() + if (isStale()) return + + if (sessionsResult.success && sessionsResult.sessions) { + const rawSessions = sessionsResult.sessions + const baseSessions = toSessionRowsWithContacts(rawSessions, cachedContactMap).filter(isExportConversationSession) + const exportableSessionIds = baseSessions + .filter((session) => session.hasSession) + .map((session) => session.username) + const exportableSessionIdSet = new Set(exportableSessionIds) + + const cachedMessageCounts = Object.entries(cachedMessageCountsPayload?.counts || {}).reduce>((acc, [sessionId, countRaw]) => { + if (!exportableSessionIdSet.has(sessionId)) return acc + const nextCount = normalizeMessageCount(countRaw) + if (typeof nextCount === 'number') { + acc[sessionId] = nextCount + } + return acc + }, {}) + + const cachedCountAsMetrics = Object.entries(cachedMessageCounts).reduce>((acc, [sessionId, count]) => { + acc[sessionId] = { totalMessages: count } + return acc + }, {}) + const cachedContentMetrics = Object.entries(cachedContentMetricsPayload?.metrics || {}).reduce>((acc, [sessionId, rawMetric]) => { + if (!exportableSessionIdSet.has(sessionId)) return acc + const metric = pickSessionMediaMetric(rawMetric) + if (!metric) return acc + acc[sessionId] = metric + if (hasCompleteSessionMediaMetric(metric)) { + sessionMediaMetricReadySetRef.current.add(sessionId) + } + return acc + }, {}) + const cachedContentMetricReadySessionIds = Object.entries(cachedContentMetrics) + .filter(([, metric]) => hasCompleteSessionMediaMetric(metric)) + .map(([sessionId]) => sessionId) + if (cachedContentMetricReadySessionIds.length > 0) { + patchSessionLoadTraceStage(cachedContentMetricReadySessionIds, 'mediaMetrics', 'done') + } + const cachedMutualFriendDirectMetrics = Object.entries(cachedMutualFriendsPayload?.metrics || {}).reduce>((acc, [sessionIdRaw, metricRaw]) => { + const sessionId = String(sessionIdRaw || '').trim() + if (!exportableSessionIdSet.has(sessionId) || !isSingleContactSession(sessionId)) return acc + const metric = metricRaw as SessionMutualFriendsMetric | undefined + if (!metric || !Array.isArray(metric.items) || !Number.isFinite(metric.count)) return acc + acc[sessionId] = metric + return acc + }, {}) + const cachedMutualFriendSessionIds = Object.keys(cachedMutualFriendDirectMetrics) + + if (isStale()) return + if (Object.keys(cachedMessageCounts).length > 0) { + setSessionMessageCounts(cachedMessageCounts) + } + if (Object.keys(cachedCountAsMetrics).length > 0) { + mergeSessionContentMetrics(cachedCountAsMetrics) + } + if (Object.keys(cachedContentMetrics).length > 0) { + mergeSessionContentMetrics(cachedContentMetrics) + } + if (cachedMutualFriendSessionIds.length > 0) { + sessionMutualFriendsDirectMetricsRef.current = cachedMutualFriendDirectMetrics + rebuildSessionMutualFriendsStateFromDirectMetrics(cachedMutualFriendSessionIds) + } else { + sessionMutualFriendsMetricsRef.current = {} + setSessionMutualFriendsMetrics({}) + } + setSessions(baseSessions) + sessionsHydratedAtRef.current = Date.now() + void (async () => { + await loadSessionMessageCounts(baseSessions, activeTabRef.current, { + scopeKey, + seededCounts: cachedMessageCounts + }) + if (isStale()) return + })() + setSessionDataSource(cachedContacts.length > 0 ? 'cache' : 'network') + if (cachedContacts.length === 0) { + setSessionContactsUpdatedAt(Date.now()) + } + setIsLoading(false) + + // 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。 + setIsSessionEnriching(true) + void (async () => { + try { + if (detailStatsPriorityRef.current) return + let contactMap = { ...cachedContactMap } + let avatarEntries = { ...cachedAvatarEntries } + let hasFreshNetworkData = false + let hasNetworkContactsSnapshot = false + + if (isStale()) return + if (detailStatsPriorityRef.current) return + const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) + if (isStale()) return + + const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] + if (contactsFromNetwork.length > 0) { + hasFreshNetworkData = true + hasNetworkContactsSnapshot = true + const contactsWithCachedAvatar = mergeAvatarCacheIntoContacts(contactsFromNetwork, avatarEntries) + const nextContactMap = contactsWithCachedAvatar.reduce>((map, contact) => { + map[contact.username] = contact + return map + }, {}) + for (const [username, cachedContact] of Object.entries(cachedContactMap)) { + if (!nextContactMap[username]) { + nextContactMap[username] = cachedContact + } + } + contactMap = nextContactMap + syncContactTypeCounts(Object.values(contactMap)) + const refreshAt = Date.now() + setSessionContactsUpdatedAt(refreshAt) + + const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, Object.values(contactMap), { + prune: true, + now: refreshAt + }) + avatarEntries = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setSessionAvatarUpdatedAt(upsertResult.updatedAt) + } + } + + const sourceContacts = Object.values(contactMap) + const sourceByUsername = new Map() + for (const contact of sourceContacts) { + if (!contact?.username) continue + sourceByUsername.set(contact.username, contact) + } + const rawSessionMap = rawSessions.reduce>((map, session) => { + map[session.username] = session + return map + }, {}) + const candidateUsernames = sourceContacts.length > 0 + ? sourceContacts.map(contact => contact.username) + : baseSessions.map(session => session.username) + const needsEnrichment = candidateUsernames + .filter(Boolean) + .filter((username) => { + const currentContact = sourceByUsername.get(username) + const session = rawSessionMap[username] + const currentAvatarUrl = currentContact?.avatarUrl || session?.avatarUrl + return !currentAvatarUrl + }) + + let extraContactMap: Record = {} + if (needsEnrichment.length > 0) { + for (let i = 0; i < needsEnrichment.length; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) { + if (isStale()) return + if (detailStatsPriorityRef.current) return + const batch = needsEnrichment.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE) + if (batch.length === 0) continue + try { + const enrichResult = await withTimeout( + window.electronAPI.chat.enrichSessionsContactInfo(batch, { + skipDisplayName: true, + onlyMissingAvatar: true + }), + CONTACT_ENRICH_TIMEOUT_MS + ) + if (isStale()) return + if (enrichResult?.success && enrichResult.contacts) { + extraContactMap = { + ...extraContactMap, + ...enrichResult.contacts + } + hasFreshNetworkData = true + for (const [username, enriched] of Object.entries(enrichResult.contacts)) { + const current = sourceByUsername.get(username) + if (!current) continue + sourceByUsername.set(username, { + ...current, + displayName: enriched.displayName || current.displayName, + avatarUrl: enriched.avatarUrl || current.avatarUrl + }) + } + } + } catch (batchError) { + console.error('导出页分批补充会话联系人信息失败:', batchError) + } + + const batchContacts = batch + .map(username => sourceByUsername.get(username)) + .filter((contact): contact is ContactInfo => Boolean(contact)) + const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, batchContacts, { + markCheckedUsernames: batch + }) + avatarEntries = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setSessionAvatarUpdatedAt(upsertResult.updatedAt) + } + await new Promise(resolve => setTimeout(resolve, 0)) + } + } + + const contactsForPersist = Array.from(sourceByUsername.values()) + if (hasNetworkContactsSnapshot && contactsForPersist.length > 0) { + const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, contactsForPersist, { + prune: true + }) + avatarEntries = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setSessionAvatarUpdatedAt(upsertResult.updatedAt) + } + } + contactMap = contactsForPersist.reduce>((map, contact) => { + map[contact.username] = contact + return map + }, contactMap) + + if (isStale()) return + const nextSessions = toSessionRowsWithContacts(rawSessions, contactMap).filter(isExportConversationSession) + .map((session) => { + const extra = extraContactMap[session.username] + const displayName = extra?.displayName || session.displayName || session.username + const avatarUrl = extra?.avatarUrl || session.avatarUrl || avatarEntries[session.username]?.avatarUrl + if (displayName === session.displayName && avatarUrl === session.avatarUrl) { + return session + } + return { + ...session, + displayName, + avatarUrl + } + }) + .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) + + const contactsCachePayload = Object.values(contactMap).map((contact) => ({ + username: contact.username, + displayName: contact.displayName || contact.username, + remark: contact.remark, + nickname: contact.nickname, + alias: contact.alias, + type: contact.type + })) + + const persistAt = Date.now() + setContactsList(contactsForPersist) + setSessions(nextSessions) + sessionsHydratedAtRef.current = persistAt + if (hasNetworkContactsSnapshot && contactsCachePayload.length > 0) { + await configService.setContactsListCache(scopeKey, contactsCachePayload) + setSessionContactsUpdatedAt(persistAt) + } + if (Object.keys(avatarEntries).length > 0) { + await configService.setContactsAvatarCache(scopeKey, avatarEntries) + setSessionAvatarUpdatedAt(persistAt) + } + if (hasFreshNetworkData) { + setSessionDataSource('network') + } + } catch (enrichError) { + console.error('导出页补充会话联系人信息失败:', enrichError) + } finally { + if (!isStale()) setIsSessionEnriching(false) + } + })() + } else { + setIsLoading(false) + } + } catch (error) { + console.error('加载会话失败:', error) + if (!isStale()) setIsLoading(false) + } finally { + if (!isStale()) setIsLoading(false) + } + }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, rebuildSessionMutualFriendsStateFromDirectMetrics, resetSessionMediaMetricLoader, resetSessionMutualFriendsLoader, syncContactTypeCounts]) + useEffect(() => { - loadSessions() - loadExportPath() - loadExportDefaults() - }, [loadSessions, loadExportPath, loadExportDefaults]) + if (!isExportRoute) return + const now = Date.now() + const hasFreshSessionSnapshot = hasBaseConfigReadyRef.current && + sessionsRef.current.length > 0 && + now - sessionsHydratedAtRef.current <= EXPORT_REENTER_SESSION_SOFT_REFRESH_MS + const baseConfigPromise = loadBaseConfig() + void ensureSharedTabCountsLoaded() + if (!hasFreshSessionSnapshot) { + void loadSessions() + } + + // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 + const timer = window.setTimeout(() => { + void (async () => { + await baseConfigPromise + const hasFreshSnsSnapshot = hasSeededSnsStatsRef.current && + Date.now() - snsStatsHydratedAtRef.current <= EXPORT_REENTER_SNS_SOFT_REFRESH_MS + if (!hasFreshSnsSnapshot) { + void loadSnsStats({ full: true }) + } + })() + }, 120) + + return () => window.clearTimeout(timer) + }, [isExportRoute, ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats]) + + useEffect(() => { + if (isExportRoute) return + // 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。 + sessionLoadTokenRef.current = Date.now() + sessionCountRequestIdRef.current += 1 + snsUserPostCountsHydrationTokenRef.current += 1 + if (snsUserPostCountsBatchTimerRef.current) { + window.clearTimeout(snsUserPostCountsBatchTimerRef.current) + snsUserPostCountsBatchTimerRef.current = null + } + resetSessionMutualFriendsLoader() + setIsSessionEnriching(false) + setIsLoadingSessionCounts(false) + setSnsUserPostCountsStatus(prev => (prev === 'loading' ? 'idle' : prev)) + }, [isExportRoute, resetSessionMutualFriendsLoader]) + + useEffect(() => { + if (activeTab === 'official') { + setActiveTab('private') + } + }, [activeTab]) + + useEffect(() => { + activeTabRef.current = activeTab + }, [activeTab]) useEffect(() => { preselectAppliedRef.current = false @@ -215,830 +4026,3062 @@ function ExportPage() { if (matched.length > 0) { setSelectedSessions(new Set(matched)) - setSearchKeyword('') } }, [sessions, preselectSessionIds]) - useEffect(() => { - const handleChange = () => { - setSelectedSessions(new Set()) - setSearchKeyword('') - setExportResult(null) - setSessions([]) - setFilteredSessions([]) - loadSessions() - } - window.addEventListener('wxid-changed', handleChange as EventListener) - return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, [loadSessions]) + const selectedCount = selectedSessions.size - useEffect(() => { - const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string; phaseProgress?: number; phaseTotal?: number; phaseLabel?: string }) => { - setExportProgress({ - current: payload.current, - total: payload.total, - currentName: payload.currentSession, - phaseLabel: payload.phaseLabel || '', - phaseProgress: payload.phaseProgress || 0, - phaseTotal: payload.phaseTotal || 0 - }) - }) - return () => { - removeListener?.() - } - }, []) - - // 导出计时器 - useEffect(() => { - if (!isExporting) return - const timer = setInterval(() => { - setElapsedSeconds(Math.floor((Date.now() - exportStartTime.current) / 1000)) - }, 1000) - return () => clearInterval(timer) - }, [isExporting]) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Node - if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) { - setShowDisplayNameSelect(false) + const toggleSelectSession = (sessionId: string) => { + const target = sessions.find(session => session.username === sessionId) + if (!target?.hasSession) return + setSelectedSessions(prev => { + const next = new Set(prev) + if (next.has(sessionId)) { + next.delete(sessionId) + } else { + next.add(sessionId) } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showDisplayNameSelect]) - - useEffect(() => { - if (!searchKeyword.trim()) { - setFilteredSessions(sessions) - return - } - const lower = searchKeyword.toLowerCase() - setFilteredSessions(sessions.filter(s => - s.displayName?.toLowerCase().includes(lower) || - s.username.toLowerCase().includes(lower) - )) - }, [searchKeyword, sessions]) - - const toggleSession = (username: string) => { - const newSet = new Set(selectedSessions) - if (newSet.has(username)) { - newSet.delete(username) - } else { - newSet.add(username) - } - setSelectedSessions(newSet) + return next + }) } - const toggleSelectAll = () => { - if (selectedSessions.size === filteredSessions.length) { - setSelectedSessions(new Set()) - } else { - setSelectedSessions(new Set(filteredSessions.map(s => s.username))) - } - } + const toggleSelectAllVisible = () => { + const visibleIds = filteredContacts + .filter(contact => sessionRowByUsername.get(contact.username)?.hasSession) + .map(contact => contact.username) + if (visibleIds.length === 0) return - const getAvatarLetter = (name: string) => { - if (!name) return '?' - return [...name][0] || '?' - } - - const formatDate = (date: Date) => { - return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) - } - - const handleFormatChange = (format: ExportOptions['format']) => { - setOptions((prev) => { - const next = { ...prev, format } - if (format === 'html') { - return { - ...next, - exportMedia: true, - exportImages: true, - exportVoices: true, - exportVideos: true, - exportEmojis: true + setSelectedSessions(prev => { + const next = new Set(prev) + const allSelected = visibleIds.every(id => next.has(id)) + if (allSelected) { + for (const id of visibleIds) { + next.delete(id) + } + } else { + for (const id of visibleIds) { + next.add(id) } } return next }) } - const openExportFolder = async () => { - if (exportFolder) { - await window.electronAPI.shell.openPath(exportFolder) + const clearSelection = () => setSelectedSessions(new Set()) + + const openExportDialog = useCallback((payload: Omit) => { + setExportDialog({ open: true, ...payload }) + setIsTimeRangeDialogOpen(false) + setTimeRangeBounds(null) + setTimeRangeSelection(exportDefaultDateRangeSelection) + + setOptions(prev => { + const nextDateRange = cloneExportDateRange(exportDefaultDateRangeSelection.dateRange) + + const next: ExportOptions = { + ...prev, + format: exportDefaultFormat, + exportAvatars: exportDefaultAvatars, + useAllTime: exportDefaultDateRangeSelection.useAllTime, + dateRange: nextDateRange, + exportMedia: Boolean( + exportDefaultMedia.images || + exportDefaultMedia.voices || + exportDefaultMedia.videos || + exportDefaultMedia.emojis + ), + exportImages: exportDefaultMedia.images, + exportVoices: exportDefaultMedia.voices, + exportVideos: exportDefaultMedia.videos, + exportEmojis: exportDefaultMedia.emojis, + exportVoiceAsText: exportDefaultVoiceAsText, + excelCompactColumns: exportDefaultExcelCompactColumns, + exportConcurrency: exportDefaultConcurrency, + imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss + } + + if (payload.scope === 'sns') { + return next + } + + if (payload.scope === 'content' && payload.contentType) { + if (payload.contentType === 'text') { + next.exportMedia = false + next.exportImages = false + next.exportVoices = false + next.exportVideos = false + next.exportEmojis = false + } else { + next.exportMedia = true + next.exportImages = payload.contentType === 'image' + next.exportVoices = payload.contentType === 'voice' + next.exportVideos = payload.contentType === 'video' + next.exportEmojis = payload.contentType === 'emoji' + next.exportVoiceAsText = false + } + } + + return next + }) + }, [ + exportDefaultDateRangeSelection, + exportDefaultExcelCompactColumns, + exportDefaultFormat, + exportDefaultAvatars, + exportDefaultMedia, + exportDefaultVoiceAsText, + exportDefaultConcurrency, + exportDefaultImageDeepSearchOnMiss + ]) + + const closeExportDialog = useCallback(() => { + setExportDialog(prev => ({ ...prev, open: false })) + setIsTimeRangeDialogOpen(false) + setTimeRangeBounds(null) + }, []) + + const resolveChatExportTimeRangeBounds = useCallback(async (sessionIds: string[]): Promise => { + const normalizedSessionIds = Array.from(new Set((sessionIds || []).map(id => String(id || '').trim()).filter(Boolean))) + if (normalizedSessionIds.length === 0) return null + + const sessionRowMap = new Map() + for (const session of sessions) { + sessionRowMap.set(session.username, session) + } + + let minTimestamp: number | undefined + let maxTimestamp: number | undefined + const resolvedSessionBounds = new Map() + + const absorbMetric = (sessionId: string, metric?: { firstTimestamp?: number; lastTimestamp?: number } | null) => { + if (!metric) return + const firstTimestamp = normalizeTimestampSeconds(metric.firstTimestamp) + const lastTimestamp = normalizeTimestampSeconds(metric.lastTimestamp) + if (typeof firstTimestamp !== 'number' && typeof lastTimestamp !== 'number') return + + const previous = resolvedSessionBounds.get(sessionId) || { hasMin: false, hasMax: false } + const nextState = { + hasMin: previous.hasMin || typeof firstTimestamp === 'number', + hasMax: previous.hasMax || typeof lastTimestamp === 'number' + } + resolvedSessionBounds.set(sessionId, nextState) + + if (typeof firstTimestamp === 'number' && (minTimestamp === undefined || firstTimestamp < minTimestamp)) { + minTimestamp = firstTimestamp + } + if (typeof lastTimestamp === 'number' && (maxTimestamp === undefined || lastTimestamp > maxTimestamp)) { + maxTimestamp = lastTimestamp + } + } + + for (const sessionId of normalizedSessionIds) { + const sessionRow = sessionRowMap.get(sessionId) + absorbMetric(sessionId, { + firstTimestamp: undefined, + lastTimestamp: sessionRow?.sortTimestamp || sessionRow?.lastTimestamp + }) + absorbMetric(sessionId, sessionContentMetrics[sessionId]) + if (sessionDetail?.wxid === sessionId) { + absorbMetric(sessionId, { + firstTimestamp: sessionDetail.firstMessageTime, + lastTimestamp: sessionDetail.latestMessageTime + }) + } + } + + const applyStatsResult = (result?: { + success: boolean + data?: Record + } | null) => { + if (!result?.success || !result.data) return + applySessionMediaMetricsFromStats(result.data) + for (const sessionId of normalizedSessionIds) { + absorbMetric(sessionId, result.data[sessionId]) + } + } + + const missingSessionIds = () => normalizedSessionIds.filter(sessionId => { + const resolved = resolvedSessionBounds.get(sessionId) + return !resolved?.hasMin || !resolved?.hasMax + }) + + const staleSessionIds = new Set() + + if (missingSessionIds().length > 0) { + const cacheResult = await window.electronAPI.chat.getExportSessionStats( + missingSessionIds(), + { includeRelations: false, allowStaleCache: true, cacheOnly: true } + ) + applyStatsResult(cacheResult) + for (const sessionId of cacheResult?.needsRefresh || []) { + staleSessionIds.add(String(sessionId || '').trim()) + } + } + + const sessionsNeedingFreshStats = Array.from(new Set([ + ...missingSessionIds(), + ...Array.from(staleSessionIds).filter(Boolean) + ])) + + if (sessionsNeedingFreshStats.length > 0) { + applyStatsResult(await window.electronAPI.chat.getExportSessionStats( + sessionsNeedingFreshStats, + { includeRelations: false } + )) + } + + if (missingSessionIds().length > 0) { + return null + } + if (typeof minTimestamp !== 'number' || typeof maxTimestamp !== 'number') { + return null + } + + return { + minDate: new Date(minTimestamp * 1000), + maxDate: new Date(maxTimestamp * 1000) + } + }, [applySessionMediaMetricsFromStats, sessionContentMetrics, sessionDetail, sessions]) + + const openTimeRangeDialog = useCallback(() => { + void (async () => { + if (isResolvingTimeRangeBounds) return + setIsResolvingTimeRangeBounds(true) + try { + let nextBounds: TimeRangeBounds | null = null + if (exportDialog.scope !== 'sns') { + nextBounds = await resolveChatExportTimeRangeBounds(exportDialog.sessionIds) + } + setTimeRangeBounds(nextBounds) + if (nextBounds) { + const nextSelection = clampExportSelectionToBounds(timeRangeSelection, nextBounds) + if (!areExportSelectionsEqual(nextSelection, timeRangeSelection)) { + setTimeRangeSelection(nextSelection) + setOptions(prev => ({ + ...prev, + useAllTime: nextSelection.useAllTime, + dateRange: cloneExportDateRange(nextSelection.dateRange) + })) + } + } + setIsTimeRangeDialogOpen(true) + } catch (error) { + console.error('导出页解析时间范围边界失败', error) + setTimeRangeBounds(null) + setIsTimeRangeDialogOpen(true) + } finally { + setIsResolvingTimeRangeBounds(false) + } + })() + }, [exportDialog.scope, exportDialog.sessionIds, isResolvingTimeRangeBounds, resolveChatExportTimeRangeBounds, timeRangeSelection]) + + const closeTimeRangeDialog = useCallback(() => { + setIsTimeRangeDialogOpen(false) + }, []) + + const timeRangeSummaryLabel = useMemo(() => getExportDateRangeLabel(timeRangeSelection), [timeRangeSelection]) + + useEffect(() => { + const unsubscribe = onOpenSingleExport((payload) => { + void (async () => { + const sessionId = typeof payload?.sessionId === 'string' + ? payload.sessionId.trim() + : '' + if (!sessionId) return + + const sessionName = typeof payload?.sessionName === 'string' + ? payload.sessionName.trim() + : '' + const displayName = sessionName || sessionId + const requestId = typeof payload?.requestId === 'string' + ? payload.requestId.trim() + : '' + + const emitStatus = ( + status: 'initializing' | 'opened' | 'failed', + message?: string + ) => { + if (!requestId) return + emitSingleExportDialogStatus({ requestId, status, message }) + } + + try { + if (!hasBaseConfigReadyRef.current) { + emitStatus('initializing') + const ready = await loadBaseConfig() + if (!ready) { + emitStatus('failed', '导出模块初始化失败,请重试') + return + } + } + + setSelectedSessions(new Set([sessionId])) + openExportDialog({ + scope: 'single', + sessionIds: [sessionId], + sessionNames: [displayName], + title: `导出会话:${displayName}` + }) + emitStatus('opened') + } catch (error) { + console.error('聊天页唤起导出弹窗失败:', error) + emitStatus('failed', String(error)) + } + })() + }) + + return unsubscribe + }, [loadBaseConfig, openExportDialog]) + + const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => { + const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared' + const exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + + const base: ElectronExportOptions = { + format: options.format, + exportAvatars: options.exportAvatars, + exportMedia: exportMediaEnabled, + exportImages: options.exportImages, + exportVoices: options.exportVoices, + exportVideos: options.exportVideos, + exportEmojis: options.exportEmojis, + exportVoiceAsText: options.exportVoiceAsText, + excelCompactColumns: options.excelCompactColumns, + txtColumns: options.txtColumns, + displayNamePreference: options.displayNamePreference, + exportConcurrency: options.exportConcurrency, + imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, + sessionLayout, + sessionNameWithTypePrefix, + dateRange: options.useAllTime + ? null + : options.dateRange + ? { + start: Math.floor(options.dateRange.start.getTime() / 1000), + end: Math.floor(options.dateRange.end.getTime() / 1000) + } + : null + } + + if (scope === 'content' && contentType) { + if (contentType === 'text') { + const textExportConcurrency = Math.min(2, Math.max(1, base.exportConcurrency ?? options.exportConcurrency)) + return { + ...base, + contentType, + exportConcurrency: textExportConcurrency, + exportAvatars: base.exportAvatars, + exportMedia: false, + exportImages: false, + exportVoices: false, + exportVideos: false, + exportEmojis: false + } + } + + return { + ...base, + contentType, + exportMedia: true, + exportImages: contentType === 'image', + exportVoices: contentType === 'voice', + exportVideos: contentType === 'video', + exportEmojis: contentType === 'emoji', + exportVoiceAsText: false + } + } + + return base + } + + const buildSnsExportOptions = () => { + const format: SnsTimelineExportFormat = snsExportFormat + const dateRange = options.useAllTime + ? null + : options.dateRange + ? { + startTime: Math.floor(options.dateRange.start.getTime() / 1000), + endTime: Math.floor(options.dateRange.end.getTime() / 1000) + } + : null + + return { + format, + exportImages: snsExportImages, + exportLivePhotos: snsExportLivePhotos, + exportVideos: snsExportVideos, + startTime: dateRange?.startTime, + endTime: dateRange?.endTime } } - const runExport = async (sessionLayout: SessionLayout) => { - if (selectedSessions.size === 0 || !exportFolder) return + const markSessionExported = useCallback((sessionIds: string[], timestamp: number) => { + setLastExportBySession(prev => { + const next = { ...prev } + for (const id of sessionIds) { + next[id] = timestamp + } + void configService.setExportLastSessionRunMap(next) + return next + }) + }, []) - setIsExporting(true) - setExportProgress({ current: 0, total: selectedSessions.size, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 }) - setExportResult(null) - exportStartTime.current = Date.now() - setElapsedSeconds(0) + const markContentExported = useCallback((sessionIds: string[], contentTypes: ContentType[], timestamp: number) => { + setLastExportByContent(prev => { + const next = { ...prev } + for (const id of sessionIds) { + for (const type of contentTypes) { + next[`${id}::${type}`] = timestamp + } + } + void configService.setExportLastContentRunMap(next) + return next + }) + }, []) - try { - const sessionList = Array.from(selectedSessions) - const exportOptions = { - format: options.format, - exportAvatars: options.exportAvatars, - exportMedia: options.exportMedia, - exportImages: options.exportMedia && options.exportImages, - exportVoices: options.exportMedia && options.exportVoices, - exportVideos: options.exportMedia && options.exportVideos, - exportEmojis: options.exportMedia && options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 - excelCompactColumns: options.excelCompactColumns, - txtColumns: options.txtColumns, - displayNamePreference: options.displayNamePreference, - exportConcurrency: options.exportConcurrency, - sessionLayout, - dateRange: options.useAllTime ? null : options.dateRange ? { - start: Math.floor(options.dateRange.start.getTime() / 1000), - // 将结束日期设置为当天的 23:59:59,确保包含当天的所有记录 - end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) - } : null + const resolveTaskExportContentLabel = useCallback((payload: ExportTaskPayload): string => { + if (payload.scope === 'content' && payload.contentType) { + return getContentTypeLabel(payload.contentType) + } + if (payload.scope === 'sns') return '朋友圈' + + const labels: string[] = ['聊天文本'] + const opts = payload.options + if (opts?.exportMedia) { + if (opts.exportImages) labels.push('图片') + if (opts.exportVoices) labels.push('语音') + if (opts.exportVideos) labels.push('视频') + if (opts.exportEmojis) labels.push('表情包') + } + return Array.from(new Set(labels)).join('、') + }, []) + + const markSessionExportRecords = useCallback(( + sessionIds: string[], + content: string, + outputDir: string, + exportTime: number + ) => { + const normalizedContent = String(content || '').trim() + const normalizedOutputDir = String(outputDir || '').trim() + const normalizedExportTime = Number.isFinite(exportTime) ? Math.max(0, Math.floor(exportTime)) : Date.now() + if (!normalizedContent || !normalizedOutputDir) return + if (!Array.isArray(sessionIds) || sessionIds.length === 0) return + + setExportRecordsBySession(prev => { + const next: Record = { ...prev } + let changed = false + + for (const rawSessionId of sessionIds) { + const sessionId = String(rawSessionId || '').trim() + if (!sessionId) continue + const existingList = Array.isArray(next[sessionId]) ? [...next[sessionId]] : [] + const lastRecord = existingList[existingList.length - 1] + if ( + lastRecord && + lastRecord.content === normalizedContent && + lastRecord.outputDir === normalizedOutputDir && + Math.abs(Number(lastRecord.exportTime || 0) - normalizedExportTime) <= 2000 + ) { + continue + } + existingList.push({ + exportTime: normalizedExportTime, + content: normalizedContent, + outputDir: normalizedOutputDir + }) + next[sessionId] = existingList.slice(-80) + changed = true } - if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html' || options.format === 'weclone') { - const result = await window.electronAPI.export.exportSessions( - sessionList, - exportFolder, - exportOptions + if (!changed) return prev + void configService.setExportSessionRecordMap(next) + return next + }) + }, []) + + const inferContentTypesFromOptions = (opts: ElectronExportOptions): ContentType[] => { + const types: ContentType[] = ['text'] + if (opts.exportMedia) { + if (opts.exportVoices) types.push('voice') + if (opts.exportImages) types.push('image') + if (opts.exportVideos) types.push('video') + if (opts.exportEmojis) types.push('emoji') + } + return types + } + + const updateTask = useCallback((taskId: string, updater: (task: ExportTask) => ExportTask) => { + setTasks(prev => prev.map(task => (task.id === taskId ? updater(task) : task))) + }, []) + + const runNextTask = useCallback(async () => { + if (runningTaskIdRef.current) return + + const queue = [...tasksRef.current].reverse() + const next = queue.find(task => task.status === 'queued') + if (!next) return + + runningTaskIdRef.current = next.id + updateTask(next.id, task => ({ + ...task, + status: 'running', + settledSessionIds: [], + startedAt: Date.now(), + finishedAt: undefined, + error: undefined, + performance: isTextBatchTask(task) + ? (task.performance || createEmptyTaskPerformance()) + : task.performance + })) + const taskExportContentLabel = resolveTaskExportContentLabel(next.payload) + + progressUnsubscribeRef.current?.() + const settledSessionIdsFromProgress = new Set() + const sessionMessageProgress = new Map() + let queuedProgressPayload: ExportProgress | null = null + let queuedProgressRaf: number | null = null + let queuedProgressTimer: number | null = null + + const clearQueuedProgress = () => { + if (queuedProgressRaf !== null) { + window.cancelAnimationFrame(queuedProgressRaf) + queuedProgressRaf = null + } + if (queuedProgressTimer !== null) { + window.clearTimeout(queuedProgressTimer) + queuedProgressTimer = null + } + } + + const updateSessionMessageProgress = (payload: ExportProgress) => { + const sessionId = String(payload.currentSessionId || '').trim() + if (!sessionId) return + const prev = sessionMessageProgress.get(sessionId) || { exported: 0, total: 0, knownTotal: false } + const nextExported = Number.isFinite(payload.exportedMessages) + ? Math.max(prev.exported, Math.max(0, Math.floor(Number(payload.exportedMessages || 0)))) + : prev.exported + const hasEstimatedTotal = Number.isFinite(payload.estimatedTotalMessages) + const nextTotal = hasEstimatedTotal + ? Math.max(prev.total, Math.max(0, Math.floor(Number(payload.estimatedTotalMessages || 0)))) + : prev.total + const knownTotal = prev.knownTotal || hasEstimatedTotal + sessionMessageProgress.set(sessionId, { + exported: nextExported, + total: nextTotal, + knownTotal + }) + } + + const resolveAggregatedMessageProgress = () => { + let exported = 0 + let estimated = 0 + let allKnown = true + for (const sessionId of next.payload.sessionIds) { + const entry = sessionMessageProgress.get(sessionId) + if (!entry) { + allKnown = false + continue + } + exported += entry.exported + estimated += entry.total + if (!entry.knownTotal) { + allKnown = false + } + } + return { + exported: Math.max(0, Math.floor(exported)), + estimated: allKnown ? Math.max(0, Math.floor(estimated)) : 0 + } + } + + const flushQueuedProgress = () => { + if (!queuedProgressPayload) return + const payload = queuedProgressPayload + queuedProgressPayload = null + const now = Date.now() + const currentSessionId = String(payload.currentSessionId || '').trim() + updateTask(next.id, task => { + if (task.status !== 'running') return task + const performance = applyProgressToTaskPerformance(task, payload, now) + const settledSessionIds = task.settledSessionIds || [] + const nextSettledSessionIds = ( + payload.phase === 'complete' && + currentSessionId && + !settledSessionIds.includes(currentSessionId) ) - setExportResult(result) - } else { - setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` }) - } - } catch (e) { - console.error('导出过程中发生异常:', e) - setExportResult({ success: false, error: String(e) }) - } finally { - setIsExporting(false) + ? [...settledSessionIds, currentSessionId] + : settledSessionIds + const aggregatedMessageProgress = resolveAggregatedMessageProgress() + const collectedMessages = Number.isFinite(payload.collectedMessages) + ? Math.max(0, Math.floor(Number(payload.collectedMessages || 0))) + : task.progress.collectedMessages + const writtenFiles = Number.isFinite(payload.writtenFiles) + ? Math.max(task.progress.writtenFiles, Math.max(0, Math.floor(Number(payload.writtenFiles || 0)))) + : task.progress.writtenFiles + const prevMediaDoneFiles = Number.isFinite(task.progress.mediaDoneFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaDoneFiles || 0))) + : 0 + const prevMediaCacheHitFiles = Number.isFinite(task.progress.mediaCacheHitFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaCacheHitFiles || 0))) + : 0 + const prevMediaCacheMissFiles = Number.isFinite(task.progress.mediaCacheMissFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaCacheMissFiles || 0))) + : 0 + const prevMediaCacheFillFiles = Number.isFinite(task.progress.mediaCacheFillFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaCacheFillFiles || 0))) + : 0 + const prevMediaDedupReuseFiles = Number.isFinite(task.progress.mediaDedupReuseFiles) + ? Math.max(0, Math.floor(Number(task.progress.mediaDedupReuseFiles || 0))) + : 0 + const prevMediaBytesWritten = Number.isFinite(task.progress.mediaBytesWritten) + ? Math.max(0, Math.floor(Number(task.progress.mediaBytesWritten || 0))) + : 0 + const mediaDoneFiles = Number.isFinite(payload.mediaDoneFiles) + ? Math.max(prevMediaDoneFiles, Math.max(0, Math.floor(Number(payload.mediaDoneFiles || 0)))) + : prevMediaDoneFiles + const mediaCacheHitFiles = Number.isFinite(payload.mediaCacheHitFiles) + ? Math.max(prevMediaCacheHitFiles, Math.max(0, Math.floor(Number(payload.mediaCacheHitFiles || 0)))) + : prevMediaCacheHitFiles + const mediaCacheMissFiles = Number.isFinite(payload.mediaCacheMissFiles) + ? Math.max(prevMediaCacheMissFiles, Math.max(0, Math.floor(Number(payload.mediaCacheMissFiles || 0)))) + : prevMediaCacheMissFiles + const mediaCacheFillFiles = Number.isFinite(payload.mediaCacheFillFiles) + ? Math.max(prevMediaCacheFillFiles, Math.max(0, Math.floor(Number(payload.mediaCacheFillFiles || 0)))) + : prevMediaCacheFillFiles + const mediaDedupReuseFiles = Number.isFinite(payload.mediaDedupReuseFiles) + ? Math.max(prevMediaDedupReuseFiles, Math.max(0, Math.floor(Number(payload.mediaDedupReuseFiles || 0)))) + : prevMediaDedupReuseFiles + const mediaBytesWritten = Number.isFinite(payload.mediaBytesWritten) + ? Math.max(prevMediaBytesWritten, Math.max(0, Math.floor(Number(payload.mediaBytesWritten || 0)))) + : prevMediaBytesWritten + return { + ...task, + progress: { + current: payload.current, + total: payload.total, + currentName: payload.currentSession, + phase: payload.phase, + phaseLabel: payload.phaseLabel || '', + phaseProgress: payload.phaseProgress || 0, + phaseTotal: payload.phaseTotal || 0, + exportedMessages: Math.max(task.progress.exportedMessages, aggregatedMessageProgress.exported), + estimatedTotalMessages: aggregatedMessageProgress.estimated > 0 + ? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated) + : (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0), + collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages), + writtenFiles, + mediaDoneFiles, + mediaCacheHitFiles, + mediaCacheMissFiles, + mediaCacheFillFiles, + mediaDedupReuseFiles, + mediaBytesWritten + }, + settledSessionIds: nextSettledSessionIds, + performance + } + }) } - } - const startExport = async () => { - if (selectedSessions.size === 0 || !exportFolder) return + const queueProgressUpdate = (payload: ExportProgress) => { + queuedProgressPayload = payload + if (payload.phase === 'complete') { + clearQueuedProgress() + flushQueuedProgress() + return + } + if (queuedProgressRaf !== null || queuedProgressTimer !== null) return + queuedProgressRaf = window.requestAnimationFrame(() => { + queuedProgressRaf = null + queuedProgressTimer = window.setTimeout(() => { + queuedProgressTimer = null + flushQueuedProgress() + }, 100) + }) + } + if (next.payload.scope === 'sns') { + progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => { + updateTask(next.id, task => { + if (task.status !== 'running') return task + return { + ...task, + progress: { + current: payload.current || 0, + total: payload.total || 0, + currentName: '', + phase: 'exporting', + phaseLabel: payload.status || '', + phaseProgress: payload.total > 0 ? payload.current : 0, + phaseTotal: payload.total || 0, + exportedMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.current || 0)) : task.progress.exportedMessages, + estimatedTotalMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.total || 0)) : task.progress.estimatedTotalMessages, + collectedMessages: task.progress.collectedMessages, + writtenFiles: task.progress.writtenFiles, + mediaDoneFiles: task.progress.mediaDoneFiles, + mediaCacheHitFiles: task.progress.mediaCacheHitFiles, + mediaCacheMissFiles: task.progress.mediaCacheMissFiles, + mediaCacheFillFiles: task.progress.mediaCacheFillFiles, + mediaDedupReuseFiles: task.progress.mediaDedupReuseFiles, + mediaBytesWritten: task.progress.mediaBytesWritten + } + } + }) + }) + } else { + progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => { + const now = Date.now() + const currentSessionId = String(payload.currentSessionId || '').trim() + updateSessionMessageProgress(payload) + if (payload.phase === 'complete' && currentSessionId && !settledSessionIdsFromProgress.has(currentSessionId)) { + settledSessionIdsFromProgress.add(currentSessionId) + const phaseLabel = String(payload.phaseLabel || '') + const isFailed = phaseLabel.includes('失败') + if (!isFailed) { + const contentTypes = next.payload.contentType + ? [next.payload.contentType] + : (next.payload.options ? inferContentTypesFromOptions(next.payload.options) : []) + markSessionExported([currentSessionId], now) + if (contentTypes.length > 0) { + markContentExported([currentSessionId], contentTypes, now) + } + markSessionExportRecords([currentSessionId], taskExportContentLabel, next.payload.outputDir, now) + } + } + queueProgressUpdate(payload) + }) + } - // 先获取预估统计 - const requestId = ++statsRequestIdRef.current - setIsLoadingStats(true) - setPreExportStats(null) - setShowPreExportDialog(true) try { - const sessionList = Array.from(selectedSessions) - const exportOptions = { - format: options.format, - exportVoiceAsText: options.exportVoiceAsText, - exportMedia: options.exportMedia, - exportImages: options.exportMedia && options.exportImages, - exportVoices: options.exportMedia && options.exportVoices, - exportVideos: options.exportMedia && options.exportVideos, - exportEmojis: options.exportMedia && options.exportEmojis, - dateRange: options.useAllTime ? null : options.dateRange ? { - start: Math.floor(options.dateRange.start.getTime() / 1000), - end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) - } : null + if (next.payload.scope === 'sns') { + const snsOptions = next.payload.snsOptions || { format: 'html' as SnsTimelineExportFormat, exportImages: false, exportLivePhotos: false, exportVideos: false } + const result = await window.electronAPI.sns.exportTimeline({ + outputDir: next.payload.outputDir, + format: snsOptions.format, + exportImages: snsOptions.exportImages, + exportLivePhotos: snsOptions.exportLivePhotos, + exportVideos: snsOptions.exportVideos, + startTime: snsOptions.startTime, + endTime: snsOptions.endTime + }) + + if (!result.success) { + updateTask(next.id, task => ({ + ...task, + status: 'error', + finishedAt: Date.now(), + error: result.error || '朋友圈导出失败', + performance: finalizeTaskPerformance(task, Date.now()) + })) + } else { + const doneAt = Date.now() + const exportedPosts = Math.max(0, result.postCount || 0) + const mergedExportedCount = Math.max(lastSnsExportPostCount, exportedPosts) + setLastSnsExportPostCount(mergedExportedCount) + await configService.setExportLastSnsPostCount(mergedExportedCount) + await loadSnsStats({ full: true }) + + updateTask(next.id, task => ({ + ...task, + status: 'success', + finishedAt: doneAt, + progress: { + ...task.progress, + current: exportedPosts, + total: exportedPosts, + phaseLabel: '完成', + phaseProgress: 1, + phaseTotal: 1 + }, + performance: finalizeTaskPerformance(task, doneAt) + })) + } + } else { + if (!next.payload.options) { + throw new Error('导出参数缺失') + } + + const result = await window.electronAPI.export.exportSessions( + next.payload.sessionIds, + next.payload.outputDir, + next.payload.options + ) + + if (!result.success) { + updateTask(next.id, task => ({ + ...task, + status: 'error', + finishedAt: Date.now(), + error: result.error || '导出失败', + performance: finalizeTaskPerformance(task, Date.now()) + })) + } else { + const doneAt = Date.now() + const contentTypes = next.payload.contentType + ? [next.payload.contentType] + : inferContentTypesFromOptions(next.payload.options) + const successSessionIds = Array.isArray(result.successSessionIds) + ? result.successSessionIds + : [] + if (successSessionIds.length > 0) { + const unsettledSuccessSessionIds = successSessionIds.filter((sessionId) => !settledSessionIdsFromProgress.has(sessionId)) + if (unsettledSuccessSessionIds.length > 0) { + markSessionExported(unsettledSuccessSessionIds, doneAt) + markSessionExportRecords(unsettledSuccessSessionIds, taskExportContentLabel, next.payload.outputDir, doneAt) + if (contentTypes.length > 0) { + markContentExported(unsettledSuccessSessionIds, contentTypes, doneAt) + } + } + } + + updateTask(next.id, task => ({ + ...task, + status: 'success', + finishedAt: doneAt, + progress: { + ...task.progress, + current: task.progress.total || next.payload.sessionIds.length, + total: task.progress.total || next.payload.sessionIds.length, + phaseLabel: '完成', + phaseProgress: 1, + phaseTotal: 1 + }, + performance: finalizeTaskPerformance(task, doneAt) + })) + } } - const stats = await window.electronAPI.export.getExportStats(sessionList, exportOptions) - if (statsRequestIdRef.current !== requestId) return - setPreExportStats(stats) - } catch (e) { - console.error('获取导出统计失败:', e) - if (statsRequestIdRef.current !== requestId) return - setPreExportStats(null) + } catch (error) { + const doneAt = Date.now() + updateTask(next.id, task => ({ + ...task, + status: 'error', + finishedAt: doneAt, + error: String(error), + performance: finalizeTaskPerformance(task, doneAt) + })) } finally { - if (statsRequestIdRef.current !== requestId) return - setIsLoadingStats(false) + clearQueuedProgress() + flushQueuedProgress() + progressUnsubscribeRef.current?.() + progressUnsubscribeRef.current = null + runningTaskIdRef.current = null + void runNextTask() } + }, [ + updateTask, + markSessionExported, + markSessionExportRecords, + markContentExported, + resolveTaskExportContentLabel, + loadSnsStats, + lastSnsExportPostCount + ]) + + useEffect(() => { + void runNextTask() + }, [tasks, runNextTask]) + + useEffect(() => { + return () => { + progressUnsubscribeRef.current?.() + progressUnsubscribeRef.current = null + } + }, []) + + const createTask = async () => { + if (!exportDialog.open || !exportFolder) return + if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return + + const exportOptions = exportDialog.scope === 'sns' + ? undefined + : buildExportOptions(exportDialog.scope, exportDialog.contentType) + const snsOptions = exportDialog.scope === 'sns' + ? buildSnsExportOptions() + : undefined + const title = + exportDialog.scope === 'single' + ? `${exportDialog.sessionNames[0] || '会话'} 导出` + : exportDialog.scope === 'multi' + ? `批量导出(${exportDialog.sessionIds.length} 个会话)` + : exportDialog.scope === 'sns' + ? '朋友圈批量导出' + : `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出` + + const task: ExportTask = { + id: createTaskId(), + title, + status: 'queued', + settledSessionIds: [], + createdAt: Date.now(), + payload: { + sessionIds: exportDialog.sessionIds, + sessionNames: exportDialog.sessionNames, + outputDir: exportFolder, + options: exportOptions, + scope: exportDialog.scope, + contentType: exportDialog.contentType, + snsOptions + }, + progress: createEmptyProgress(), + performance: exportDialog.scope === 'content' && exportDialog.contentType === 'text' + ? createEmptyTaskPerformance() + : undefined + } + + setTasks(prev => [task, ...prev]) + closeExportDialog() + + await configService.setExportDefaultFormat(options.format) + await configService.setExportDefaultAvatars(options.exportAvatars) + await configService.setExportDefaultMedia({ + images: options.exportImages, + voices: options.exportVoices, + videos: options.exportVideos, + emojis: options.exportEmojis + }) + await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText) + await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) + await configService.setExportDefaultTxtColumns(options.txtColumns) + await configService.setExportDefaultConcurrency(options.exportConcurrency) + await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss) + setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss) } - const confirmExport = () => { - statsRequestIdRef.current++ - setIsLoadingStats(false) - setShowPreExportDialog(false) - setPreExportStats(null) + const openSingleExport = useCallback((session: SessionRow) => { + if (!session.hasSession) return + openExportDialog({ + scope: 'single', + sessionIds: [session.username], + sessionNames: [session.displayName || session.username], + title: `导出会话:${session.displayName || session.username}` + }) + }, [openExportDialog]) - if (options.exportMedia && selectedSessions.size > 1) { - setShowMediaLayoutPrompt(true) + const resolveSessionExistingMessageCount = useCallback((session: SessionRow): number => { + const counted = normalizeMessageCount(sessionMessageCounts[session.username]) + if (typeof counted === 'number') return counted + const hinted = normalizeMessageCount(session.messageCountHint) + if (typeof hinted === 'number') return hinted + return 0 + }, [sessionMessageCounts]) + + const orderSessionsForExport = useCallback((source: SessionRow[]): SessionRow[] => { + return source + .filter((session) => session.hasSession && isContentScopeSession(session)) + .map((session) => ({ + session, + count: resolveSessionExistingMessageCount(session) + })) + .filter((item) => item.count > 0) + .sort((a, b) => { + const kindDiff = exportKindPriority[a.session.kind] - exportKindPriority[b.session.kind] + if (kindDiff !== 0) return kindDiff + if (a.count !== b.count) return b.count - a.count + const tsA = a.session.sortTimestamp || a.session.lastTimestamp || 0 + const tsB = b.session.sortTimestamp || b.session.lastTimestamp || 0 + if (tsA !== tsB) return tsB - tsA + return (a.session.displayName || a.session.username) + .localeCompare(b.session.displayName || b.session.username, 'zh-Hans-CN') + }) + .map((item) => item.session) + }, [resolveSessionExistingMessageCount]) + + const openBatchExport = () => { + const selectedSet = new Set(selectedSessions) + const selectedRows = sessions.filter((session) => selectedSet.has(session.username)) + const orderedRows = orderSessionsForExport(selectedRows) + if (orderedRows.length === 0) { + window.alert('所选会话暂无可导出的消息(总消息数为 0)') + return + } + const ids = orderedRows.map((session) => session.username) + const names = orderedRows.map((session) => session.displayName || session.username) + + openExportDialog({ + scope: 'multi', + sessionIds: ids, + sessionNames: names, + title: `批量导出(${ids.length} 个会话)` + }) + } + + const openContentExport = (contentType: ContentType) => { + const orderedRows = orderSessionsForExport(sessions) + if (orderedRows.length === 0) { + window.alert('当前会话列表暂无可导出的消息(总消息数为 0)') + return + } + const ids = orderedRows.map((session) => session.username) + const names = orderedRows.map((session) => session.displayName || session.username) + + openExportDialog({ + scope: 'content', + contentType, + sessionIds: ids, + sessionNames: names, + title: `${contentTypeLabels[contentType]}批量导出` + }) + } + + const openSnsExport = () => { + openExportDialog({ + scope: 'sns', + sessionIds: [], + sessionNames: ['全部朋友圈动态'], + title: '朋友圈批量导出' + }) + } + + const runningSessionIds = useMemo(() => { + const set = new Set() + for (const task of tasks) { + if (task.status !== 'running') continue + const settled = new Set(task.settledSessionIds || []) + for (const id of task.payload.sessionIds) { + if (settled.has(id)) continue + set.add(id) + } + } + return set + }, [tasks]) + + const queuedSessionIds = useMemo(() => { + const set = new Set() + for (const task of tasks) { + if (task.status !== 'queued') continue + for (const id of task.payload.sessionIds) { + set.add(id) + } + } + return set + }, [tasks]) + + const inProgressSessionIds = useMemo(() => { + const set = new Set() + for (const task of tasks) { + if (task.status !== 'running' && task.status !== 'queued') continue + for (const id of task.payload.sessionIds) { + set.add(id) + } + } + return Array.from(set).sort() + }, [tasks]) + const activeTaskCount = useMemo( + () => tasks.filter(task => task.status === 'running' || task.status === 'queued').length, + [tasks] + ) + + const inProgressSessionIdsKey = useMemo( + () => inProgressSessionIds.join('||'), + [inProgressSessionIds] + ) + const inProgressStatusKey = useMemo( + () => `${activeTaskCount}::${inProgressSessionIdsKey}`, + [activeTaskCount, inProgressSessionIdsKey] + ) + + useEffect(() => { + inProgressSessionIdsRef.current = inProgressSessionIds + }, [inProgressSessionIds]) + + useEffect(() => { + activeTaskCountRef.current = activeTaskCount + }, [activeTaskCount]) + + useEffect(() => { + emitExportSessionStatus({ + inProgressSessionIds: inProgressSessionIdsRef.current, + activeTaskCount: activeTaskCountRef.current + }) + }, [inProgressStatusKey]) + + useEffect(() => { + const unsubscribe = onExportSessionStatusRequest(() => { + emitExportSessionStatus({ + inProgressSessionIds: inProgressSessionIdsRef.current, + activeTaskCount: activeTaskCountRef.current + }) + }) + return unsubscribe + }, []) + + const runningCardTypes = useMemo(() => { + const set = new Set() + for (const task of tasks) { + if (task.status !== 'running') continue + if (task.payload.scope === 'sns') { + set.add('sns') + continue + } + if (task.payload.scope === 'content' && task.payload.contentType) { + set.add(task.payload.contentType) + } + } + return set + }, [tasks]) + + const contentCards = useMemo(() => { + const scopeSessions = sessions.filter(isContentScopeSession) + const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts) + + const sessionCards = [ + { type: 'text' as ContentType, icon: MessageSquareText }, + { type: 'voice' as ContentType, icon: Mic }, + { type: 'image' as ContentType, icon: ImageIcon }, + { type: 'video' as ContentType, icon: Video }, + { type: 'emoji' as ContentType, icon: WandSparkles } + ].map(item => { + let exported = 0 + for (const session of scopeSessions) { + if (lastExportByContent[`${session.username}::${item.type}`]) { + exported += 1 + } + } + + return { + ...item, + label: contentTypeLabels[item.type], + stats: [ + { label: '已导出', value: exported, unit: '个对话' } + ] + } + }) + + const snsCard = { + type: 'sns' as ContentCardType, + icon: Aperture, + label: '朋友圈', + headerCount: snsStats.totalPosts, + stats: [ + { label: '已导出', value: snsExportedCount, unit: '条' } + ] + } + + return [...sessionCards, snsCard] + }, [sessions, lastExportByContent, snsStats, lastSnsExportPostCount]) + + const activeTabLabel = useMemo(() => { + if (activeTab === 'private') return '私聊' + if (activeTab === 'group') return '群聊' + return '曾经的好友' + }, [activeTab]) + const contactsHeaderMainLabel = useMemo(() => { + if (activeTab === 'group') return '群聊名称' + if (activeTab === 'private' || activeTab === 'former_friend') return '联系人' + return '联系人(头像/名称/微信号)' + }, [activeTab]) + const shouldShowSnsColumn = useMemo(() => ( + activeTab === 'private' || activeTab === 'former_friend' + ), [activeTab]) + const shouldShowMutualFriendsColumn = shouldShowSnsColumn + + const sessionRowByUsername = useMemo(() => { + const map = new Map() + for (const session of sessions) { + map.set(session.username, session) + } + return map + }, [sessions]) + + const filteredContacts = useMemo(() => { + const keyword = searchKeyword.trim().toLowerCase() + const contacts = contactsList + .filter((contact) => { + if (!matchesContactTab(contact, activeTab)) return false + if (!keyword) return true + return ( + (contact.displayName || '').toLowerCase().includes(keyword) || + (contact.remark || '').toLowerCase().includes(keyword) || + (contact.nickname || '').toLowerCase().includes(keyword) || + (contact.alias || '').toLowerCase().includes(keyword) || + contact.username.toLowerCase().includes(keyword) + ) + }) + + const indexedContacts = contacts.map((contact, index) => ({ + contact, + index, + count: (() => { + const counted = normalizeMessageCount(sessionMessageCounts[contact.username]) + if (typeof counted === 'number') return counted + const hinted = normalizeMessageCount(sessionRowByUsername.get(contact.username)?.messageCountHint) + return hinted + })() + })) + + indexedContacts.sort((a, b) => { + const aHasCount = typeof a.count === 'number' + const bHasCount = typeof b.count === 'number' + if (aHasCount && bHasCount) { + const diff = (b.count as number) - (a.count as number) + if (diff !== 0) return diff + } else if (aHasCount) { + return -1 + } else if (bHasCount) { + return 1 + } + // 无统计值或同分时保持原顺序,避免列表频繁跳动。 + return a.index - b.index + }) + + return indexedContacts.map(item => item.contact) + }, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername]) + + const keywordMatchedContactUsernameSet = useMemo(() => { + const keyword = searchKeyword.trim().toLowerCase() + const matched = new Set() + for (const contact of contactsList) { + if (!contact?.username) continue + if (!keyword) { + matched.add(contact.username) + continue + } + if ( + (contact.displayName || '').toLowerCase().includes(keyword) || + (contact.remark || '').toLowerCase().includes(keyword) || + (contact.nickname || '').toLowerCase().includes(keyword) || + (contact.alias || '').toLowerCase().includes(keyword) || + contact.username.toLowerCase().includes(keyword) + ) { + matched.add(contact.username) + } + } + return matched + }, [contactsList, searchKeyword]) + + const loadDetailTargetsByTab = useMemo(() => { + const targets: Record = { + private: [], + group: [], + official: [], + former_friend: [] + } + for (const session of sessions) { + if (!session.hasSession) continue + if (!keywordMatchedContactUsernameSet.has(session.username)) continue + targets[session.kind].push(session.username) + } + return targets + }, [keywordMatchedContactUsernameSet, sessions]) + + const formatLoadDetailTime = useCallback((value?: number): string => { + if (!value || !Number.isFinite(value)) return '--' + return new Date(value).toLocaleTimeString('zh-CN', { hour12: false }) + }, []) + + const getLoadDetailStatusLabel = useCallback(( + loaded: number, + total: number, + hasStarted: boolean, + hasLoading: boolean, + failedCount: number + ): string => { + if (total <= 0) return '待加载' + const terminalCount = loaded + failedCount + if (terminalCount >= total) { + if (failedCount > 0) return `已完成 ${loaded}/${total}(失败 ${failedCount})` + return `已完成 ${total}` + } + if (hasLoading) return `加载中 ${loaded}/${total}` + if (hasStarted && failedCount > 0) return `已完成 ${loaded}/${total}(失败 ${failedCount})` + if (hasStarted) return `已完成 ${loaded}/${total}` + return '待加载' + }, []) + + const summarizeLoadTraceForTab = useCallback(( + sessionIds: string[], + stageKey: keyof SessionLoadTraceState + ): SessionLoadStageSummary => { + const total = sessionIds.length + let loaded = 0 + let failedCount = 0 + let hasStarted = false + let hasLoading = false + let earliestStart: number | undefined + let latestFinish: number | undefined + let latestProgressAt: number | undefined + for (const sessionId of sessionIds) { + const stage = sessionLoadTraceMap[sessionId]?.[stageKey] + if (stage?.status === 'done') { + loaded += 1 + if (typeof stage.finishedAt === 'number') { + latestProgressAt = latestProgressAt === undefined + ? stage.finishedAt + : Math.max(latestProgressAt, stage.finishedAt) + } + } + if (stage?.status === 'failed') { + failedCount += 1 + } + if (stage?.status === 'loading') { + hasLoading = true + } + if (stage?.status === 'loading' || stage?.status === 'failed' || typeof stage?.startedAt === 'number') { + hasStarted = true + } + if (typeof stage?.startedAt === 'number') { + earliestStart = earliestStart === undefined + ? stage.startedAt + : Math.min(earliestStart, stage.startedAt) + } + if (typeof stage?.finishedAt === 'number') { + latestFinish = latestFinish === undefined + ? stage.finishedAt + : Math.max(latestFinish, stage.finishedAt) + } + } + return { + total, + loaded, + statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted, hasLoading, failedCount), + startedAt: earliestStart, + finishedAt: (loaded + failedCount) >= total ? latestFinish : undefined, + latestProgressAt + } + }, [getLoadDetailStatusLabel, sessionLoadTraceMap]) + + const createNotApplicableLoadSummary = useCallback((): SessionLoadStageSummary => { + return { + total: 0, + loaded: 0, + statusLabel: '不适用' + } + }, []) + + const sessionLoadDetailRows = useMemo(() => { + const tabOrder: ConversationTab[] = ['private', 'group', 'former_friend'] + return tabOrder.map((tab) => { + const sessionIds = loadDetailTargetsByTab[tab] || [] + const snsSessionIds = sessionIds.filter((sessionId) => isSingleContactSession(sessionId)) + const snsPostCounts = tab === 'private' || tab === 'former_friend' + ? summarizeLoadTraceForTab(snsSessionIds, 'snsPostCounts') + : createNotApplicableLoadSummary() + const mutualFriends = tab === 'private' || tab === 'former_friend' + ? summarizeLoadTraceForTab(snsSessionIds, 'mutualFriends') + : createNotApplicableLoadSummary() + return { + tab, + label: conversationTabLabels[tab], + messageCount: summarizeLoadTraceForTab(sessionIds, 'messageCount'), + mediaMetrics: summarizeLoadTraceForTab(sessionIds, 'mediaMetrics'), + snsPostCounts, + mutualFriends + } + }) + }, [createNotApplicableLoadSummary, loadDetailTargetsByTab, summarizeLoadTraceForTab]) + + const formatLoadDetailPulseTime = useCallback((value?: number): string => { + if (!value || !Number.isFinite(value)) return '--' + return new Date(value).toLocaleTimeString('zh-CN', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + }, []) + + useEffect(() => { + const previousSnapshot = sessionLoadProgressSnapshotRef.current + const nextSnapshot: Record = {} + const resetKeys: string[] = [] + const updates: Array<{ key: string; at: number; delta: number }> = [] + const stageKeys: Array = ['messageCount', 'mediaMetrics', 'snsPostCounts', 'mutualFriends'] + + for (const row of sessionLoadDetailRows) { + for (const stageKey of stageKeys) { + const summary = row[stageKey] + const key = `${stageKey}:${row.tab}` + const loaded = Number.isFinite(summary.loaded) ? Math.max(0, Math.floor(summary.loaded)) : 0 + const total = Number.isFinite(summary.total) ? Math.max(0, Math.floor(summary.total)) : 0 + nextSnapshot[key] = { loaded, total } + + const previous = previousSnapshot[key] + if (!previous || previous.total !== total || loaded < previous.loaded) { + resetKeys.push(key) + continue + } + if (loaded > previous.loaded) { + updates.push({ + key, + at: summary.latestProgressAt || Date.now(), + delta: loaded - previous.loaded + }) + } + } + } + + sessionLoadProgressSnapshotRef.current = nextSnapshot + if (resetKeys.length === 0 && updates.length === 0) return + + setSessionLoadProgressPulseMap(prev => { + let changed = false + const next = { ...prev } + for (const key of resetKeys) { + if (!(key in next)) continue + delete next[key] + changed = true + } + for (const update of updates) { + const previous = next[update.key] + if (previous && previous.at === update.at && previous.delta === update.delta) continue + next[update.key] = { at: update.at, delta: update.delta } + changed = true + } + return changed ? next : prev + }) + }, [sessionLoadDetailRows]) + + useEffect(() => { + contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' }) + setIsContactsListAtTop(true) + }, [activeTab, searchKeyword]) + + const collectVisibleSessionMetricTargets = useCallback((sourceContacts: ContactInfo[]): string[] => { + if (sourceContacts.length === 0) return [] + const startCandidate = sessionMediaMetricVisibleRangeRef.current.startIndex + const endCandidate = sessionMediaMetricVisibleRangeRef.current.endIndex + const startIndex = Math.max(0, Math.min(sourceContacts.length - 1, startCandidate >= 0 ? startCandidate : 0)) + const visibleEnd = endCandidate >= startIndex + ? endCandidate + : Math.min(sourceContacts.length - 1, startIndex + 9) + const endIndex = Math.max(startIndex, Math.min(sourceContacts.length - 1, visibleEnd + SESSION_MEDIA_METRIC_PREFETCH_ROWS)) + const sessionIds: string[] = [] + for (let index = startIndex; index <= endIndex; index += 1) { + const contact = sourceContacts[index] + if (!contact?.username) continue + const mappedSession = sessionRowByUsername.get(contact.username) + if (!mappedSession?.hasSession) continue + sessionIds.push(contact.username) + } + return sessionIds + }, [sessionRowByUsername]) + + const collectVisibleSessionMutualFriendsTargets = useCallback((sourceContacts: ContactInfo[]): string[] => { + if (sourceContacts.length === 0) return [] + const startCandidate = sessionMutualFriendsVisibleRangeRef.current.startIndex + const endCandidate = sessionMutualFriendsVisibleRangeRef.current.endIndex + const startIndex = Math.max(0, Math.min(sourceContacts.length - 1, startCandidate >= 0 ? startCandidate : 0)) + const visibleEnd = endCandidate >= startIndex + ? endCandidate + : Math.min(sourceContacts.length - 1, startIndex + 9) + const endIndex = Math.max(startIndex, Math.min(sourceContacts.length - 1, visibleEnd + SESSION_MEDIA_METRIC_PREFETCH_ROWS)) + const sessionIds: string[] = [] + for (let index = startIndex; index <= endIndex; index += 1) { + const contact = sourceContacts[index] + if (!contact?.username || !isSingleContactSession(contact.username)) continue + const mappedSession = sessionRowByUsername.get(contact.username) + if (!mappedSession?.hasSession) continue + sessionIds.push(contact.username) + } + return sessionIds + }, [sessionRowByUsername]) + + const handleContactsRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { + const startIndex = Number.isFinite(range?.startIndex) ? Math.max(0, Math.floor(range.startIndex)) : 0 + const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex + sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex } + sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex } + void hydrateVisibleContactAvatars( + filteredContacts + .slice(startIndex, endIndex + 1) + .map((contact) => contact.username) + ) + const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) + if (visibleTargets.length === 0) return + enqueueSessionMediaMetricRequests(visibleTargets, { front: true }) + scheduleSessionMediaMetricWorker() + const visibleMutualFriendsTargets = collectVisibleSessionMutualFriendsTargets(filteredContacts) + if (visibleMutualFriendsTargets.length > 0) { + enqueueSessionMutualFriendsRequests(visibleMutualFriendsTargets, { front: true }) + scheduleSessionMutualFriendsWorker() + } + }, [ + collectVisibleSessionMetricTargets, + collectVisibleSessionMutualFriendsTargets, + enqueueSessionMediaMetricRequests, + enqueueSessionMutualFriendsRequests, + filteredContacts, + hydrateVisibleContactAvatars, + scheduleSessionMediaMetricWorker, + scheduleSessionMutualFriendsWorker + ]) + + useEffect(() => { + if (filteredContacts.length === 0) return + const bootstrapTargets = filteredContacts.slice(0, 24).map((contact) => contact.username) + void hydrateVisibleContactAvatars(bootstrapTargets) + }, [filteredContacts, hydrateVisibleContactAvatars]) + + useEffect(() => { + const sessionId = String(sessionDetail?.wxid || '').trim() + if (!sessionId) return + void hydrateVisibleContactAvatars([sessionId]) + }, [hydrateVisibleContactAvatars, sessionDetail?.wxid]) + + useEffect(() => { + if (activeTaskCount > 0) return + if (filteredContacts.length === 0) return + const runId = sessionMediaMetricRunIdRef.current + const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) + if (visibleTargets.length > 0) { + enqueueSessionMediaMetricRequests(visibleTargets, { front: true }) + scheduleSessionMediaMetricWorker() + } + + if (sessionMediaMetricBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current) + sessionMediaMetricBackgroundFeedTimerRef.current = null + } + + const visibleTargetSet = new Set(visibleTargets) + let cursor = 0 + const feedNext = () => { + if (runId !== sessionMediaMetricRunIdRef.current) return + const batchIds: string[] = [] + while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) { + const contact = filteredContacts[cursor] + cursor += 1 + if (!contact?.username) continue + if (visibleTargetSet.has(contact.username)) continue + const mappedSession = sessionRowByUsername.get(contact.username) + if (!mappedSession?.hasSession) continue + batchIds.push(contact.username) + } + + if (batchIds.length > 0) { + enqueueSessionMediaMetricRequests(batchIds) + scheduleSessionMediaMetricWorker() + } + + if (cursor < filteredContacts.length) { + sessionMediaMetricBackgroundFeedTimerRef.current = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS) + } + } + + feedNext() + return () => { + if (sessionMediaMetricBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current) + sessionMediaMetricBackgroundFeedTimerRef.current = null + } + } + }, [ + activeTaskCount, + collectVisibleSessionMetricTargets, + enqueueSessionMediaMetricRequests, + filteredContacts, + scheduleSessionMediaMetricWorker, + sessionRowByUsername + ]) + + useEffect(() => { + if (activeTaskCount > 0) return + const runId = sessionMediaMetricRunIdRef.current + const allTargets = [ + ...(loadDetailTargetsByTab.private || []), + ...(loadDetailTargetsByTab.group || []), + ...(loadDetailTargetsByTab.former_friend || []) + ] + if (allTargets.length === 0) return + + let timer: number | null = null + let cursor = 0 + const feedNext = () => { + if (runId !== sessionMediaMetricRunIdRef.current) return + const batchIds: string[] = [] + while (cursor < allTargets.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) { + const sessionId = allTargets[cursor] + cursor += 1 + if (!sessionId) continue + batchIds.push(sessionId) + } + if (batchIds.length > 0) { + enqueueSessionMediaMetricRequests(batchIds) + scheduleSessionMediaMetricWorker() + } + if (cursor < allTargets.length) { + timer = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS) + } + } + + feedNext() + return () => { + if (timer !== null) { + window.clearTimeout(timer) + } + } + }, [ + activeTaskCount, + enqueueSessionMediaMetricRequests, + loadDetailTargetsByTab.former_friend, + loadDetailTargetsByTab.group, + loadDetailTargetsByTab.private, + scheduleSessionMediaMetricWorker + ]) + + useEffect(() => { + if (activeTaskCount > 0) return + if (!isSessionCountStageReady || filteredContacts.length === 0) return + const runId = sessionMutualFriendsRunIdRef.current + const visibleTargets = collectVisibleSessionMutualFriendsTargets(filteredContacts) + if (visibleTargets.length > 0) { + enqueueSessionMutualFriendsRequests(visibleTargets, { front: true }) + scheduleSessionMutualFriendsWorker() + } + + if (sessionMutualFriendsBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current) + sessionMutualFriendsBackgroundFeedTimerRef.current = null + } + + const visibleTargetSet = new Set(visibleTargets) + let cursor = 0 + const feedNext = () => { + if (runId !== sessionMutualFriendsRunIdRef.current) return + const batchIds: string[] = [] + while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) { + const contact = filteredContacts[cursor] + cursor += 1 + if (!contact?.username || !isSingleContactSession(contact.username)) continue + if (visibleTargetSet.has(contact.username)) continue + const mappedSession = sessionRowByUsername.get(contact.username) + if (!mappedSession?.hasSession) continue + batchIds.push(contact.username) + } + + if (batchIds.length > 0) { + enqueueSessionMutualFriendsRequests(batchIds) + scheduleSessionMutualFriendsWorker() + } + + if (cursor < filteredContacts.length) { + sessionMutualFriendsBackgroundFeedTimerRef.current = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS) + } + } + + feedNext() + return () => { + if (sessionMutualFriendsBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current) + sessionMutualFriendsBackgroundFeedTimerRef.current = null + } + } + }, [ + activeTaskCount, + collectVisibleSessionMutualFriendsTargets, + enqueueSessionMutualFriendsRequests, + filteredContacts, + isSessionCountStageReady, + scheduleSessionMutualFriendsWorker, + sessionRowByUsername + ]) + + useEffect(() => { + return () => { + snsUserPostCountsHydrationTokenRef.current += 1 + if (snsUserPostCountsBatchTimerRef.current) { + window.clearTimeout(snsUserPostCountsBatchTimerRef.current) + snsUserPostCountsBatchTimerRef.current = null + } + if (sessionMediaMetricBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current) + sessionMediaMetricBackgroundFeedTimerRef.current = null + } + if (sessionMediaMetricPersistTimerRef.current) { + window.clearTimeout(sessionMediaMetricPersistTimerRef.current) + sessionMediaMetricPersistTimerRef.current = null + } + if (sessionMutualFriendsBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMutualFriendsBackgroundFeedTimerRef.current) + sessionMutualFriendsBackgroundFeedTimerRef.current = null + } + if (sessionMutualFriendsPersistTimerRef.current) { + window.clearTimeout(sessionMutualFriendsPersistTimerRef.current) + sessionMutualFriendsPersistTimerRef.current = null + } + void flushSessionMediaMetricCache() + void flushSessionMutualFriendsCache() + } + }, [flushSessionMediaMetricCache, flushSessionMutualFriendsCache]) + + const contactByUsername = useMemo(() => { + const map = new Map() + for (const contact of contactsList) { + map.set(contact.username, contact) + } + return map + }, [contactsList]) + + useEffect(() => { + if (!showSessionDetailPanel) return + const sessionId = String(sessionDetail?.wxid || '').trim() + if (!sessionId) return + + const mappedSession = sessionRowByUsername.get(sessionId) + const mappedContact = contactByUsername.get(sessionId) + if (!mappedSession && !mappedContact) return + + setSessionDetail((prev) => { + if (!prev || prev.wxid !== sessionId) return prev + + const nextDisplayName = mappedSession?.displayName || mappedContact?.displayName || prev.displayName || sessionId + const nextRemark = mappedContact?.remark ?? prev.remark + const nextNickName = mappedContact?.nickname ?? prev.nickName + const nextAlias = mappedContact?.alias ?? prev.alias + const nextAvatarUrl = mappedSession?.avatarUrl || mappedContact?.avatarUrl || prev.avatarUrl + + if ( + nextDisplayName === prev.displayName && + nextRemark === prev.remark && + nextNickName === prev.nickName && + nextAlias === prev.alias && + nextAvatarUrl === prev.avatarUrl + ) { + return prev + } + + return { + ...prev, + displayName: nextDisplayName, + remark: nextRemark, + nickName: nextNickName, + alias: nextAlias, + avatarUrl: nextAvatarUrl + } + }) + }, [contactByUsername, sessionDetail?.wxid, sessionRowByUsername, showSessionDetailPanel]) + + const currentSessionExportRecords = useMemo(() => { + const sessionId = String(sessionDetail?.wxid || '').trim() + if (!sessionId) return [] as configService.ExportSessionRecordEntry[] + const records = Array.isArray(exportRecordsBySession[sessionId]) ? exportRecordsBySession[sessionId] : [] + return [...records] + .sort((a, b) => Number(b.exportTime || 0) - Number(a.exportTime || 0)) + .slice(0, 20) + }, [sessionDetail?.wxid, exportRecordsBySession]) + + const sessionDetailSupportsSnsTimeline = useMemo(() => { + const sessionId = String(sessionDetail?.wxid || '').trim() + return isSingleContactSession(sessionId) + }, [sessionDetail?.wxid]) + + const sessionDetailSnsCountLabel = useMemo(() => { + const sessionId = String(sessionDetail?.wxid || '').trim() + if (!sessionId || !sessionDetailSupportsSnsTimeline) return '朋友圈:0条' + + if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') { + return '朋友圈:统计中...' + } + if (snsUserPostCountsStatus === 'error') { + return '朋友圈:统计失败' + } + + const count = Number(snsUserPostCounts[sessionId] || 0) + const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0 + return `朋友圈:${normalized}条` + }, [sessionDetail?.wxid, sessionDetailSupportsSnsTimeline, snsUserPostCounts, snsUserPostCountsStatus]) + + const sessionMutualFriendsDialogMetric = useMemo(() => { + const sessionId = String(sessionMutualFriendsDialogTarget?.username || '').trim() + if (!sessionId) return null + return sessionMutualFriendsMetrics[sessionId] || null + }, [sessionMutualFriendsDialogTarget, sessionMutualFriendsMetrics]) + + const filteredSessionMutualFriendsDialogItems = useMemo(() => { + const items = sessionMutualFriendsDialogMetric?.items || [] + const keyword = sessionMutualFriendsSearch.trim().toLowerCase() + if (!keyword) return items + return items.filter(item => item.name.toLowerCase().includes(keyword)) + }, [sessionMutualFriendsDialogMetric, sessionMutualFriendsSearch]) + + const applySessionDetailStats = useCallback(( + sessionId: string, + metric: SessionExportMetric, + cacheMeta?: SessionExportCacheMeta, + relationLoadedOverride?: boolean + ) => { + mergeSessionContentMetrics({ [sessionId]: metric }) + setSessionDetail((prev) => { + if (!prev || prev.wxid !== sessionId) return prev + const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded) + return { + ...prev, + messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : prev.messageCount, + voiceMessages: Number.isFinite(metric.voiceMessages) ? metric.voiceMessages : prev.voiceMessages, + imageMessages: Number.isFinite(metric.imageMessages) ? metric.imageMessages : prev.imageMessages, + videoMessages: Number.isFinite(metric.videoMessages) ? metric.videoMessages : prev.videoMessages, + emojiMessages: Number.isFinite(metric.emojiMessages) ? metric.emojiMessages : prev.emojiMessages, + transferMessages: Number.isFinite(metric.transferMessages) ? metric.transferMessages : prev.transferMessages, + redPacketMessages: Number.isFinite(metric.redPacketMessages) ? metric.redPacketMessages : prev.redPacketMessages, + callMessages: Number.isFinite(metric.callMessages) ? metric.callMessages : prev.callMessages, + groupMemberCount: Number.isFinite(metric.groupMemberCount) ? metric.groupMemberCount : prev.groupMemberCount, + groupMyMessages: Number.isFinite(metric.groupMyMessages) ? metric.groupMyMessages : prev.groupMyMessages, + groupActiveSpeakers: Number.isFinite(metric.groupActiveSpeakers) ? metric.groupActiveSpeakers : prev.groupActiveSpeakers, + privateMutualGroups: relationLoaded && Number.isFinite(metric.privateMutualGroups) + ? metric.privateMutualGroups + : prev.privateMutualGroups, + groupMutualFriends: relationLoaded && Number.isFinite(metric.groupMutualFriends) + ? metric.groupMutualFriends + : prev.groupMutualFriends, + relationStatsLoaded: relationLoaded, + statsUpdatedAt: cacheMeta?.updatedAt ?? prev.statsUpdatedAt, + statsStale: typeof cacheMeta?.stale === 'boolean' ? cacheMeta.stale : prev.statsStale, + firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : prev.firstMessageTime, + latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : prev.latestMessageTime + } + }) + }, [mergeSessionContentMetrics]) + + const loadSessionDetail = useCallback(async (sessionId: string) => { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + const preciseCacheKey = `${exportCacheScopeRef.current}::${normalizedSessionId}` + + detailStatsPriorityRef.current = true + sessionCountRequestIdRef.current += 1 + setIsLoadingSessionCounts(false) + + const requestSeq = ++detailRequestSeqRef.current + const mappedSession = sessionRowByUsername.get(normalizedSessionId) + const mappedContact = contactByUsername.get(normalizedSessionId) + const cachedMetric = sessionContentMetrics[normalizedSessionId] + const countedCount = normalizeMessageCount(sessionMessageCounts[normalizedSessionId]) + const metricCount = normalizeMessageCount(cachedMetric?.totalMessages) + const metricVoice = normalizeMessageCount(cachedMetric?.voiceMessages) + const metricImage = normalizeMessageCount(cachedMetric?.imageMessages) + const metricVideo = normalizeMessageCount(cachedMetric?.videoMessages) + const metricEmoji = normalizeMessageCount(cachedMetric?.emojiMessages) + const metricTransfer = normalizeMessageCount(cachedMetric?.transferMessages) + const metricRedPacket = normalizeMessageCount(cachedMetric?.redPacketMessages) + const metricCall = normalizeMessageCount(cachedMetric?.callMessages) + const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0 + ? Math.floor(mappedSession.messageCountHint) + : undefined + const initialMessageCount = countedCount ?? metricCount ?? hintedCount + + setCopiedDetailField(null) + setIsRefreshingSessionDetailStats(false) + setIsLoadingSessionRelationStats(false) + setSessionDetail((prev) => { + const sameSession = prev?.wxid === normalizedSessionId + return { + wxid: normalizedSessionId, + displayName: mappedSession?.displayName || mappedContact?.displayName || prev?.displayName || normalizedSessionId, + remark: sameSession ? prev?.remark : mappedContact?.remark, + nickName: sameSession ? prev?.nickName : mappedContact?.nickname, + alias: sameSession ? prev?.alias : mappedContact?.alias, + avatarUrl: mappedSession?.avatarUrl || mappedContact?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined), + messageCount: initialMessageCount ?? (sameSession ? prev.messageCount : Number.NaN), + voiceMessages: metricVoice ?? (sameSession ? prev?.voiceMessages : undefined), + imageMessages: metricImage ?? (sameSession ? prev?.imageMessages : undefined), + videoMessages: metricVideo ?? (sameSession ? prev?.videoMessages : undefined), + emojiMessages: metricEmoji ?? (sameSession ? prev?.emojiMessages : undefined), + transferMessages: metricTransfer ?? (sameSession ? prev?.transferMessages : undefined), + redPacketMessages: metricRedPacket ?? (sameSession ? prev?.redPacketMessages : undefined), + callMessages: metricCall ?? (sameSession ? prev?.callMessages : undefined), + privateMutualGroups: sameSession ? prev?.privateMutualGroups : undefined, + groupMemberCount: sameSession ? prev?.groupMemberCount : undefined, + groupMyMessages: sameSession ? prev?.groupMyMessages : undefined, + groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined, + groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined, + relationStatsLoaded: sameSession ? prev?.relationStatsLoaded : false, + statsUpdatedAt: sameSession ? prev?.statsUpdatedAt : undefined, + statsStale: sameSession ? prev?.statsStale : undefined, + firstMessageTime: sameSession ? prev?.firstMessageTime : undefined, + latestMessageTime: sameSession ? prev?.latestMessageTime : undefined, + messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : [] + } + }) + setIsLoadingSessionDetail(true) + setIsLoadingSessionDetailExtra(true) + + try { + const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId) + if (requestSeq !== detailRequestSeqRef.current) return + if (result.success && result.detail) { + const fastMessageCount = normalizeMessageCount(result.detail.messageCount) + if (typeof fastMessageCount === 'number') { + setSessionMessageCounts((prev) => { + if (prev[normalizedSessionId] === fastMessageCount) return prev + return { + ...prev, + [normalizedSessionId]: fastMessageCount + } + }) + mergeSessionContentMetrics({ + [normalizedSessionId]: { + totalMessages: fastMessageCount + } + }) + } + setSessionDetail((prev) => ({ + wxid: normalizedSessionId, + displayName: result.detail!.displayName || prev?.displayName || normalizedSessionId, + remark: result.detail!.remark ?? prev?.remark, + nickName: result.detail!.nickName ?? prev?.nickName, + alias: result.detail!.alias ?? prev?.alias, + avatarUrl: result.detail!.avatarUrl || prev?.avatarUrl, + messageCount: Number.isFinite(result.detail!.messageCount) ? result.detail!.messageCount : prev?.messageCount ?? Number.NaN, + voiceMessages: prev?.voiceMessages, + imageMessages: prev?.imageMessages, + videoMessages: prev?.videoMessages, + emojiMessages: prev?.emojiMessages, + transferMessages: prev?.transferMessages, + redPacketMessages: prev?.redPacketMessages, + callMessages: prev?.callMessages, + privateMutualGroups: prev?.privateMutualGroups, + groupMemberCount: prev?.groupMemberCount, + groupMyMessages: prev?.groupMyMessages, + groupActiveSpeakers: prev?.groupActiveSpeakers, + groupMutualFriends: prev?.groupMutualFriends, + relationStatsLoaded: prev?.relationStatsLoaded, + statsUpdatedAt: prev?.statsUpdatedAt, + statsStale: prev?.statsStale, + firstMessageTime: prev?.firstMessageTime, + latestMessageTime: prev?.latestMessageTime, + messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : [] + })) + } + } catch (error) { + console.error('导出页加载会话详情失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingSessionDetail(false) + } + } + + try { + const extraPromise = window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId) + void (async () => { + try { + const extraResult = await extraPromise + if (requestSeq !== detailRequestSeqRef.current) return + if (!extraResult.success || !extraResult.detail) return + const detail = extraResult.detail + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + firstMessageTime: detail.firstMessageTime, + latestMessageTime: detail.latestMessageTime, + messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : [] + } + }) + } catch (error) { + console.error('导出页加载会话详情补充信息失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingSessionDetailExtra(false) + } + } + })() + + let quickMetric: SessionExportMetric | undefined + let quickCacheMeta: SessionExportCacheMeta | undefined + try { + const quickStatsResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, allowStaleCache: true, cacheOnly: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (quickStatsResult.success) { + quickMetric = quickStatsResult.data?.[normalizedSessionId] as SessionExportMetric | undefined + quickCacheMeta = quickStatsResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (quickMetric) { + applySessionDetailStats(normalizedSessionId, quickMetric, quickCacheMeta, false) + } else if (quickCacheMeta) { + const cacheMeta = quickCacheMeta + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + statsUpdatedAt: cacheMeta.updatedAt, + statsStale: cacheMeta.stale + } + }) + } + } + } catch (error) { + console.error('导出页读取会话统计缓存失败:', error) + } + + try { + const relationCacheResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: true, allowStaleCache: true, cacheOnly: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (relationCacheResult.success && relationCacheResult.data) { + const relationMetric = relationCacheResult.data[normalizedSessionId] as SessionExportMetric | undefined + const relationCacheMeta = relationCacheResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (relationMetric) { + applySessionDetailStats(normalizedSessionId, relationMetric, relationCacheMeta, true) + } + } + } catch (error) { + console.error('导出页读取会话关系缓存失败:', error) + } + + const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0 + const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS + const shouldRunBackgroundRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale)) + + if (shouldRunBackgroundRefresh) { + setIsRefreshingSessionDetailStats(true) + void (async () => { + try { + // 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列。 + const freshResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, forceRefresh: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (freshResult.success && freshResult.data) { + const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined + const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (metric) { + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false) + sessionPreciseRefreshAtRef.current[preciseCacheKey] = Date.now() + } else if (cacheMeta) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + statsUpdatedAt: cacheMeta.updatedAt, + statsStale: cacheMeta.stale + } + }) + } + } + } catch (error) { + console.error('导出页刷新会话统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsRefreshingSessionDetailStats(false) + } + } + })() + } + } catch (error) { + console.error('导出页加载会话详情补充统计失败:', error) + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingSessionDetailExtra(false) + } + } + }, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername]) + + const loadSessionRelationStats = useCallback(async (options?: { forceRefresh?: boolean }) => { + const normalizedSessionId = String(sessionDetail?.wxid || '').trim() + if (!normalizedSessionId || isLoadingSessionRelationStats) return + + const requestSeq = detailRequestSeqRef.current + const forceRefresh = options?.forceRefresh === true + setIsLoadingSessionRelationStats(true) + try { + if (!forceRefresh) { + const relationCacheResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: true, allowStaleCache: true, cacheOnly: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + + const relationMetric = relationCacheResult.success && relationCacheResult.data + ? relationCacheResult.data[normalizedSessionId] as SessionExportMetric | undefined + : undefined + const relationCacheMeta = relationCacheResult.success + ? relationCacheResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + : undefined + if (relationMetric) { + applySessionDetailStats(normalizedSessionId, relationMetric, relationCacheMeta, true) + return + } + } + + const relationResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + + const metric = relationResult.success && relationResult.data + ? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined + : undefined + const cacheMeta = relationResult.success + ? relationResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + : undefined + if (metric) { + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, true) + } + } catch (error) { + console.error('导出页加载会话关系统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingSessionRelationStats(false) + } + } + }, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid]) + + const handleRefreshTableData = useCallback(async () => { + const scopeKey = await ensureExportCacheScope() + + resetSessionMutualFriendsLoader() + sessionMutualFriendsMetricsRef.current = {} + setSessionMutualFriendsMetrics({}) + closeSessionMutualFriendsDialog() + try { + await configService.clearExportSessionMutualFriendsCache(scopeKey) + } catch (error) { + console.error('清理导出页共同好友缓存失败:', error) + } + + if (isSessionCountStageReady) { + const visibleTargetIds = collectVisibleSessionMutualFriendsTargets(filteredContacts) + const visibleTargetSet = new Set(visibleTargetIds) + const remainingTargetIds = sessionsRef.current + .filter((session) => session.hasSession && isSingleContactSession(session.username) && !visibleTargetSet.has(session.username)) + .map((session) => session.username) + + if (visibleTargetIds.length > 0) { + enqueueSessionMutualFriendsRequests(visibleTargetIds, { front: true }) + } + if (remainingTargetIds.length > 0) { + enqueueSessionMutualFriendsRequests(remainingTargetIds) + } + scheduleSessionMutualFriendsWorker() + } + + await Promise.all([ + loadContactsList({ scopeKey }), + loadSnsStats({ full: true }), + loadSnsUserPostCounts({ force: true }) + ]) + + const currentDetailSessionId = showSessionDetailPanel + ? String(sessionDetail?.wxid || '').trim() + : '' + if (currentDetailSessionId) { + await loadSessionDetail(currentDetailSessionId) + void loadSessionRelationStats({ forceRefresh: true }) + } + }, [ + closeSessionMutualFriendsDialog, + collectVisibleSessionMutualFriendsTargets, + enqueueSessionMutualFriendsRequests, + ensureExportCacheScope, + filteredContacts, + isSessionCountStageReady, + loadContactsList, + loadSessionDetail, + loadSessionRelationStats, + loadSnsStats, + loadSnsUserPostCounts, + resetSessionMutualFriendsLoader, + scheduleSessionMutualFriendsWorker, + showSessionDetailPanel, + sessionDetail?.wxid + ]) + + useEffect(() => { + if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return + if (snsUserPostCountsStatus === 'idle') { + void loadSnsUserPostCounts() + } + }, [ + loadSnsUserPostCounts, + sessionDetailSupportsSnsTimeline, + showSessionDetailPanel, + snsUserPostCountsStatus + ]) + + useEffect(() => { + if (!isExportRoute || !isSessionCountStageReady) return + if (snsUserPostCountsStatus !== 'idle') return + const timer = window.setTimeout(() => { + void loadSnsUserPostCounts() + }, 260) + return () => window.clearTimeout(timer) + }, [isExportRoute, isSessionCountStageReady, loadSnsUserPostCounts, snsUserPostCountsStatus]) + + useEffect(() => { + if (!sessionSnsTimelineTarget) return + if (Object.prototype.hasOwnProperty.call(snsUserPostCounts, sessionSnsTimelineTarget.username)) { + const total = Number(snsUserPostCounts[sessionSnsTimelineTarget.username] || 0) + const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0 + setSessionSnsTimelineTotalPosts(normalizedTotal) + setSessionSnsRankTotalPosts(normalizedTotal) + setSessionSnsTimelineStatsLoading(false) + return + } + if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') { + setSessionSnsTimelineStatsLoading(true) + return + } + setSessionSnsTimelineTotalPosts(null) + setSessionSnsRankTotalPosts(null) + setSessionSnsTimelineStatsLoading(false) + }, [sessionSnsTimelineTarget, snsUserPostCounts, snsUserPostCountsStatus]) + + useEffect(() => { + if (sessionSnsTimelineTotalPosts === null) return + if (sessionSnsTimelinePosts.length >= sessionSnsTimelineTotalPosts) { + setSessionSnsTimelineHasMore(false) + } + }, [sessionSnsTimelinePosts.length, sessionSnsTimelineTotalPosts]) + + useEffect(() => { + if (!sessionSnsRankMode || !sessionSnsTimelineTarget) return + void loadSessionSnsRankings(sessionSnsTimelineTarget) + }, [loadSessionSnsRankings, sessionSnsRankMode, sessionSnsTimelineTarget]) + + const closeSessionDetailPanel = useCallback(() => { + detailRequestSeqRef.current += 1 + detailStatsPriorityRef.current = false + sessionSnsTimelineRequestTokenRef.current += 1 + sessionSnsTimelineLoadingRef.current = false + sessionSnsRankRequestTokenRef.current += 1 + sessionSnsRankLoadingRef.current = false + setShowSessionDetailPanel(false) + setIsLoadingSessionDetail(false) + setIsLoadingSessionDetailExtra(false) + setIsRefreshingSessionDetailStats(false) + setIsLoadingSessionRelationStats(false) + setSessionSnsRankMode(null) + setSessionSnsLikeRankings([]) + setSessionSnsCommentRankings([]) + setSessionSnsRankLoading(false) + setSessionSnsRankError(null) + setSessionSnsRankLoadedPosts(0) + setSessionSnsRankTotalPosts(null) + setSessionSnsTimelineTarget(null) + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineLoading(false) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineHasMore(false) + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(false) + }, []) + + const openSessionDetail = useCallback((sessionId: string) => { + if (!sessionId) return + detailStatsPriorityRef.current = true + setShowSessionDetailPanel(true) + if (isSingleContactSession(sessionId)) { + void loadSnsUserPostCounts() + } + void loadSessionDetail(sessionId) + }, [loadSessionDetail, loadSnsUserPostCounts]) + + useEffect(() => { + if (!showSessionDetailPanel) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeSessionDetailPanel() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [closeSessionDetailPanel, showSessionDetailPanel]) + + useEffect(() => { + if (!showSessionLoadDetailModal) return + if (snsUserPostCountsStatus === 'idle') { + void loadSnsUserPostCounts() + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setShowSessionLoadDetailModal(false) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [loadSnsUserPostCounts, showSessionLoadDetailModal, snsUserPostCountsStatus]) + + useEffect(() => { + if (!sessionSnsTimelineTarget) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeSessionSnsTimeline() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [closeSessionSnsTimeline, sessionSnsTimelineTarget]) + + useEffect(() => { + if (!sessionMutualFriendsDialogTarget) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeSessionMutualFriendsDialog() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [closeSessionMutualFriendsDialog, sessionMutualFriendsDialogTarget]) + + useEffect(() => { + if (!showSessionFormatSelect) return + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as Node + if (sessionFormatDropdownRef.current && !sessionFormatDropdownRef.current.contains(target)) { + setShowSessionFormatSelect(false) + } + } + document.addEventListener('mousedown', handlePointerDown) + return () => document.removeEventListener('mousedown', handlePointerDown) + }, [showSessionFormatSelect]) + + useEffect(() => { + if (!exportDialog.open) { + setShowSessionFormatSelect(false) + } + }, [exportDialog.open]) + + const handleCopyDetailField = useCallback(async (text: string, field: string) => { + try { + await navigator.clipboard.writeText(text) + setCopiedDetailField(field) + setTimeout(() => setCopiedDetailField(null), 1500) + } catch { + const textarea = document.createElement('textarea') + textarea.value = text + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + setCopiedDetailField(field) + setTimeout(() => setCopiedDetailField(null), 1500) + } + }, []) + + const contactsIssueElapsedMs = useMemo(() => { + if (!contactsLoadIssue) return 0 + if (isContactsListLoading && contactsLoadSession) { + return Math.max(contactsLoadIssue.elapsedMs, contactsDiagnosticTick - contactsLoadSession.startedAt) + } + return contactsLoadIssue.elapsedMs + }, [contactsDiagnosticTick, isContactsListLoading, contactsLoadIssue, contactsLoadSession]) + + const contactsDiagnosticsText = useMemo(() => { + if (!contactsLoadIssue || !contactsLoadSession) return '' + return [ + `请求ID: ${contactsLoadSession.requestId}`, + `请求序号: 第 ${contactsLoadSession.attempt} 次`, + `阈值配置: ${contactsLoadSession.timeoutMs}ms`, + `当前状态: ${contactsLoadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`, + `累计耗时: ${(contactsIssueElapsedMs / 1000).toFixed(1)}s`, + `发生时间: ${new Date(contactsLoadIssue.occurredAt).toLocaleString()}`, + '阶段: chat.getContacts', + `原因: ${contactsLoadIssue.reason}`, + `错误详情: ${contactsLoadIssue.errorDetail || '无'}` + ].join('\n') + }, [contactsIssueElapsedMs, contactsLoadIssue, contactsLoadSession]) + + const copyContactsDiagnostics = useCallback(async () => { + if (!contactsDiagnosticsText) return + try { + await navigator.clipboard.writeText(contactsDiagnosticsText) + alert('诊断信息已复制') + } catch (error) { + console.error('复制诊断信息失败:', error) + alert('复制失败,请手动复制诊断信息') + } + }, [contactsDiagnosticsText]) + const handleCancelBackgroundTask = useCallback((taskId: string) => { + requestCancelBackgroundTask(taskId) + }, []) + const handleCancelAllNonExportTasks = useCallback(() => { + requestCancelBackgroundTasks(task => ( + task.sourcePage !== 'export' && + task.cancelable && + (task.status === 'running' || task.status === 'cancel_requested') + )) + }, []) + + const sessionContactsUpdatedAtLabel = useMemo(() => { + if (!sessionContactsUpdatedAt) return '' + return new Date(sessionContactsUpdatedAt).toLocaleString() + }, [sessionContactsUpdatedAt]) + + const sessionAvatarUpdatedAtLabel = useMemo(() => { + if (!sessionAvatarUpdatedAt) return '' + return new Date(sessionAvatarUpdatedAt).toLocaleString() + }, [sessionAvatarUpdatedAt]) + + const sessionAvatarCachedCount = useMemo(() => { + return sessions.reduce((count, session) => (session.avatarUrl ? count + 1 : count), 0) + }, [sessions]) + + const visibleSelectableCount = useMemo(() => ( + filteredContacts.reduce((count, contact) => ( + sessionRowByUsername.get(contact.username)?.hasSession ? count + 1 : count + ), 0) + ), [filteredContacts, sessionRowByUsername]) + const isAllVisibleSelected = visibleSelectableCount > 0 && selectedCount === visibleSelectableCount + + const canCreateTask = exportDialog.scope === 'sns' + ? Boolean(exportFolder) + : Boolean(exportFolder) && exportDialog.sessionIds.length > 0 + const scopeLabel = exportDialog.scope === 'single' + ? '单会话' + : exportDialog.scope === 'multi' + ? '多会话' + : exportDialog.scope === 'sns' + ? '朋友圈批量' + : `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']})` + const scopeCountLabel = exportDialog.scope === 'sns' + ? `共 ${snsStats.totalPosts} 条朋友圈动态` + : `共 ${exportDialog.sessionIds.length} 个会话` + const snsFormatOptions: Array<{ value: SnsTimelineExportFormat; label: string; desc: string }> = [ + { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, + { value: 'json', label: 'JSON', desc: '原始结构化格式(兼容旧导入)' }, + { value: 'arkmejson', label: 'ArkmeJSON', desc: '增强结构化格式,包含互动身份字段' } + ] + const formatCandidateOptions = exportDialog.scope === 'sns' + ? snsFormatOptions + : formatOptions + const isSessionScopeDialog = exportDialog.scope === 'single' || exportDialog.scope === 'multi' + const isContentScopeDialog = exportDialog.scope === 'content' + const isContentTextDialog = isContentScopeDialog && exportDialog.contentType === 'text' + const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog + const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog + const shouldShowMediaSection = !isContentScopeDialog + const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && ( + (isSessionScopeDialog && options.exportImages) || + (isContentScopeDialog && exportDialog.contentType === 'image') + ) + const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像' + const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。' + const activeDialogFormatLabel = exportDialog.scope === 'sns' + ? (snsFormatOptions.find(option => option.value === snsExportFormat)?.label ?? snsExportFormat) + : (formatOptions.find(option => option.value === options.format)?.label ?? options.format) + const shouldShowDisplayNameSection = !( + exportDialog.scope === 'sns' || + ( + exportDialog.scope === 'content' && + ( + exportDialog.contentType === 'voice' || + exportDialog.contentType === 'image' || + exportDialog.contentType === 'video' || + exportDialog.contentType === 'emoji' + ) + ) + ) + const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady + const isSnsCardStatsLoading = !hasSeededSnsStats + const taskRunningCount = tasks.filter(task => task.status === 'running').length + const taskQueuedCount = tasks.filter(task => task.status === 'queued').length + const taskCenterAlertCount = taskRunningCount + taskQueuedCount + const hasFilteredContacts = filteredContacts.length > 0 + const contactsTableMinWidth = useMemo(() => { + const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + 140 + (8 * 12) + const snsWidth = shouldShowSnsColumn ? 72 + 12 : 0 + const mutualFriendsWidth = shouldShowMutualFriendsColumn ? 72 + 12 : 0 + return baseWidth + snsWidth + mutualFriendsWidth + }, [shouldShowMutualFriendsColumn, shouldShowSnsColumn]) + const contactsTableStyle = useMemo(() => ( + { + ['--contacts-table-min-width' as const]: `${contactsTableMinWidth}px` + } as CSSProperties + ), [contactsTableMinWidth]) + const hasContactsHorizontalOverflow = contactsHorizontalScrollMetrics.contentWidth - contactsHorizontalScrollMetrics.viewportWidth > 1 + const contactsBottomScrollbarInnerStyle = useMemo(() => ({ + width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px` + }), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth]) + const nonExportBackgroundTasks = useMemo(() => ( + backgroundTasks.filter(task => task.sourcePage !== 'export') + ), [backgroundTasks]) + const runningNonExportTaskCount = useMemo(() => ( + nonExportBackgroundTasks.filter(task => task.status === 'running' || task.status === 'cancel_requested').length + ), [nonExportBackgroundTasks]) + const cancelableNonExportTaskCount = useMemo(() => ( + nonExportBackgroundTasks.filter(task => ( + task.cancelable && + (task.status === 'running' || task.status === 'cancel_requested') + )).length + ), [nonExportBackgroundTasks]) + const nonExportBackgroundTasksUpdatedAt = useMemo(() => ( + nonExportBackgroundTasks.reduce((latest, task) => Math.max(latest, task.updatedAt || 0), 0) + ), [nonExportBackgroundTasks]) + const sessionLoadDetailUpdatedAt = useMemo(() => { + let latest = 0 + for (const row of sessionLoadDetailRows) { + const candidateTimes = [ + row.messageCount.finishedAt || row.messageCount.startedAt || 0, + row.mediaMetrics.finishedAt || row.mediaMetrics.startedAt || 0, + row.snsPostCounts.finishedAt || row.snsPostCounts.startedAt || 0, + row.mutualFriends.finishedAt || row.mutualFriends.startedAt || 0 + ] + for (const candidate of candidateTimes) { + if (candidate > latest) { + latest = candidate + } + } + } + return latest + }, [sessionLoadDetailRows]) + const isSessionLoadDetailActive = useMemo(() => ( + sessionLoadDetailRows.some(row => ( + row.messageCount.statusLabel.startsWith('加载中') || + row.mediaMetrics.statusLabel.startsWith('加载中') || + row.snsPostCounts.statusLabel.startsWith('加载中') || + row.mutualFriends.statusLabel.startsWith('加载中') + )) + ), [sessionLoadDetailRows]) + const syncContactsHorizontalScroll = useCallback((source: 'viewport' | 'bottom', scrollLeft: number) => { + if (contactsScrollSyncSourceRef.current && contactsScrollSyncSourceRef.current !== source) return + + contactsScrollSyncSourceRef.current = source + const viewport = contactsHorizontalViewportRef.current + const bottomScrollbar = contactsBottomScrollbarRef.current + + if (source !== 'viewport' && viewport && Math.abs(viewport.scrollLeft - scrollLeft) > 1) { + viewport.scrollLeft = scrollLeft + } + + if (source !== 'bottom' && bottomScrollbar && Math.abs(bottomScrollbar.scrollLeft - scrollLeft) > 1) { + bottomScrollbar.scrollLeft = scrollLeft + } + + window.requestAnimationFrame(() => { + if (contactsScrollSyncSourceRef.current === source) { + contactsScrollSyncSourceRef.current = null + } + }) + }, []) + const handleContactsHorizontalViewportScroll = useCallback((event: UIEvent) => { + syncContactsHorizontalScroll('viewport', event.currentTarget.scrollLeft) + }, [syncContactsHorizontalScroll]) + const handleContactsBottomScrollbarScroll = useCallback((event: UIEvent) => { + syncContactsHorizontalScroll('bottom', event.currentTarget.scrollLeft) + }, [syncContactsHorizontalScroll]) + const resetContactsHeaderDrag = useCallback((currentTarget?: HTMLDivElement | null) => { + const dragState = contactsHeaderDragStateRef.current + if (currentTarget && dragState.pointerId >= 0 && currentTarget.hasPointerCapture(dragState.pointerId)) { + currentTarget.releasePointerCapture(dragState.pointerId) + } + dragState.pointerId = -1 + dragState.startClientX = 0 + dragState.startScrollLeft = 0 + dragState.didDrag = false + setIsContactsHeaderDragging(false) + }, []) + const handleContactsHeaderPointerDown = useCallback((event: PointerEvent) => { + if (!hasContactsHorizontalOverflow || event.pointerType === 'touch') return + if (event.button !== 0) return + if (event.target instanceof Element && event.target.closest('button, a, input, textarea, select, label, [role="button"]')) { return } - const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared' - runExport(layout) - } - - const getDaysInMonth = (date: Date) => { - const year = date.getFullYear() - const month = date.getMonth() - return new Date(year, month + 1, 0).getDate() - } - - const getFirstDayOfMonth = (date: Date) => { - const year = date.getFullYear() - const month = date.getMonth() - return new Date(year, month, 1).getDay() - } - - const generateCalendar = () => { - const daysInMonth = getDaysInMonth(calendarDate) - const firstDay = getFirstDayOfMonth(calendarDate) - const days: (number | null)[] = [] - - for (let i = 0; i < firstDay; i++) { - days.push(null) + contactsHeaderDragStateRef.current = { + pointerId: event.pointerId, + startClientX: event.clientX, + startScrollLeft: contactsHorizontalViewportRef.current?.scrollLeft ?? 0, + didDrag: false } + event.currentTarget.setPointerCapture(event.pointerId) + setIsContactsHeaderDragging(true) + }, [hasContactsHorizontalOverflow]) + const handleContactsHeaderPointerMove = useCallback((event: PointerEvent) => { + const dragState = contactsHeaderDragStateRef.current + if (dragState.pointerId !== event.pointerId) return - for (let i = 1; i <= daysInMonth; i++) { - days.push(i) - } + const viewport = contactsHorizontalViewportRef.current + const content = contactsHorizontalContentRef.current + if (!viewport || !content) return - return days - } + const deltaX = event.clientX - dragState.startClientX + if (!dragState.didDrag && Math.abs(deltaX) < 4) return - const handleDateSelect = (day: number) => { - const year = calendarDate.getFullYear() - const month = calendarDate.getMonth() - const selectedDate = new Date(year, month, day) - // 设置时间为当天的开始或结束 - selectedDate.setHours(selectingStart ? 0 : 23, selectingStart ? 0 : 59, selectingStart ? 0 : 59, selectingStart ? 0 : 999) + dragState.didDrag = true + const maxScrollLeft = Math.max(0, content.scrollWidth - viewport.clientWidth) + const nextScrollLeft = Math.max(0, Math.min(dragState.startScrollLeft - deltaX, maxScrollLeft)) - const now = new Date() - // 如果选择的日期晚于当前时间,限制为当前时间 - if (selectedDate > now) { - selectedDate.setTime(now.getTime()) - } + viewport.scrollLeft = nextScrollLeft + syncContactsHorizontalScroll('viewport', nextScrollLeft) + event.preventDefault() + }, [syncContactsHorizontalScroll]) + const handleContactsHeaderPointerUp = useCallback((event: PointerEvent) => { + if (contactsHeaderDragStateRef.current.pointerId !== event.pointerId) return + resetContactsHeaderDrag(event.currentTarget) + }, [resetContactsHeaderDrag]) + const handleContactsHeaderPointerCancel = useCallback((event: PointerEvent) => { + if (contactsHeaderDragStateRef.current.pointerId !== event.pointerId) return + resetContactsHeaderDrag(event.currentTarget) + }, [resetContactsHeaderDrag]) + useEffect(() => { + const viewport = contactsHorizontalViewportRef.current + const content = contactsHorizontalContentRef.current + if (!viewport || !content) return - if (selectingStart) { - // 选择开始日期 - const currentEnd = options.dateRange?.end || new Date() - // 如果选择的开始日期晚于结束日期,则同时更新结束日期 - if (selectedDate > currentEnd) { - const newEnd = new Date(selectedDate) - newEnd.setHours(23, 59, 59, 999) - // 确保结束日期也不晚于当前时间 - if (newEnd > now) { - newEnd.setTime(now.getTime()) + const syncMetrics = () => { + const viewportWidth = Math.round(viewport.clientWidth) + const contentWidth = Math.round(content.scrollWidth) + + setContactsHorizontalScrollMetrics((prev) => ( + prev.viewportWidth === viewportWidth && prev.contentWidth === contentWidth + ? prev + : { viewportWidth, contentWidth } + )) + + const maxScrollLeft = Math.max(0, contentWidth - viewportWidth) + const clampedScrollLeft = Math.min(viewport.scrollLeft, maxScrollLeft) + + if (Math.abs(viewport.scrollLeft - clampedScrollLeft) > 1) { + viewport.scrollLeft = clampedScrollLeft + } + + const bottomScrollbar = contactsBottomScrollbarRef.current + if (bottomScrollbar) { + const nextScrollLeft = Math.min(bottomScrollbar.scrollLeft, maxScrollLeft) + if (Math.abs(bottomScrollbar.scrollLeft - nextScrollLeft) > 1) { + bottomScrollbar.scrollLeft = nextScrollLeft + } + if (Math.abs(nextScrollLeft - clampedScrollLeft) > 1) { + bottomScrollbar.scrollLeft = clampedScrollLeft } - setOptions({ - ...options, - dateRange: { start: selectedDate, end: newEnd } - }) - } else { - setOptions({ - ...options, - dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() } - }) } - setSelectingStart(false) - } else { - // 选择结束日期 - const currentStart = options.dateRange?.start || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - // 如果选择的结束日期早于开始日期,则同时更新开始日期 - if (selectedDate < currentStart) { - const newStart = new Date(selectedDate) - newStart.setHours(0, 0, 0, 0) - setOptions({ - ...options, - dateRange: { start: newStart, end: selectedDate } - }) - } else { - setOptions({ - ...options, - dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate } - }) + } + + syncMetrics() + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', syncMetrics) + return () => window.removeEventListener('resize', syncMetrics) + } + + const resizeObserver = new ResizeObserver(syncMetrics) + resizeObserver.observe(viewport) + resizeObserver.observe(content) + + return () => { + resizeObserver.disconnect() + } + }, []) + const closeTaskCenter = useCallback(() => { + setIsTaskCenterOpen(false) + setExpandedPerfTaskId(null) + }, []) + const toggleTaskPerfDetail = useCallback((taskId: string) => { + setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId)) + }, []) + const renderContactRow = useCallback((_: number, contact: ContactInfo) => { + const matchedSession = sessionRowByUsername.get(contact.username) + const canExport = Boolean(matchedSession?.hasSession) + const isSessionBindingPending = !matchedSession && (isLoading || isSessionEnriching) + const checked = canExport && selectedSessions.has(contact.username) + const isRunning = canExport && runningSessionIds.has(contact.username) + const isQueued = canExport && queuedSessionIds.has(contact.username) + const recentExportTimestamp = lastExportBySession[contact.username] + const hasRecentExport = canExport && Boolean(recentExportTimestamp) + const recentExportTime = hasRecentExport ? formatRecentExportTime(recentExportTimestamp, nowTick) : '' + const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username]) + const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) + const displayedMessageCount = countedMessages ?? hintedMessages + const mediaMetric = sessionContentMetrics[contact.username] + const messageCountState: { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } = + !canExport + ? (isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' }) + : typeof displayedMessageCount === 'number' + ? { state: 'value', text: displayedMessageCount.toLocaleString('zh-CN') } + : { state: 'loading' } + const metricToDisplay = (value: unknown): { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } => { + const normalized = normalizeMessageCount(value) + if (!canExport) { + return isSessionBindingPending ? { state: 'loading' } : { state: 'na', text: '--' } } - setSelectingStart(true) + if (typeof normalized === 'number') { + return { state: 'value', text: normalized.toLocaleString('zh-CN') } + } + return { state: 'loading' } } - } - - const formatOptions = [ - { value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' }, - { value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' }, - { value: 'json', label: 'JSON', icon: FileJson, desc: '详细格式,包含完整消息信息' }, - { value: 'html', label: 'HTML', icon: FileText, desc: '网页格式,可直接浏览' }, - { value: 'txt', label: 'TXT', icon: Table, desc: '纯文本,通用格式' }, - { value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' }, - { value: 'weclone', label: 'WeClone CSV', icon: Table, desc: 'WeClone 兼容字段格式(CSV)' }, - { value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' } - ] - const displayNameOptions = [ - { - value: 'group-nickname', - label: '群昵称优先', - desc: '仅群聊有效,私聊显示备注/昵称' - }, - { - value: 'remark', - label: '备注优先', - desc: '有备注显示备注,否则显示昵称' - }, - { - value: 'nickname', - label: '微信昵称', - desc: '始终显示微信昵称' - } - ] - const displayNameOption = displayNameOptions.find(option => option.value === options.displayNamePreference) - const displayNameLabel = displayNameOption?.label || '备注优先' - - return ( -
-
-
-

选择会话

- -
- -
- - setSearchKeyword(e.target.value)} - /> - {searchKeyword && ( - - )} -
- -
- - 已选 {selectedSessions.size} 个 -
- - {isLoading ? ( -
- - 加载中... -
- ) : filteredSessions.length === 0 ? ( -
- 暂无会话 -
- ) : ( -
- {filteredSessions.map(session => ( -
toggleSession(session.username)} - > -
- {selectedSessions.has(session.username) && } -
-
- {session.avatarUrl ? ( - - ) : ( - {getAvatarLetter(session.displayName || session.username)} - )} -
-
-
{session.displayName || session.username}
-
{session.summary || '暂无消息'}
-
-
- ))} -
- )} -
- -
-
-

导出设置

-
- -
-
-

导出格式

-
- {formatOptions.map(fmt => ( -
handleFormatChange(fmt.value as ExportOptions['format'])} - > - - {fmt.label} - {fmt.desc} -
- ))} -
-
- -
-

时间范围

-

选择要导出的消息时间区间

-
-
-
- 导出全部时间 - 关闭此项以选择特定的起止日期 -
- -
- - {!options.useAllTime && options.dateRange && ( - <> -
-
setShowDatePicker(true)}> -
- - {formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)} -
- -
- - )} -
-
- - {/* 发送者名称显示偏好 */} - {(options.format === 'html' || options.format === 'json' || options.format === 'txt') && ( -
-

发送者名称显示

-

选择导出时优先显示的名称

-
- - {showDisplayNameSelect && ( -
- {displayNameOptions.map(option => ( - - ))} -
- )} -
-
- )} -
-

媒体文件

-

导出图片/语音/视频/表情并在记录内写入相对路径

-
-
-
- 导出媒体文件 - 会创建子文件夹并保存媒体资源 -
- -
- -
- - - -
- - - -
- - - -
- - - -
- - -
-
- -
-

头像

-

可选导出头像索引,关闭则不下载头像

-
-
-
- 导出头像 - 用于展示发送者头像,可能会读取或下载头像文件 -
- -
-
-
- -
-

导出位置

-
- - {exportFolder || '未设置'} -
- -
-
- -
- -
-
- - {/* 媒体导出布局选择弹窗 */} - {showMediaLayoutPrompt && ( -
setShowMediaLayoutPrompt(false)}> -
e.stopPropagation()}> -

导出文件夹布局

-

检测到同时导出多个会话并包含媒体文件,请选择存放方式:

-
+ const emojiMetric = metricToDisplay(mediaMetric?.emojiMessages) + const voiceMetric = metricToDisplay(mediaMetric?.voiceMessages) + const imageMetric = metricToDisplay(mediaMetric?.imageMessages) + const videoMetric = metricToDisplay(mediaMetric?.videoMessages) + const supportsSnsTimeline = isSingleContactSession(contact.username) + const hasSnsCount = Object.prototype.hasOwnProperty.call(snsUserPostCounts, contact.username) + const snsStageStatus = sessionLoadTraceMap[contact.username]?.snsPostCounts?.status + const isSnsCountLoading = ( + supportsSnsTimeline && + !hasSnsCount && + ( + snsStageStatus === 'pending' || + snsStageStatus === 'loading' || + snsUserPostCountsStatus === 'loading' || + snsUserPostCountsStatus === 'idle' + ) + ) + const snsRawCount = Number(snsUserPostCounts[contact.username] || 0) + const snsCount = Number.isFinite(snsRawCount) ? Math.max(0, Math.floor(snsRawCount)) : 0 + const mutualFriendsMetric = sessionMutualFriendsMetrics[contact.username] + const hasMutualFriendsMetric = Boolean(mutualFriendsMetric) + const mutualFriendsStageStatus = sessionLoadTraceMap[contact.username]?.mutualFriends?.status + const isMutualFriendsLoading = ( + supportsSnsTimeline && + canExport && + !hasMutualFriendsMetric && + ( + mutualFriendsStageStatus === 'pending' || + mutualFriendsStageStatus === 'loading' + ) + ) + const openChatLabel = contact.type === 'friend' + ? '打开私聊' + : contact.type === 'group' + ? '打开群聊' + : '打开对话' + return ( +
+
+
+
-
-
- -
-
-
- )} - - {/* 导出前预估弹窗 */} - {showPreExportDialog && ( -
-
e.stopPropagation()}> -

导出预估

- {isLoadingStats ? ( -
- - 正在统计消息,可直接点击“直接导出”跳过等待 -
- ) : preExportStats ? ( -
-
-
- 会话数 -
{selectedSessions.size}
-
-
- 总消息 -
{preExportStats.totalMessages.toLocaleString()}
-
- {options.exportVoiceAsText && preExportStats.voiceMessages > 0 && ( - <> -
- 语音消息 -
{preExportStats.voiceMessages}
-
-
- 已有缓存 -
{preExportStats.cachedVoiceCount}
-
- - )} -
- {options.exportVoiceAsText && preExportStats.needTranscribeCount > 0 && ( -
- - {' '}需要转写 {preExportStats.needTranscribeCount} 条语音,预计耗时约 {preExportStats.estimatedSeconds > 60 - ? `${Math.round(preExportStats.estimatedSeconds / 60)} 分钟` - : `${preExportStats.estimatedSeconds} 秒` - } -
- )} - {options.exportVoiceAsText && preExportStats.voiceMessages > 0 && preExportStats.needTranscribeCount === 0 && ( -
- - {' '}所有 {preExportStats.voiceMessages} 条语音已有转写缓存,无需重新转写 -
- )} -
- ) : ( -

统计信息获取失败,仍可继续导出

- )} -
- - -
-
-
- )} - - {/* 导出进度弹窗 */} - {isExporting && ( -
-
-
- -
-

正在导出

-

{exportProgress.currentName}

- {exportProgress.phaseLabel && ( -

- {exportProgress.phaseLabel} -

- )} - {exportProgress.phaseTotal > 0 && ( -
-
-
- )} -
-
0 ? (exportProgress.current / exportProgress.total) * 100 : 0}%` }} +
+
-

- {exportProgress.current} / {exportProgress.total} 个会话 - - {elapsedSeconds > 0 && `已用 ${elapsedSeconds >= 60 ? `${Math.floor(elapsedSeconds / 60)}分${elapsedSeconds % 60}秒` : `${elapsedSeconds}秒`}`} - -

+
+
{contact.displayName}
+
{contact.alias || contact.username}
+
+
+
+
+ + {messageCountState.state === 'loading' + ? + : messageCountState.text} + +
+ {canExport && ( + + )} +
+
+ + {emojiMetric.state === 'loading' + ? + : emojiMetric.text} + +
+
+ + {voiceMetric.state === 'loading' + ? + : voiceMetric.text} + +
+
+ + {imageMetric.state === 'loading' + ? + : imageMetric.text} + +
+
+ + {videoMetric.state === 'loading' + ? + : videoMetric.text} + +
+ {shouldShowSnsColumn && ( +
+ {supportsSnsTimeline ? ( + + ) : ( + -- + )} +
+ )} + {shouldShowMutualFriendsColumn && ( +
+ {supportsSnsTimeline ? ( + + ) : ( + -- + )} +
+ )} +
+
+
+ + {hasRecentExport && {recentExportTime}} +
+ +
- )} +
+ ) + }, [ + lastExportBySession, + navigate, + nowTick, + openContactSnsTimeline, + openSessionDetail, + openSessionMutualFriendsDialog, + openSingleExport, + queuedSessionIds, + runningSessionIds, + selectedSessions, + sessionDetail?.wxid, + sessionContentMetrics, + sessionMutualFriendsMetrics, + sessionLoadTraceMap, + sessionMessageCounts, + sessionRowByUsername, + isLoading, + isSessionEnriching, + showSessionDetailPanel, + shouldShowMutualFriendsColumn, + shouldShowSnsColumn, + snsUserPostCounts, + snsUserPostCountsStatus, + setCurrentSession, + toggleSelectSession + ]) + const handleContactsListWheelCapture = useCallback((event: WheelEvent) => { + const deltaY = event.deltaY + if (!deltaY) return + const sectionTop = sessionTableSectionRef.current?.getBoundingClientRect().top ?? 0 + const sectionPinned = sectionTop <= 8 - {/* 导出结果弹窗 */} - {exportResult && ( -
-
-
- {exportResult.success ? : } -
-

{exportResult.success ? '导出完成' : '导出失败'}

- {exportResult.success ? ( -

- 成功导出 {exportResult.successCount} 个会话 - {exportResult.failCount ? `,${exportResult.failCount} 个失败` : ''} -

- ) : ( -

{exportResult.error}

- )} -
- {exportResult.success && ( - + +
+ - )} -
+
+ + { + setWriteLayout(value) + await configService.setExportWriteLayout(value) + }} + sessionNameWithTypePrefix={sessionNameWithTypePrefix} + onSessionNameWithTypePrefixChange={async (enabled) => { + setSessionNameWithTypePrefix(enabled) + await configService.setExportSessionNamePrefixEnabled(enabled) + }} + /> + +
+ +
+
+ + +
+
+ + + + {isExportDefaultsModalOpen && ( +
setIsExportDefaultsModalOpen(false)} + > +
event.stopPropagation()} + > +
+
+

更多导出设置

+
+ +
+
+ +
+
+
@@ -1046,163 +7089,1176 @@ function ExportPage() {
)} - {/* 日期选择弹窗 */} - {showDatePicker && ( -
{ setShowDatePicker(false); setShowYearMonthPicker(false) }}> -
e.stopPropagation()}> -

选择时间范围

-

- 点击选择开始和结束日期,系统会自动调整确保时间顺序正确 -

-
- - - -
-
-
setSelectingStart(true)} - > - 开始日期 - - {options.dateRange?.start?.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit' - })} - -
- -
setSelectingStart(false)} - > - 结束日期 - - {options.dateRange?.end?.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit' - })} - -
-
-
-
- - setShowYearMonthPicker(!showYearMonthPicker)}> - {calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月 - - -
- {showYearMonthPicker ? ( -
-
- - {calendarDate.getFullYear()}年 - +
+

按类型批量导出

+ +
+
+ {contentCards.map(card => { + const Icon = card.icon + const isCardStatsLoading = card.type === 'sns' + ? isSnsCardStatsLoading + : false + const isCardRunning = runningCardTypes.has(card.type) + const isPrimaryCard = card.type === 'text' + return ( +
+
+
{card.label}
+ {card.type === 'sns' && ( +
+ {isCardStatsLoading ? ( + + 统计中 + + ) : `${card.headerCount.toLocaleString()} 条`}
-
- {['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => ( + )} +
+
+ {card.stats.map((stat) => ( +
+ {stat.label} + + {isCardStatsLoading ? ( + + 统计中 + + ) : `${stat.value.toLocaleString()} ${stat.unit}`} + +
+ ))} +
+ +
+ ) + })} +
+ +
+

按会话导出

+ + +
+
+
+
+
+
+ + + +
+ +
+
+ + setSearchKeyword(event.target.value)} + placeholder={`搜索${activeTabLabel}联系人...`} + /> + {searchKeyword && ( + + )} +
+ +
+
+ +
+
+
+
+ {contactsList.length > 0 && isContactsListLoading && ( +
+ + 联系人列表同步中… +
+ )} + + {hasFilteredContacts && ( +
+ + + + + + {contactsHeaderMainLabel} + + + 总消息数 + 表情包 + 语音 + 图片 + 视频 + {shouldShowSnsColumn && ( + 朋友圈 + )} + {shouldShowMutualFriendsColumn && ( + 共同好友 + )} + + {selectedCount > 0 && ( + <> + + + + )} + +
+ )} +
+ + {contactsList.length === 0 && contactsLoadIssue ? ( +
+
+
+ + {contactsLoadIssue.title} +
+

{contactsLoadIssue.message}

+

{contactsLoadIssue.reason}

+
    +
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • +
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • +
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • +
+
+ + + +
+ {showContactsDiagnostics && ( +
{contactsDiagnosticsText}
+ )} +
+
+ ) : isContactsListLoading && contactsList.length === 0 ? ( +
+ + 联系人加载中... +
+ ) : !hasFilteredContacts ? ( +
+ 暂无联系人 +
+ ) : ( +
+ contact.username} + fixedItemHeight={76} + itemContent={renderContactRow} + rangeChanged={handleContactsRangeChanged} + atTopStateChange={setIsContactsListAtTop} + overscan={420} + /> +
+ )} +
+
+
+ + {hasFilteredContacts && hasContactsHorizontalOverflow && ( +
+
+
+ )} +
+ + {showSessionLoadDetailModal && ( +
setShowSessionLoadDetailModal(false)} + > +
event.stopPropagation()} + > +
+
+

数据加载详情

+

+ 更新时间: + {sessionLoadDetailUpdatedAt > 0 + ? new Date(sessionLoadDetailUpdatedAt).toLocaleString('zh-CN') + : '暂无'} +

+
+ +
+ +
+
+
其他页面后台任务
+
+
+ {runningNonExportTaskCount} + 个任务正在占用后台读取资源 + {nonExportBackgroundTasksUpdatedAt > 0 && ( + 最近更新时间 {new Date(nonExportBackgroundTasksUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false })} + )} +
- ))} + type="button" + className="session-load-detail-stop-btn" + onClick={handleCancelAllNonExportTasks} + disabled={cancelableNonExportTaskCount === 0} + > + 中断其他页面加载 + +
+

+ 停止请求会阻止其他页面继续发起后续统计或补算;当前已经发出的单次查询,会在返回后结束。 +

+ {nonExportBackgroundTasks.length > 0 ? ( +
+ {nonExportBackgroundTasks.map((task) => ( +
+
+
+ + {backgroundTaskSourceLabels[task.sourcePage] || backgroundTaskSourceLabels.other} + + {task.title} + + {backgroundTaskStatusLabels[task.status]} + +
+

{task.detail || '暂无详细说明'}

+
+ 开始:{formatLoadDetailTime(task.startedAt)} + 更新:{formatLoadDetailTime(task.updatedAt)} + {task.progressText && 进度:{task.progressText}} +
+
+ +
+ ))} +
+ ) : ( +
+ 当前没有检测到其他页面后台任务 +
+ )} +
+ +
+
总消息数
+
+
+ 会话类型 + 加载状态 + 开始时间 + 完成时间 +
+ {sessionLoadDetailRows.map((row) => { + const pulse = sessionLoadProgressPulseMap[`messageCount:${row.tab}`] + const isLoading = row.messageCount.statusLabel.startsWith('加载中') + return ( +
+ {row.label} + + {row.messageCount.statusLabel} + {isLoading && ( + + )} + {isLoading && pulse && pulse.delta > 0 && ( + + {formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条 + + )} + + {formatLoadDetailTime(row.messageCount.startedAt)} + {formatLoadDetailTime(row.messageCount.finishedAt)} +
+ ) + })} +
+
+ +
+
多媒体统计(表情包/图片/视频/语音)
+
+
+ 会话类型 + 加载状态 + 开始时间 + 完成时间 +
+ {sessionLoadDetailRows.map((row) => { + const pulse = sessionLoadProgressPulseMap[`mediaMetrics:${row.tab}`] + const isLoading = row.mediaMetrics.statusLabel.startsWith('加载中') + return ( +
+ {row.label} + + {row.mediaMetrics.statusLabel} + {isLoading && ( + + )} + {isLoading && pulse && pulse.delta > 0 && ( + + {formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条 + + )} + + {formatLoadDetailTime(row.mediaMetrics.startedAt)} + {formatLoadDetailTime(row.mediaMetrics.finishedAt)} +
+ ) + })} +
+
+ +
+
朋友圈条数统计
+
+
+ 会话类型 + 加载状态 + 开始时间 + 完成时间 +
+ {sessionLoadDetailRows + .filter((row) => row.tab === 'private' || row.tab === 'former_friend') + .map((row) => { + const pulse = sessionLoadProgressPulseMap[`snsPostCounts:${row.tab}`] + const isLoading = row.snsPostCounts.statusLabel.startsWith('加载中') + return ( +
+ {row.label} + + {row.snsPostCounts.statusLabel} + {isLoading && ( + + )} + {isLoading && pulse && pulse.delta > 0 && ( + + {formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条 + + )} + + {formatLoadDetailTime(row.snsPostCounts.startedAt)} + {formatLoadDetailTime(row.snsPostCounts.finishedAt)} +
+ ) + })} +
+
+ +
+
共同好友统计
+
+
+ 会话类型 + 加载状态 + 开始时间 + 完成时间 +
+ {sessionLoadDetailRows + .filter((row) => row.tab === 'private' || row.tab === 'former_friend') + .map((row) => { + const pulse = sessionLoadProgressPulseMap[`mutualFriends:${row.tab}`] + const isLoading = row.mutualFriends.statusLabel.startsWith('加载中') + return ( +
+ {row.label} + + {row.mutualFriends.statusLabel} + {isLoading && ( + + )} + {isLoading && pulse && pulse.delta > 0 && ( + + {formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}个 + + )} + + {formatLoadDetailTime(row.mutualFriends.startedAt)} + {formatLoadDetailTime(row.mutualFriends.finishedAt)} +
+ ) + })} +
+
+
+
+
+ )} + + {sessionMutualFriendsDialogTarget && sessionMutualFriendsDialogMetric && ( +
+
event.stopPropagation()} + > +
+
+
+ +
+
+

{sessionMutualFriendsDialogTarget.displayName} 的共同好友

+
+ 共 {sessionMutualFriendsDialogMetric.count.toLocaleString('zh-CN')} 人 + {sessionMutualFriendsDialogMetric.totalPosts !== null + ? ` · 已统计 ${sessionMutualFriendsDialogMetric.loadedPosts.toLocaleString('zh-CN')} / ${sessionMutualFriendsDialogMetric.totalPosts.toLocaleString('zh-CN')} 条朋友圈` + : ` · 已统计 ${sessionMutualFriendsDialogMetric.loadedPosts.toLocaleString('zh-CN')} 条朋友圈`} +
+
+
+ +
+ +
+ 打开桌面端微信,进入到这个人的朋友圈中,刷ta 的朋友圈,刷的越多这里的数据聚合越多 +
+ +
+ setSessionMutualFriendsSearch(event.target.value)} + placeholder="搜索共同好友" + aria-label="搜索共同好友" + /> +
+ +
+ {filteredSessionMutualFriendsDialogItems.length === 0 ? ( +
+ {sessionMutualFriendsSearch.trim() ? '没有匹配的共同好友' : '暂无共同好友数据'} +
+ ) : ( +
+ {filteredSessionMutualFriendsDialogItems.map((item, index) => ( +
+ {index + 1} + {item.name} + + {getSessionMutualFriendDirectionLabel(item.direction)} + + {item.totalCount.toLocaleString('zh-CN')} + {formatYmdDateFromSeconds(item.latestTime)} + + {describeSessionMutualFriendRelation(item, sessionMutualFriendsDialogTarget.displayName)} + +
+ ))} +
+ )} +
+
+
+ )} + + {showSessionDetailPanel && ( +
+ +
+ )} + + +
+
+ + {exportDialog.open && createPortal( +
+
event.stopPropagation()}> +
+
+

{exportDialog.title}

+ {isContentTextDialog && ( +
{contentTextDialogSummary}
+ )}
-
- {generateCalendar().map((day, index) => { - if (day === null) { - return
- } + +
- const currentDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day) - const isStart = options.dateRange?.start?.toDateString() === currentDate.toDateString() - const isEnd = options.dateRange?.end?.toDateString() === currentDate.toDateString() - const isInRange = options.dateRange?.start && options.dateRange?.end && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end - const today = new Date() - today.setHours(0, 0, 0, 0) - const isFuture = currentDate > today +
+ {exportDialog.scope !== 'single' && ( +
+

导出范围

+
+ {scopeLabel} + {scopeCountLabel} +
+
+ {exportDialog.sessionNames.slice(0, 20).map(name => ( + {name} + ))} + {exportDialog.sessionNames.length > 20 && ... 还有 {exportDialog.sessionNames.length - 20} 个} +
+
+ )} - return ( -
!isFuture && handleDateSelect(day)} - style={{ cursor: isFuture ? 'not-allowed' : 'pointer', opacity: isFuture ? 0.3 : 1 }} - > - {day} + {shouldShowFormatSection && ( +
+ {useCollapsedSessionFormatSelector ? ( +
+

对话文本导出格式选择

+
+ + {showSessionFormatSelect && ( +
+ {formatOptions.map(option => ( + + ))} +
+ )} +
- ) - })} + ) : ( +

{exportDialog.scope === 'sns' ? '朋友圈导出格式选择' : '对话文本导出格式选择'}

+ )} + {!isContentScopeDialog && exportDialog.scope !== 'sns' && ( +
{avatarExportStatusLabel}
+ )} + {isContentTextDialog && ( +
{avatarExportStatusLabel}
+ )} + {!useCollapsedSessionFormatSelector && ( +
+ {formatCandidateOptions.map(option => ( + + ))} +
+ )} +
+ )} + +
+
+

时间范围

+ +
- + + {shouldShowMediaSection && ( +
+

{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体内容'}

+
+ {exportDialog.scope === 'sns' ? ( + <> + + + + + ) : ( + <> + + + + + + )} +
+ {exportDialog.scope === 'sns' && ( +
全不勾选时仅导出文本信息,不导出媒体文件。
+ )} +
+ )} + + {shouldShowImageDeepSearchToggle && ( +
+
+
+

缺图时深度搜索

+
关闭后仅尝试 hardlink 命中,未命中将直接显示占位符,导出速度更快。
+
+ +
+
+ )} + + {isSessionScopeDialog && ( +
+
+
+

语音转文字

+
默认状态跟随更多导出设置中的语音转文字开关。
+
+ +
+
+ )} + + {shouldShowDisplayNameSection && ( +
+

发送者名称显示

+
+ {displayNameOptions.map(option => { + const isActive = options.displayNamePreference === option.value + return ( + + ) + })} +
+
)}
-
- - +
+ + { + setTimeRangeSelection(nextSelection) + setOptions(prev => ({ + ...prev, + useAllTime: nextSelection.useAllTime, + dateRange: cloneExportDateRange(nextSelection.dateRange) + })) + closeTimeRangeDialog() + }} + />
-
+
, + document.body )}
) diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index b9cd651..b1b0eab 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -1,7 +1,18 @@ +.group-analytics-shell { + display: flex; + flex-direction: column; + gap: 16px; + height: 100%; + min-height: 0; + overflow: hidden; +} + .group-analytics-page { display: flex; - height: 100%; + flex: 1; + min-height: 0; gap: 16px; + overflow: hidden; &.standalone { height: 100vh; @@ -189,6 +200,7 @@ flex-direction: column; min-width: 250px; max-width: 450px; + min-height: 0; background: var(--bg-secondary); border-radius: 16px; overflow: hidden; @@ -199,6 +211,7 @@ display: flex; align-items: center; min-height: 56px; + flex-shrink: 0; .search-row { flex: 1; @@ -288,6 +301,7 @@ .group-list { flex: 1; + min-height: 0; overflow-y: auto; overflow-x: hidden; @@ -460,11 +474,18 @@ display: flex; flex-direction: column; min-width: 0; + min-height: 0; background: var(--bg-secondary); border-radius: 16px; overflow: hidden; } +.detail-drag-region { + height: 16px; + flex-shrink: 0; + -webkit-app-region: drag; +} + .resize-handle { width: 4px; cursor: col-resize; @@ -487,22 +508,30 @@ .function-menu { flex: 1; + min-height: 0; display: flex; flex-direction: column; - align-items: center; - justify-content: center; - padding: 32px; + gap: 20px; + padding: 24px; + overflow-y: auto; .selected-group-info { - text-align: center; - margin-bottom: 40px; + display: flex; + align-items: center; + gap: 18px; + padding: 20px 24px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 20px; + box-shadow: var(--shadow-sm); .group-avatar.large { width: 80px; height: 80px; border-radius: 10px; overflow: hidden; - margin: 0 auto 16px; + margin: 0; + flex-shrink: 0; img { width: 100%; @@ -521,45 +550,64 @@ } } + .selected-group-meta { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + } + + .group-summary-label { + font-size: 12px; + color: var(--text-tertiary); + letter-spacing: 0.04em; + } + h2 { - font-size: 20px; + font-size: 22px; font-weight: 600; color: var(--text-primary); - margin-bottom: 4px; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } p { color: var(--text-secondary); font-size: 14px; + margin: 0; } } .function-grid { - display: flex; - flex-wrap: wrap; - gap: 20px; - justify-content: center; + width: 100%; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; } .function-card { - width: 140px; - padding: 24px 16px; - background: rgba(255, 255, 255, 0.15); + min-height: 148px; + padding: 20px 18px; + background: color-mix(in srgb, var(--card-bg) 92%, var(--bg-secondary)); border-radius: 16px; display: flex; flex-direction: column; - align-items: center; - gap: 12px; + align-items: flex-start; + justify-content: flex-start; + gap: 10px; cursor: pointer; transition: all 0.2s; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + box-shadow: var(--shadow-sm); backdrop-filter: blur(8px); - border: 1px solid rgba(255, 255, 255, 0.15); + border: 1px solid var(--border-color); + text-align: left; &:hover { transform: translateY(-2px); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); - background: rgba(255, 255, 255, 0.25); + box-shadow: var(--shadow-md); + background: color-mix(in srgb, var(--card-bg) 100%, var(--bg-hover)); } svg { @@ -567,15 +615,22 @@ } span { - font-size: 13px; - font-weight: 500; + font-size: 15px; + font-weight: 600; color: var(--text-primary); } + + small { + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); + } } } .function-content { flex: 1; + min-height: 0; display: flex; flex-direction: column; overflow: hidden; @@ -686,6 +741,7 @@ .content-body { flex: 1; + min-height: 0; overflow-y: auto; padding: 20px 24px; display: flex; @@ -777,7 +833,8 @@ } } -.member-export-panel { +.member-export-panel, +.member-messages-panel { display: flex; flex-direction: column; gap: 16px; @@ -1113,6 +1170,153 @@ cursor: not-allowed; } } + + .member-message-empty { + padding: 20px; + border-radius: 12px; + background: var(--bg-tertiary); + color: var(--text-secondary); + text-align: center; + font-size: 14px; + } + + .member-message-toolbar { + display: grid; + gap: 12px; + grid-template-columns: minmax(240px, 360px) minmax(160px, 1fr); + align-items: end; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + } + } + + .member-message-toolbar-actions { + display: flex; + justify-content: flex-end; + align-items: center; + + @media (max-width: 900px) { + justify-content: flex-start; + } + } + + .member-message-select-trigger { + border-radius: 12px; + } + + .member-message-summary-text { + align-self: flex-start; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.2; + } + + .member-message-summary-card { + min-height: 48px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; + padding: 12px 14px; + border-radius: 14px; + background: color-mix(in srgb, var(--card-bg) 88%, var(--bg-secondary)); + border: 1px solid var(--border-color); + } + + .summary-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + .summary-desc { + font-size: 12px; + color: var(--text-secondary); + } + + .member-message-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .member-message-item { + padding: 14px 16px; + border-radius: 14px; + background: color-mix(in srgb, var(--card-bg) 92%, var(--bg-secondary)); + border: 1px solid var(--border-color); + box-shadow: var(--shadow-sm); + } + + .member-message-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + } + + .member-message-time { + font-size: 12px; + color: var(--text-secondary); + } + + .member-message-type { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 9999px; + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + font-size: 11px; + font-weight: 600; + } + + .member-message-content { + color: var(--text-primary); + font-size: 14px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + } + + .member-message-actions { + display: flex; + justify-content: center; + padding-top: 4px; + } + + .member-message-load-more { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 132px; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--primary); + color: var(--primary); + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } + } + + .member-message-end { + font-size: 12px; + color: var(--text-tertiary); + } } .rankings-list { @@ -1397,6 +1601,16 @@ background: rgba(30, 30, 30, 0.95); border: 1px solid rgba(255, 255, 255, 0.1); } + + .member-export-modal { + background: rgba(30, 30, 30, 0.95); + border: 1px solid rgba(255, 255, 255, 0.1); + } + + .member-result-modal { + background: rgba(30, 30, 30, 0.95); + border: 1px solid rgba(255, 255, 255, 0.1); + } } // 成员详情弹框 @@ -1488,6 +1702,34 @@ gap: 12px; } + .member-modal-actions { + width: 100%; + margin-top: 18px; + display: flex; + justify-content: center; + } + + .member-modal-primary-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + border: none; + border-radius: 12px; + background: var(--primary); + color: #fff; + padding: 12px 16px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.92; + } + } + .detail-row { display: flex; align-items: center; @@ -1529,3 +1771,141 @@ } } } + +.member-export-modal { + background: rgba(255, 255, 255, 0.97); + border-radius: 20px; + padding: 28px; + width: min(720px, calc(100vw - 32px)); + max-height: min(760px, calc(100vh - 32px)); + overflow-y: auto; + position: relative; + backdrop-filter: blur(20px); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + + .modal-close { + position: absolute; + top: 16px; + right: 16px; + background: var(--bg-tertiary); + border: none; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + transition: all 0.15s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .member-export-modal-header { + margin-bottom: 18px; + padding-right: 40px; + + h3 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + } + + p { + margin: 6px 0 0; + font-size: 13px; + color: var(--text-secondary); + } + } + + .member-export-panel { + gap: 18px; + } +} + +.member-result-modal { + background: rgba(255, 255, 255, 0.97); + border-radius: 20px; + padding: 28px; + width: min(420px, calc(100vw - 32px)); + position: relative; + backdrop-filter: blur(20px); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + + &.success { + border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border-color)); + } + + &.error { + border: 1px solid color-mix(in srgb, #ef4444 38%, var(--border-color)); + } + + .modal-close { + position: absolute; + top: 16px; + right: 16px; + background: var(--bg-tertiary); + border: none; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + transition: all 0.15s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } +} + +.member-result-modal-body { + padding-right: 40px; + + h3 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + } + + p { + margin: 10px 0 0; + font-size: 14px; + line-height: 1.6; + color: var(--text-secondary); + word-break: break-word; + } +} + +.member-result-modal-actions { + margin-top: 24px; + display: flex; + justify-content: flex-end; +} + +.member-result-modal-btn { + min-width: 96px; + border: none; + border-radius: 12px; + background: var(--primary); + color: #fff; + padding: 10px 18px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.92; + } +} diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 05bf1af..db14c4d 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1,10 +1,18 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useLocation } from 'react-router-dom' -import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown } from 'lucide-react' +import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare } from 'lucide-react' import { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' +import ChatAnalysisHeader from '../components/ChatAnalysisHeader' import * as configService from '../services/config' +import type { Message } from '../types/models' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import './GroupAnalyticsPage.scss' interface GroupChatInfo { @@ -29,8 +37,8 @@ interface GroupMessageRank { messageCount: number } -type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats' -type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' +type AnalysisFunction = 'members' | 'memberMessages' | 'ranking' | 'activeHours' | 'mediaStats' +type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' interface MemberMessageExportOptions { format: MemberExportFormat @@ -50,14 +58,105 @@ interface MemberExportFormatOption { desc: string } +interface GroupMemberMessagesPage { + messages: Message[] + hasMore: boolean + nextCursor: number +} + +const MEMBER_MESSAGE_PAGE_SIZE = 40 + +const filterMembersByKeyword = (members: GroupMember[], keyword: string) => { + const normalizedKeyword = keyword.trim().toLowerCase() + if (!normalizedKeyword) return members + return members.filter(member => { + const fields = [ + member.username, + member.displayName, + member.nickname, + member.remark, + member.alias, + member.groupNickname + ] + return fields.some(field => String(field || '').toLowerCase().includes(normalizedKeyword)) + }) +} + +const formatMemberMessageTime = (createTime: number) => { + if (!createTime) return '-' + return new Date(createTime * 1000).toLocaleString('zh-CN', { hour12: false }) +} + +const getMemberMessageTypeLabel = (message: Message) => { + switch (message.localType) { + case 1: + return '文本' + case 3: + return '图片' + case 34: + return '语音' + case 42: + return '名片' + case 43: + return '视频' + case 47: + return '表情' + case 48: + return '位置' + case 49: + return message.fileName ? '文件' : '链接' + case 50: + return '通话' + case 10000: + case 10002: + return '系统' + default: + return `类型 ${message.localType}` + } +} + +const getMemberMessagePreview = (message: Message) => { + const text = (message.parsedContent || message.content || message.rawContent || '').trim() + switch (message.localType) { + case 1: + case 10000: + case 10002: + return text || '[空文本]' + case 3: + return text || '[图片]' + case 34: + return message.voiceDurationSeconds ? `[语音] ${message.voiceDurationSeconds} 秒` : '[语音]' + case 42: + return `[名片] ${message.cardNickname || message.cardUsername || text || '联系人名片'}` + case 43: + return text || '[视频]' + case 47: + return text || '[表情]' + case 48: + return `[位置] ${message.locationPoiname || message.locationLabel || text || '位置消息'}` + case 49: + if (message.fileName) return `[文件] ${message.fileName}` + if (message.linkTitle) return `[链接] ${message.linkTitle}` + return text || '[链接/文件]' + case 50: + return text || '[通话]' + default: + return text || `[消息类型 ${message.localType}]` + } +} + function GroupAnalyticsPage() { const location = useLocation() const [groups, setGroups] = useState([]) const [filteredGroups, setFilteredGroups] = useState([]) const [isLoading, setIsLoading] = useState(true) - const [selectedGroup, setSelectedGroup] = useState(null) + const [selectedGroupId, setSelectedGroupId] = useState(null) const [selectedFunction, setSelectedFunction] = useState(null) const [searchQuery, setSearchQuery] = useState('') + const selectedGroup = useMemo( + () => (selectedGroupId ? groups.find(group => group.username === selectedGroupId) || null : null), + [groups, selectedGroupId] + ) // 功能数据 const [members, setMembers] = useState([]) @@ -67,7 +166,11 @@ function GroupAnalyticsPage() { const [functionLoading, setFunctionLoading] = useState(false) const [isExportingMembers, setIsExportingMembers] = useState(false) const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false) - const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('') + const [memberMessages, setMemberMessages] = useState([]) + const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false) + const [memberMessagesCursor, setMemberMessagesCursor] = useState(0) + const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false) + const [selectedMessageMemberUsername, setSelectedMessageMemberUsername] = useState('') const [exportFolder, setExportFolder] = useState('') const [memberExportOptions, setMemberExportOptions] = useState({ format: 'excel', @@ -84,11 +187,17 @@ function GroupAnalyticsPage() { // 成员详情弹框 const [selectedMember, setSelectedMember] = useState(null) const [copiedField, setCopiedField] = useState(null) - const [showMemberSelect, setShowMemberSelect] = useState(false) + const [showMemberExportModal, setShowMemberExportModal] = useState(false) + const [exportResultDialog, setExportResultDialog] = useState<{ + title: string + message: string + tone: 'success' | 'error' + } | null>(null) + const [showMessageMemberSelect, setShowMessageMemberSelect] = useState(false) const [showFormatSelect, setShowFormatSelect] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) - const [memberSearchKeyword, setMemberSearchKeyword] = useState('') - const memberSelectDropdownRef = useRef(null) + const [messageMemberSearchKeyword, setMessageMemberSearchKeyword] = useState('') + const messageMemberSelectDropdownRef = useRef(null) const formatDropdownRef = useRef(null) const displayNameDropdownRef = useRef(null) @@ -119,6 +228,7 @@ function GroupAnalyticsPage() { { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' }, { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, + { value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' }, { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, @@ -133,9 +243,9 @@ function GroupAnalyticsPage() { { value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' }, { value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' } ]), []) - const selectedExportMember = useMemo( - () => members.find(member => member.username === selectedExportMemberUsername) || null, - [members, selectedExportMemberUsername] + const selectedMessageMember = useMemo( + () => members.find(member => member.username === selectedMessageMemberUsername) || null, + [members, selectedMessageMemberUsername] ) const selectedFormatOption = useMemo( () => memberExportFormatOptions.find(option => option.value === memberExportOptions.format) || memberExportFormatOptions[0], @@ -145,20 +255,26 @@ function GroupAnalyticsPage() { () => displayNameOptions.find(option => option.value === memberExportOptions.displayNamePreference) || displayNameOptions[0], [displayNameOptions, memberExportOptions.displayNamePreference] ) - const filteredMemberOptions = useMemo(() => { - const keyword = memberSearchKeyword.trim().toLowerCase() - if (!keyword) return members - return members.filter(member => { - const fields = [ - member.username, - member.displayName, - member.nickname, - member.remark, - member.alias - ] - return fields.some(field => String(field || '').toLowerCase().includes(keyword)) - }) - }, [memberSearchKeyword, members]) + const filteredMessageMemberOptions = useMemo(() => { + return filterMembersByKeyword(members, messageMemberSearchKeyword) + }, [members, messageMemberSearchKeyword]) + + const resetMemberMessageState = useCallback((clearSelection = true) => { + setMemberMessages([]) + setMemberMessagesHasMore(false) + setMemberMessagesCursor(0) + setMemberMessagesLoadingMore(false) + setShowMessageMemberSelect(false) + if (clearSelection) { + setSelectedMessageMemberUsername('') + setMessageMemberSearchKeyword('') + } + }, []) + + const getSelectedTimeRange = () => ({ + startTime: startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined, + endTime: endDate ? Math.floor(new Date(`${endDate}T23:59:59`).getTime() / 1000) : undefined + }) const loadExportPath = useCallback(async () => { try { @@ -175,15 +291,39 @@ function GroupAnalyticsPage() { }, []) const loadGroups = useCallback(async () => { + const taskId = registerBackgroundTask({ + sourcePage: 'groupAnalytics', + title: '群列表加载', + detail: '正在读取群聊列表', + progressText: '群聊列表', + cancelable: true + }) setIsLoading(true) try { const result = await window.electronAPI.groupAnalytics.getGroupChats() + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,群聊列表结果未继续写入' + }) + return + } if (result.success && result.data) { setGroups(result.data) setFilteredGroups(result.data) + finishBackgroundTask(taskId, 'completed', { + detail: `群聊列表加载完成,共 ${result.data.length} 个群`, + progressText: `${result.data.length} 个群` + }) + } else { + finishBackgroundTask(taskId, 'failed', { + detail: result.error || '加载群聊列表失败' + }) } } catch (e) { console.error(e) + finishBackgroundTask(taskId, 'failed', { + detail: String(e) + }) } finally { setIsLoading(false) } @@ -208,20 +348,20 @@ function GroupAnalyticsPage() { useEffect(() => { if (members.length === 0) { - setSelectedExportMemberUsername('') + setSelectedMessageMemberUsername('') return } - const exists = members.some(member => member.username === selectedExportMemberUsername) - if (!exists) { - setSelectedExportMemberUsername(members[0].username) + const messageExists = members.some(member => member.username === selectedMessageMemberUsername) + if (!messageExists) { + setSelectedMessageMemberUsername(members[0].username) } - }, [members, selectedExportMemberUsername]) + }, [members, selectedMessageMemberUsername]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node - if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) { - setShowMemberSelect(false) + if (showMessageMemberSelect && messageMemberSelectDropdownRef.current && !messageMemberSelectDropdownRef.current.contains(target)) { + setShowMessageMemberSelect(false) } if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) { setShowFormatSelect(false) @@ -232,7 +372,7 @@ function GroupAnalyticsPage() { } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showDisplayNameSelect, showFormatSelect, showMemberSelect]) + }, [showDisplayNameSelect, showFormatSelect, showMessageMemberSelect]) useEffect(() => { if (preselectAppliedRef.current) return @@ -242,7 +382,7 @@ function GroupAnalyticsPage() { preselectAppliedRef.current = true if (matchedGroup) { - setSelectedGroup(matchedGroup) + setSelectedGroupId(matchedGroup.username) setSelectedFunction(null) setSearchQuery('') } @@ -269,7 +409,7 @@ function GroupAnalyticsPage() { // 日期范围变化时自动刷新 useEffect(() => { - if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members' && selectedFunction !== 'memberExport') { + if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') { setDateRangeReady(false) loadFunctionData(selectedFunction) } @@ -279,9 +419,11 @@ function GroupAnalyticsPage() { const handleChange = () => { setGroups([]) setFilteredGroups([]) - setSelectedGroup(null) + setSelectedGroupId(null) setSelectedFunction(null) setMembers([]) + resetMemberMessageState() + setShowMemberExportModal(false) setRankings([]) setActiveHours({}) setMediaStats(null) @@ -290,65 +432,197 @@ function GroupAnalyticsPage() { } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, [loadExportPath, loadGroups]) + }, [loadExportPath, loadGroups, resetMemberMessageState]) const handleGroupSelect = (group: GroupChatInfo) => { - if (selectedGroup?.username !== group.username) { - setSelectedGroup(group) - setSelectedFunction(null) - setSelectedExportMemberUsername('') - setMemberSearchKeyword('') - setShowMemberSelect(false) - setShowFormatSelect(false) - setShowDisplayNameSelect(false) - } + setSelectedGroupId(group.username) + setSelectedFunction(null) + setSelectedMember(null) + setShowMemberExportModal(false) + resetMemberMessageState() + setShowFormatSelect(false) + setShowDisplayNameSelect(false) } + const loadMemberMessagesPage = async ( + targetGroup: GroupChatInfo, + memberUsername: string, + options?: { + cursor?: number + append?: boolean + startTime?: number + endTime?: number + } + ): Promise => { + const result = await window.electronAPI.groupAnalytics.getGroupMemberMessages(targetGroup.username, memberUsername, { + startTime: options?.startTime, + endTime: options?.endTime, + limit: MEMBER_MESSAGE_PAGE_SIZE, + cursor: options?.cursor && options.cursor > 0 ? options.cursor : undefined + }) + if (!result.success || !result.data) { + throw new Error(result.error || '读取成员消息失败') + } + + setMemberMessages(prev => { + if (!options?.append) return result.data!.messages + const next = [...prev] + const seen = new Set(prev.map(message => message.messageKey)) + for (const message of result.data!.messages) { + if (seen.has(message.messageKey)) continue + seen.add(message.messageKey) + next.push(message) + } + return next + }) + setMemberMessagesHasMore(result.data.hasMore) + setMemberMessagesCursor(result.data.nextCursor || 0) + return result.data + } + const handleFunctionSelect = async (func: AnalysisFunction) => { if (!selectedGroup) return setSelectedFunction(func) await loadFunctionData(func) } - const loadFunctionData = async (func: AnalysisFunction) => { - if (!selectedGroup) return + const loadFunctionData = async ( + func: AnalysisFunction, + targetGroup: GroupChatInfo | null = selectedGroup, + preferredMemberUsername?: string + ) => { + if (!targetGroup) return + const taskId = registerBackgroundTask({ + sourcePage: 'groupAnalytics', + title: `群分析:${func}`, + detail: `正在读取 ${targetGroup.displayName || targetGroup.username} 的分析数据`, + progressText: func, + cancelable: true + }) setFunctionLoading(true) - // 计算时间戳 - const startTime = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined - const endTime = endDate ? Math.floor(new Date(endDate + 'T23:59:59').getTime() / 1000) : undefined + const { startTime, endTime } = getSelectedTimeRange() try { switch (func) { case 'members': { - const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) + 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) setMembers(result.data) + finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { + detail: result.success ? `群成员列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取群成员列表失败'), + progressText: result.success ? `${result.data?.length || 0} 人` : '失败' + }) break } - case 'memberExport': { - const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) - if (result.success && result.data) setMembers(result.data) + case 'memberMessages': { + 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) { + resetMemberMessageState() + finishBackgroundTask(taskId, 'failed', { + detail: result.error || '读取群成员失败', + progressText: '失败' + }) + break + } + + setMembers(result.data) + const targetMember = result.data.find(member => member.username === (preferredMemberUsername || selectedMessageMemberUsername)) || result.data[0] + + if (!targetMember) { + resetMemberMessageState() + finishBackgroundTask(taskId, 'completed', { + detail: '当前群暂无可用成员数据', + progressText: '0 条' + }) + break + } + + setSelectedMessageMemberUsername(targetMember.username) + updateBackgroundTask(taskId, { + detail: `正在读取 ${targetMember.displayName || targetMember.username} 的发言记录`, + progressText: '消息分页' + }) + const page = await loadMemberMessagesPage(targetGroup, targetMember.username, { startTime, endTime }) + finishBackgroundTask(taskId, 'completed', { + detail: `成员消息加载完成,已读取 ${page.messages.length} 条`, + progressText: `${page.messages.length} 条` + }) break } case 'ranking': { - const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime) + updateBackgroundTask(taskId, { + detail: '正在计算群消息排行', + progressText: '消息排行' + }) + const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(targetGroup.username, 20, startTime, endTime) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息排行未继续写入' }) + return + } if (result.success && result.data) setRankings(result.data) + finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { + detail: result.success ? `群消息排行加载完成,共 ${result.data?.length || 0} 条` : (result.error || '读取群消息排行失败'), + progressText: result.success ? `${result.data?.length || 0} 条` : '失败' + }) break } case 'activeHours': { - const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime) + updateBackgroundTask(taskId, { + detail: '正在计算群活跃时段', + progressText: '活跃时段' + }) + const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(targetGroup.username, startTime, endTime) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群活跃时段未继续写入' }) + return + } if (result.success && result.data) setActiveHours(result.data.hourlyDistribution) + finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { + detail: result.success ? '群活跃时段加载完成' : (result.error || '读取群活跃时段失败'), + progressText: result.success ? '24 小时分布' : '失败' + }) break } case 'mediaStats': { - const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime) + updateBackgroundTask(taskId, { + detail: '正在统计群消息类型', + progressText: '消息类型' + }) + const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(targetGroup.username, startTime, endTime) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息类型统计未继续写入' }) + return + } if (result.success && result.data) setMediaStats(result.data) + finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { + detail: result.success ? `群消息类型统计完成,共 ${result.data?.total || 0} 条` : (result.error || '读取群消息类型统计失败'), + progressText: result.success ? `${result.data?.total || 0} 条` : '失败' + }) break } } } catch (e) { console.error(e) + finishBackgroundTask(taskId, 'failed', { + detail: String(e) + }) } finally { setFunctionLoading(false) } @@ -421,12 +695,11 @@ function GroupAnalyticsPage() { const handleRefresh = () => { if (selectedFunction) { - loadFunctionData(selectedFunction) + void loadFunctionData(selectedFunction) } } const handleDateRangeComplete = () => { - if (selectedFunction === 'memberExport') return setDateRangeReady(true) } @@ -435,6 +708,69 @@ function GroupAnalyticsPage() { setCopiedField(null) } + const openSelectedGroupChat = () => { + if (!selectedGroup) return + void window.electronAPI.window.openSessionChatWindow(selectedGroup.username, { + source: 'chat', + initialDisplayName: selectedGroup.displayName || selectedGroup.username, + initialAvatarUrl: selectedGroup.avatarUrl, + initialContactType: 'group' + }) + } + + const handleMessageMemberSelect = async (memberUsername: string) => { + if (!selectedGroup) return + setSelectedMessageMemberUsername(memberUsername) + setMessageMemberSearchKeyword('') + setShowMessageMemberSelect(false) + setFunctionLoading(true) + try { + const { startTime, endTime } = getSelectedTimeRange() + await loadMemberMessagesPage(selectedGroup, memberUsername, { startTime, endTime }) + } catch (e) { + console.error('读取成员消息失败:', e) + alert(`读取成员消息失败:${String(e)}`) + } finally { + setFunctionLoading(false) + } + } + + const handleLoadMoreMemberMessages = async () => { + if (!selectedGroup || !selectedMessageMemberUsername || !memberMessagesHasMore || memberMessagesLoadingMore) return + setMemberMessagesLoadingMore(true) + try { + const { startTime, endTime } = getSelectedTimeRange() + await loadMemberMessagesPage(selectedGroup, selectedMessageMemberUsername, { + cursor: memberMessagesCursor, + append: true, + startTime, + endTime + }) + } catch (e) { + console.error('加载更多成员消息失败:', e) + alert(`加载更多成员消息失败:${String(e)}`) + } finally { + setMemberMessagesLoadingMore(false) + } + } + + const handleViewMemberMessagesFromModal = async (member: GroupMember) => { + if (!selectedGroup) return + setSelectedMember(null) + setSelectedFunction('memberMessages') + setSelectedMessageMemberUsername(member.username) + setMessageMemberSearchKeyword('') + setShowMessageMemberSelect(false) + await loadFunctionData('memberMessages', selectedGroup, member.username) + } + + const handleOpenMemberExportModal = () => { + setShowMessageMemberSelect(false) + setShowFormatSelect(false) + setShowDisplayNameSelect(false) + setShowMemberExportModal(true) + } + const handleExportMembers = async () => { if (!selectedGroup || isExportingMembers) return setIsExportingMembers(true) @@ -452,13 +788,25 @@ function GroupAnalyticsPage() { const result = await window.electronAPI.groupAnalytics.exportGroupMembers(selectedGroup.username, saveResult.filePath) if (result.success) { - alert(`导出成功,共 ${result.count ?? members.length} 人`) + setExportResultDialog({ + title: '导出成功', + message: `共导出 ${result.count ?? members.length} 人`, + tone: 'success' + }) } else { - alert(`导出失败:${result.error || '未知错误'}`) + setExportResultDialog({ + title: '导出失败', + message: result.error || '未知错误', + tone: 'error' + }) } } catch (e) { console.error('导出群成员失败:', e) - alert(`导出失败:${String(e)}`) + setExportResultDialog({ + title: '导出失败', + message: String(e), + tone: 'error' + }) } finally { setIsExportingMembers(false) } @@ -497,8 +845,8 @@ function GroupAnalyticsPage() { } const handleExportMemberMessages = async () => { - if (!selectedGroup || !selectedExportMemberUsername || !exportFolder || isExportingMemberMessages) return - const member = members.find(item => item.username === selectedExportMemberUsername) + if (!selectedGroup || !selectedMessageMemberUsername || !exportFolder || isExportingMemberMessages) return + const member = members.find(item => item.username === selectedMessageMemberUsername) if (!member) { alert('请先选择成员') return @@ -532,13 +880,26 @@ function GroupAnalyticsPage() { } ) if (result.success && (result.successCount ?? 0) > 0) { - alert(`导出成功:${member.displayName || member.username}`) + setShowMemberExportModal(false) + setExportResultDialog({ + title: '导出成功', + message: `已导出 ${member.displayName || member.username}`, + tone: 'success' + }) } else { - alert(`导出失败:${result.error || '未知错误'}`) + setExportResultDialog({ + title: '导出失败', + message: result.error || '未知错误', + tone: 'error' + }) } } catch (e) { console.error('导出成员消息失败:', e) - alert(`导出失败:${String(e)}`) + setExportResultDialog({ + title: '导出失败', + message: String(e), + tone: 'error' + }) } finally { setIsExportingMemberMessages(false) } @@ -617,6 +978,16 @@ function GroupAnalyticsPage() {
)}
+
+ +
@@ -668,7 +1039,7 @@ function GroupAnalyticsPage() { filteredGroups.map(group => (
handleGroupSelect(group)} >
@@ -692,29 +1063,37 @@ function GroupAnalyticsPage() {
-

{selectedGroup?.displayName}

-

{selectedGroup?.memberCount} 位成员

+
+ 已选择群聊 +

{selectedGroup?.displayName}

+

{selectedGroup?.memberCount} 位成员

+
handleFunctionSelect('members')}> 群成员查看 + 查看群成员列表和基础资料
-
handleFunctionSelect('memberExport')}> - - 成员消息导出 +
handleFunctionSelect('memberMessages')}> + + 成员消息筛选与导出 + 按成员查看群聊消息,并支持导出当前成员记录
handleFunctionSelect('ranking')}> 群聊发言排行 + 统计成员发言数量排行
handleFunctionSelect('activeHours')}> 群聊活跃时段 + 查看全天活跃时间分布
handleFunctionSelect('mediaStats')}> 媒体内容统计 + 统计文本、图片、语音等类型
@@ -724,7 +1103,7 @@ function GroupAnalyticsPage() { const getFunctionTitle = () => { switch (selectedFunction) { case 'members': return '群成员查看' - case 'memberExport': return '成员消息导出' + case 'memberMessages': return '成员消息筛选与导出' case 'ranking': return '群聊发言排行' case 'activeHours': return '群聊活跃时段' case 'mediaStats': return '媒体内容统计' @@ -759,6 +1138,12 @@ function GroupAnalyticsPage() { 导出成员 )} + {selectedFunction === 'memberMessages' && ( + + )} @@ -780,58 +1165,57 @@ function GroupAnalyticsPage() { ))}
)} - {selectedFunction === 'memberExport' && ( -
+ {selectedFunction === 'memberMessages' && ( +
{members.length === 0 ? ( -
暂无群成员数据,请先刷新。
+
暂无群成员数据,请先刷新。
) : ( <> -
-
- 导出成员 +
已加载 {memberMessages.length} 条消息
+ +
+
+ 查看成员 - {showMemberSelect && ( + {showMessageMemberSelect && (
setMemberSearchKeyword(e.target.value)} + value={messageMemberSearchKeyword} + onChange={e => setMessageMemberSearchKeyword(e.target.value)} placeholder="搜索 wxid / 昵称 / 备注 / 微信号" />
- {filteredMemberOptions.length === 0 ? ( + {filteredMessageMemberOptions.length === 0 ? (
无匹配成员
) : ( - filteredMemberOptions.map(member => ( + filteredMessageMemberOptions.map(member => ( )) @@ -848,162 +1233,51 @@ function GroupAnalyticsPage() {
)}
-
- 导出格式 +
- {showFormatSelect && ( -
- {memberExportFormatOptions.map(option => ( - - ))} -
- )} -
-
- 导出目录 -
- - -
-
-
- 媒体导出 - -
-
- 媒体类型 -
- - - - -
-
-
- 附加选项 -
- - -
-
-
- 显示名称规则 - - {showDisplayNameSelect && ( -
- {displayNameOptions.map(option => ( - - ))} + {memberMessages.length === 0 ? ( +
当前时间范围内暂无该成员消息。
+ ) : ( +
+ {memberMessages.map(message => ( +
+
+ {formatMemberMessageTime(message.createTime)} + {getMemberMessageTypeLabel(message)} +
+
{getMemberMessagePreview(message)}
+ ))} +
+ )} + + {(memberMessagesHasMore || memberMessages.length > 0) && ( +
+ {memberMessagesHasMore ? ( + + ) : ( + 已显示当前可读取的全部消息 )}
-
- -
- -
+ )} )}
@@ -1069,30 +1343,244 @@ function GroupAnalyticsPage() { const renderDetailPanel = () => { + if (selectedFunction) { + return renderFunctionContent() + } + if (!selectedGroup) { return ( -
- + <> + + ) } - if (!selectedFunction) { - return renderFunctionMenu() - } - return renderFunctionContent() + return ( + <> +