mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
17
.github/ISSUE_TEMPLATE/bug.yml
vendored
17
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -19,6 +19,17 @@ body:
|
||||
required: true
|
||||
- label: 我已阅读过相关文档
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: 使用平台
|
||||
description: 选择出现问题的平台
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: severity
|
||||
attributes:
|
||||
@@ -76,9 +87,9 @@ body:
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: 操作系统
|
||||
description: 例如:Windows 11、macOS 14.2、Ubuntu 22.04
|
||||
placeholder: Windows 11
|
||||
label: 操作系统版本
|
||||
description: 例如:Windows 11 24H2、macOS 15.0、Ubuntu 24.04
|
||||
placeholder: Windows 11 24H2
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
||||
84
.github/workflows/issue-auto-assign.yml
vendored
Normal file
84
.github/workflows/issue-auto-assign.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
name: Issue Auto Assign
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, reopened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
assign-by-platform:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Assign issue by selected platform
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
ASSIGNEE_WINDOWS: ${{ vars.ISSUE_ASSIGNEE_WINDOWS }}
|
||||
ASSIGNEE_MACOS: ${{ vars.ISSUE_ASSIGNEE_MACOS }}
|
||||
ASSIGNEE_LINUX: ${{ vars.ISSUE_ASSIGNEE_LINUX || 'H3CoF6' }}
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
if (!issue) {
|
||||
core.info("No issue payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = (issue.labels || []).map((l) => l.name);
|
||||
if (!labels.includes("type: bug")) {
|
||||
core.info("Skip non-bug issue.");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = issue.body || "";
|
||||
const match = body.match(/###\s*(?:使用平台|平台|Platform)\s*\r?\n+([^\r\n]+)/i);
|
||||
if (!match) {
|
||||
core.info("No platform field found in issue body.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rawPlatform = match[1].trim().toLowerCase();
|
||||
let platformKey = null;
|
||||
if (rawPlatform.includes("windows")) platformKey = "windows";
|
||||
if (rawPlatform.includes("macos")) platformKey = "macos";
|
||||
if (rawPlatform.includes("linux")) platformKey = "linux";
|
||||
|
||||
if (!platformKey) {
|
||||
core.info(`Unrecognized platform value: ${rawPlatform}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const parseAssignees = (value) =>
|
||||
(value || "")
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const assigneeMap = {
|
||||
windows: parseAssignees(process.env.ASSIGNEE_WINDOWS),
|
||||
macos: parseAssignees(process.env.ASSIGNEE_MACOS),
|
||||
linux: parseAssignees(process.env.ASSIGNEE_LINUX),
|
||||
};
|
||||
|
||||
const candidates = assigneeMap[platformKey] || [];
|
||||
if (candidates.length === 0) {
|
||||
core.info(`No assignee configured for platform: ${platformKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = new Set((issue.assignees || []).map((a) => a.login));
|
||||
const toAdd = candidates.filter((u) => !existing.has(u));
|
||||
if (toAdd.length === 0) {
|
||||
core.info("All configured assignees already assigned.");
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
assignees: toAdd,
|
||||
});
|
||||
|
||||
core.info(`Assigned issue #${issue.number} to: ${toAdd.join(", ")}`);
|
||||
98
.github/workflows/release.yml
vendored
98
.github/workflows/release.yml
vendored
@@ -49,20 +49,41 @@ jobs:
|
||||
run: |
|
||||
npx electron-builder --mac dmg --arm64 --publish always
|
||||
|
||||
- name: Update Release Notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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 ci
|
||||
|
||||
- name: Sync version with tag
|
||||
shell: bash
|
||||
run: |
|
||||
cat <<EOF > release_notes.md
|
||||
## 更新日志
|
||||
修复了一些已知问题
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
## 查看更多日志/获取最新动态
|
||||
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||
EOF
|
||||
- name: Build Frontend & Type Check
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
||||
- name: Package and Publish Linux
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --linux --publish always
|
||||
|
||||
release:
|
||||
runs-on: windows-latest
|
||||
@@ -100,17 +121,66 @@ jobs:
|
||||
run: |
|
||||
npx electron-builder --publish always
|
||||
|
||||
- name: Update Release Notes
|
||||
update-release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- release-mac-arm64
|
||||
- release-linux
|
||||
- release
|
||||
|
||||
steps:
|
||||
- name: Generate release notes with platform download links
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
cat <<EOF > release_notes.md
|
||||
set -euo pipefail
|
||||
|
||||
TAG="$GITHUB_REF_NAME"
|
||||
REPO="$GITHUB_REPOSITORY"
|
||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||
|
||||
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
||||
|
||||
pick_asset() {
|
||||
local pattern="$1"
|
||||
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||
}
|
||||
|
||||
WINDOWS_ASSET="$(pick_asset "\\.exe$")"
|
||||
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
||||
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
|
||||
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
|
||||
LINUX_PACMAN_ASSET="$(pick_asset "\\.pacman$")"
|
||||
|
||||
build_link() {
|
||||
local name="$1"
|
||||
if [ -n "$name" ]; then
|
||||
echo "https://github.com/$REPO/releases/download/$TAG/$name"
|
||||
fi
|
||||
}
|
||||
|
||||
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
|
||||
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||
LINUX_PACMAN_URL="$(build_link "$LINUX_PACMAN_ASSET")"
|
||||
|
||||
cat > release_notes.md <<EOF
|
||||
## 更新日志
|
||||
修复了一些已知问题
|
||||
|
||||
## 查看更多日志/获取最新动态
|
||||
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||
|
||||
## 下载
|
||||
- Windows (Win10+): ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||
- macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE}
|
||||
- Linux (.deb): ${LINUX_DEB_URL:-$RELEASE_PAGE}
|
||||
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
||||
- Linux (pacman): ${LINUX_PACMAN_URL:-$RELEASE_PAGE}
|
||||
|
||||
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
|
||||
EOF
|
||||
|
||||
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
||||
|
||||
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -62,9 +62,11 @@ server/
|
||||
chatlab-format.md
|
||||
*.bak
|
||||
AGENTS.md
|
||||
AGENT.md
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.agents/
|
||||
resources/wx_send
|
||||
概述.md
|
||||
pnpm-lock.yaml
|
||||
/pnpm-workspace.yaml
|
||||
|
||||
4
.npmrc
4
.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/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WeFlow HTTP API 文档
|
||||
# WeFlow HTTP API / Push 文档
|
||||
|
||||
WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件。
|
||||
WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
||||
|
||||
## 启用方式
|
||||
|
||||
@@ -9,12 +9,15 @@ WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、
|
||||
- 默认监听地址:`127.0.0.1`
|
||||
- 默认端口:`5031`
|
||||
- 基础地址:`http://127.0.0.1:5031`
|
||||
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
||||
|
||||
## 接口列表
|
||||
|
||||
- `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`
|
||||
@@ -46,7 +49,50 @@ GET /api/v1/health
|
||||
|
||||
---
|
||||
|
||||
## 2. 获取消息
|
||||
## 2. 主动推送
|
||||
|
||||
通过 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 格式。
|
||||
|
||||
@@ -183,7 +229,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
||||
|
||||
---
|
||||
|
||||
## 3. 获取会话列表
|
||||
## 4. 获取会话列表
|
||||
|
||||
**请求**
|
||||
|
||||
@@ -228,7 +274,7 @@ GET /api/v1/sessions
|
||||
|
||||
---
|
||||
|
||||
## 4. 获取联系人列表
|
||||
## 5. 获取联系人列表
|
||||
|
||||
**请求**
|
||||
|
||||
@@ -277,7 +323,7 @@ GET /api/v1/contacts
|
||||
|
||||
---
|
||||
|
||||
## 5. 获取群成员列表
|
||||
## 6. 获取群成员列表
|
||||
|
||||
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
||||
|
||||
@@ -369,7 +415,7 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include
|
||||
|
||||
---
|
||||
|
||||
## 6. 访问导出媒体
|
||||
## 7. 访问导出媒体
|
||||
|
||||
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
|
||||
|
||||
@@ -410,7 +456,7 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
||||
|
||||
---
|
||||
|
||||
## 7. 使用示例
|
||||
## 8. 使用示例
|
||||
|
||||
### PowerShell
|
||||
|
||||
@@ -453,7 +499,7 @@ print(members)
|
||||
|
||||
---
|
||||
|
||||
## 8. 注意事项
|
||||
## 9. 注意事项
|
||||
|
||||
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
||||
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
||||
|
||||
56
electron/exportWorker.ts
Normal file
56
electron/exportWorker.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import type { ExportOptions } from './services/exportService'
|
||||
|
||||
interface ExportWorkerConfig {
|
||||
sessionIds: string[]
|
||||
outputDir: string
|
||||
options: ExportOptions
|
||||
resourcesPath?: string
|
||||
userDataPath?: string
|
||||
logEnabled?: boolean
|
||||
}
|
||||
|
||||
const config = workerData as ExportWorkerConfig
|
||||
process.env.WEFLOW_WORKER = '1'
|
||||
if (config.resourcesPath) {
|
||||
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
||||
}
|
||||
if (config.userDataPath) {
|
||||
process.env.WEFLOW_USER_DATA_PATH = config.userDataPath
|
||||
process.env.WEFLOW_CONFIG_CWD = config.userDataPath
|
||||
}
|
||||
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
|
||||
|
||||
async function run() {
|
||||
const [{ wcdbService }, { exportService }] = await Promise.all([
|
||||
import('./services/wcdbService'),
|
||||
import('./services/exportService')
|
||||
])
|
||||
|
||||
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
||||
wcdbService.setLogEnabled(config.logEnabled === true)
|
||||
|
||||
const result = await exportService.exportSessions(
|
||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||
String(config.outputDir || ''),
|
||||
config.options || { format: 'json' },
|
||||
(progress) => {
|
||||
parentPort?.postMessage({
|
||||
type: 'export:progress',
|
||||
data: progress
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
parentPort?.postMessage({
|
||||
type: 'export:result',
|
||||
data: result
|
||||
})
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
parentPort?.postMessage({
|
||||
type: 'export:error',
|
||||
error: String(error)
|
||||
})
|
||||
})
|
||||
178
electron/main.ts
178
electron/main.ts
@@ -1,6 +1,7 @@
|
||||
import './preload-env'
|
||||
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, rm, readdir } from 'fs/promises'
|
||||
@@ -16,6 +17,7 @@ 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'
|
||||
@@ -27,6 +29,7 @@ import { cloudControlService } from './services/cloudControlService'
|
||||
|
||||
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||
import { httpService } from './services/httpService'
|
||||
import { messagePushService } from './services/messagePushService'
|
||||
|
||||
|
||||
// 配置自动更新
|
||||
@@ -89,15 +92,28 @@ let onboardingWindow: BrowserWindow | null = null
|
||||
let splashWindow: BrowserWindow | null = null
|
||||
const sessionChatWindows = new Map<string, BrowserWindow>()
|
||||
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
|
||||
const keyService = process.platform === 'darwin'
|
||||
? new KeyServiceMac() as any
|
||||
: new KeyService()
|
||||
|
||||
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<string, { sessionId: string; title?: string; recordList: any[] }>()
|
||||
|
||||
type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||
|
||||
@@ -272,12 +288,18 @@ const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => {
|
||||
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')
|
||||
: (process.platform === 'darwin'
|
||||
? join(process.resourcesPath, 'icon.icns')
|
||||
: join(process.resourcesPath, 'icon.ico'))
|
||||
? join(__dirname, `../public/${iconName}`)
|
||||
: join(process.resourcesPath, iconName);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1400,
|
||||
@@ -749,6 +771,14 @@ 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')
|
||||
@@ -783,7 +813,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
||||
})
|
||||
|
||||
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')) {
|
||||
@@ -797,7 +827,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
|
||||
})
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../dist/index.html'), {
|
||||
hash: `/chat-history/${sessionId}/${messageId}`
|
||||
hash: route
|
||||
})
|
||||
}
|
||||
|
||||
@@ -965,11 +995,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
|
||||
})
|
||||
|
||||
@@ -1237,6 +1270,23 @@ 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)
|
||||
@@ -1611,7 +1661,7 @@ function registerIpcHandlers() {
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1623,10 +1673,6 @@ function registerIpcHandlers() {
|
||||
return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => {
|
||||
return chatService.execQuery(kind, path, sql)
|
||||
})
|
||||
|
||||
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
|
||||
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||
})
|
||||
@@ -1838,7 +1884,83 @@ function registerIpcHandlers() {
|
||||
}
|
||||
}
|
||||
|
||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
||||
const runMainFallback = async (reason: string) => {
|
||||
console.warn(`[fallback-export-main] ${reason}`)
|
||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
||||
}
|
||||
|
||||
const cfg = configService || new ConfigService()
|
||||
configService = cfg
|
||||
const logEnabled = cfg.get('logEnabled')
|
||||
const resourcesPath = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
const userDataPath = app.getPath('userData')
|
||||
const workerPath = join(__dirname, 'exportWorker.js')
|
||||
|
||||
const runWorker = async () => {
|
||||
return await new Promise<any>((resolve, reject) => {
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
sessionIds,
|
||||
outputDir,
|
||||
options,
|
||||
resourcesPath,
|
||||
userDataPath,
|
||||
logEnabled
|
||||
}
|
||||
})
|
||||
|
||||
let settled = false
|
||||
const finalizeResolve = (value: any) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate()
|
||||
resolve(value)
|
||||
}
|
||||
const finalizeReject = (error: Error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
worker.removeAllListeners()
|
||||
void worker.terminate()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'export:progress') {
|
||||
onProgress(msg.data as ExportProgress)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:result') {
|
||||
finalizeResolve(msg.data)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'export:error') {
|
||||
finalizeReject(new Error(String(msg.error || '导出 Worker 执行失败')))
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (error) => {
|
||||
finalizeReject(error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
if (settled) return
|
||||
if (code === 0) {
|
||||
finalizeResolve({ success: false, successCount: 0, failCount: 0, error: '导出 Worker 未返回结果' })
|
||||
} else {
|
||||
finalizeReject(new Error(`导出 Worker 异常退出: ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
return await runWorker()
|
||||
} catch (error) {
|
||||
return runMainFallback(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||
@@ -2508,6 +2630,10 @@ app.whenReady().then(async () => {
|
||||
// 注册 IPC 处理器
|
||||
updateSplashProgress(25, '正在初始化...')
|
||||
registerIpcHandlers()
|
||||
chatService.addDbMonitorListener((type, json) => {
|
||||
messagePushService.handleDbMonitorChange(type, json)
|
||||
})
|
||||
messagePushService.start()
|
||||
await delay(200)
|
||||
|
||||
// 检查配置状态
|
||||
@@ -2518,12 +2644,20 @@ app.whenReady().then(async () => {
|
||||
updateSplashProgress(30, '正在加载界面...')
|
||||
mainWindow = createWindow({ autoShow: false })
|
||||
|
||||
// 初始化系统托盘图标(与其他窗口 icon 路径逻辑保持一致)
|
||||
const resolvedTrayIcon = process.platform === 'win32'
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: (process.platform === 'darwin'
|
||||
? join(process.resourcesPath, 'icon.icns')
|
||||
: join(process.resourcesPath, 'icon.ico'))
|
||||
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')
|
||||
|
||||
@@ -113,6 +113,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||
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?: {
|
||||
@@ -215,13 +219,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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),
|
||||
@@ -244,12 +246,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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -352,7 +356,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; currentSessionId?: string; phase: string }) => void) => {
|
||||
onProgress: (callback: (payload: {
|
||||
current: number
|
||||
total: number
|
||||
currentSession: string
|
||||
currentSessionId?: string
|
||||
phase: string
|
||||
phaseProgress?: number
|
||||
phaseTotal?: number
|
||||
phaseLabel?: string
|
||||
collectedMessages?: number
|
||||
exportedMessages?: number
|
||||
estimatedTotalMessages?: number
|
||||
writtenFiles?: number
|
||||
}) => void) => {
|
||||
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||
}
|
||||
|
||||
@@ -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<Record<string, string>> {
|
||||
const map: Record<string, string> = {}
|
||||
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<string, any>[]) {
|
||||
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
|
||||
|
||||
@@ -278,16 +278,16 @@ class AnnualReportService {
|
||||
return cached || null
|
||||
}
|
||||
|
||||
const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`)
|
||||
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) {
|
||||
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<string>()
|
||||
for (const row of result.rows as Record<string, any>[]) {
|
||||
const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase()
|
||||
for (const columnName of result.columns) {
|
||||
const name = String(columnName || '').trim().toLowerCase()
|
||||
if (name) columns.add(name)
|
||||
}
|
||||
|
||||
@@ -309,10 +309,11 @@ class AnnualReportService {
|
||||
const tried = new Set<string>()
|
||||
|
||||
const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
|
||||
const sql = `SELECT MIN(${this.quoteSqlIdentifier(column)}) AS first_ts, MAX(${this.quoteSqlIdentifier(column)}) AS last_ts FROM ${this.quoteSqlIdentifier(tableName)}`
|
||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null
|
||||
const row = result.rows[0] as Record<string, any>
|
||||
const result = await wcdbService.getMessageTableTimeRange(dbPath, tableName)
|
||||
if (!result.success || !result.data) return null
|
||||
const row = result.data as Record<string, any>
|
||||
const actualColumn = String(row.column || '').trim().toLowerCase()
|
||||
if (column && actualColumn && column.toLowerCase() !== actualColumn) return null
|
||||
const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs)
|
||||
const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs)
|
||||
return { first, last }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ class CloudControlService {
|
||||
private deviceId: string = ''
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private pages: Set<string> = new Set()
|
||||
private platformVersionCache: string | null = null
|
||||
|
||||
async init() {
|
||||
this.deviceId = this.getDeviceId()
|
||||
@@ -47,7 +48,12 @@ class CloudControlService {
|
||||
}
|
||||
|
||||
private getPlatformVersion(): string {
|
||||
if (this.platformVersionCache) {
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
const os = require('os')
|
||||
const fs = require('fs')
|
||||
const platform = process.platform
|
||||
|
||||
if (platform === 'win32') {
|
||||
@@ -59,21 +65,79 @@ class CloudControlService {
|
||||
|
||||
// Windows 11 是 10.0.22000+,且主版本必须是 10.0
|
||||
if (major === 10 && minor === 0 && build >= 22000) {
|
||||
return 'Windows 11'
|
||||
this.platformVersionCache = 'Windows 11'
|
||||
return this.platformVersionCache
|
||||
} else if (major === 10) {
|
||||
return 'Windows 10'
|
||||
this.platformVersionCache = 'Windows 10'
|
||||
return this.platformVersionCache
|
||||
}
|
||||
return `Windows ${release}`
|
||||
this.platformVersionCache = `Windows ${release}`
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// `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()
|
||||
return `macOS ${macVersion}`
|
||||
this.platformVersionCache = `macOS ${macVersion}`
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
return platform
|
||||
if (platform === 'linux') {
|
||||
try {
|
||||
const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release']
|
||||
for (const filePath of osReleasePaths) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
const values: Record<string, string> = {}
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const separatorIndex = trimmed.indexOf('=')
|
||||
if (separatorIndex <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, separatorIndex)
|
||||
let value = trimmed.slice(separatorIndex + 1).trim()
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
if (values.PRETTY_NAME) {
|
||||
this.platformVersionCache = values.PRETTY_NAME
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
if (values.NAME && values.VERSION_ID) {
|
||||
this.platformVersionCache = `${values.NAME} ${values.VERSION_ID}`
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
if (values.NAME) {
|
||||
this.platformVersionCache = values.NAME
|
||||
return this.platformVersionCache
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[CloudControl] Failed to detect Linux distro version:', error)
|
||||
}
|
||||
|
||||
this.platformVersionCache = `Linux ${os.release()}`
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
this.platformVersionCache = platform
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
recordPage(pageName: string) {
|
||||
|
||||
@@ -16,7 +16,7 @@ interface ConfigSchema {
|
||||
imageXorKey: number
|
||||
imageAesKey: string
|
||||
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
||||
|
||||
exportPath?: string;
|
||||
// 缓存相关
|
||||
cachePath: string
|
||||
lastOpenedDb: string
|
||||
@@ -50,6 +50,7 @@ interface ConfigSchema {
|
||||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
notificationFilterList: string[]
|
||||
messagePushEnabled: boolean
|
||||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||
wordCloudExcludeWords: string[]
|
||||
}
|
||||
@@ -83,44 +84,71 @@ export class ConfigService {
|
||||
return ConfigService.instance
|
||||
}
|
||||
ConfigService.instance = this
|
||||
this.store = new Store<ConfigSchema>({
|
||||
const defaults: ConfigSchema = {
|
||||
dbPath: '',
|
||||
decryptKey: '',
|
||||
myWxid: '',
|
||||
onboardingDone: false,
|
||||
imageXorKey: 0,
|
||||
imageAesKey: '',
|
||||
wxidConfigs: {},
|
||||
cachePath: '',
|
||||
lastOpenedDb: '',
|
||||
lastSession: '',
|
||||
theme: 'system',
|
||||
themeId: 'cloud-dancer',
|
||||
language: 'zh-CN',
|
||||
logEnabled: false,
|
||||
llmModelPath: '',
|
||||
whisperModelName: 'base',
|
||||
whisperModelDir: '',
|
||||
whisperDownloadSource: 'tsinghua',
|
||||
autoTranscribeVoice: false,
|
||||
transcribeLanguages: ['zh'],
|
||||
exportDefaultConcurrency: 4,
|
||||
analyticsExcludedUsernames: [],
|
||||
authEnabled: false,
|
||||
authPassword: '',
|
||||
authUseHello: false,
|
||||
authHelloSecret: '',
|
||||
ignoredUpdateVersion: '',
|
||||
notificationEnabled: true,
|
||||
notificationPosition: 'top-right',
|
||||
notificationFilterMode: 'all',
|
||||
notificationFilterList: [],
|
||||
messagePushEnabled: false,
|
||||
windowCloseBehavior: 'ask',
|
||||
wordCloudExcludeWords: []
|
||||
}
|
||||
|
||||
const storeOptions: any = {
|
||||
name: 'WeFlow-config',
|
||||
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: 4,
|
||||
analyticsExcludedUsernames: [],
|
||||
authEnabled: false,
|
||||
authPassword: '',
|
||||
authUseHello: false,
|
||||
authHelloSecret: '',
|
||||
ignoredUpdateVersion: '',
|
||||
notificationEnabled: true,
|
||||
notificationPosition: 'top-right',
|
||||
notificationFilterMode: 'all',
|
||||
notificationFilterList: [],
|
||||
windowCloseBehavior: 'ask',
|
||||
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<ConfigSchema>(storeOptions)
|
||||
} catch (error) {
|
||||
const message = String((error as Error)?.message || error || '')
|
||||
if (message.includes('projectName')) {
|
||||
const fallbackOptions = {
|
||||
...storeOptions,
|
||||
projectName: 'WeFlow',
|
||||
cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd()
|
||||
}
|
||||
this.store = new Store<ConfigSchema>(fallbackOptions)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
this.migrateAuthFields()
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -230,10 +230,9 @@ class GroupAnalyticsService {
|
||||
}
|
||||
|
||||
try {
|
||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
||||
const roomResult = await wcdbService.execQuery('contact', null, `SELECT * FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`)
|
||||
if (roomResult.success && roomResult.rows && roomResult.rows.length > 0) {
|
||||
const owner = tryResolve(roomResult.rows[0])
|
||||
const roomExt = await wcdbService.getChatRoomExtBuffer(chatroomId)
|
||||
if (roomExt.success && roomExt.extBuffer) {
|
||||
const owner = tryResolve({ ext_buffer: roomExt.extBuffer })
|
||||
if (owner) return owner
|
||||
}
|
||||
} catch {
|
||||
@@ -273,13 +272,12 @@ class GroupAnalyticsService {
|
||||
}
|
||||
|
||||
try {
|
||||
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) {
|
||||
const result = await wcdbService.getChatRoomExtBuffer(chatroomId)
|
||||
if (!result.success || !result.extBuffer) {
|
||||
return nicknameMap
|
||||
}
|
||||
|
||||
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
||||
const extBuffer = this.decodeExtBuffer(result.extBuffer)
|
||||
if (!extBuffer) return nicknameMap
|
||||
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
|
||||
return nicknameMap
|
||||
@@ -583,19 +581,9 @@ class GroupAnalyticsService {
|
||||
const batch = candidates.slice(i, i + batchSize)
|
||||
if (batch.length === 0) continue
|
||||
|
||||
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
|
||||
const lightweightSql = `
|
||||
SELECT username, user_name, encrypt_username, encrypt_user_name, remark, nick_name, alias, local_type
|
||||
FROM contact
|
||||
WHERE username IN (${inList})
|
||||
`
|
||||
let result = await wcdbService.execQuery('contact', null, lightweightSql)
|
||||
if (!result.success || !result.rows) {
|
||||
// 兼容历史/变体列名,轻查询失败时回退全字段查询,避免好友标识丢失
|
||||
result = await wcdbService.execQuery('contact', null, `SELECT * FROM contact WHERE username IN (${inList})`)
|
||||
}
|
||||
if (!result.success || !result.rows) continue
|
||||
appendContactsToLookup(result.rows as Record<string, unknown>[])
|
||||
const result = await wcdbService.getContactsCompact(batch)
|
||||
if (!result.success || !result.contacts) continue
|
||||
appendContactsToLookup(result.contacts as Record<string, unknown>[])
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
@@ -774,31 +762,111 @@ 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, any>): string {
|
||||
const candidates = [
|
||||
row.sender_username,
|
||||
row.senderUsername,
|
||||
row.sender,
|
||||
row.WCDB_CT_sender_username
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
const value = String(candidate || '').trim()
|
||||
if (value) return value
|
||||
}
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
const normalizedKey = key.toLowerCase()
|
||||
if (
|
||||
normalizedKey === 'sender_username' ||
|
||||
normalizedKey === 'senderusername' ||
|
||||
normalizedKey === 'sender' ||
|
||||
normalizedKey === 'wcdb_ct_sender_username'
|
||||
) {
|
||||
const normalizedValue = String(value || '').trim()
|
||||
if (normalizedValue) return normalizedValue
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
||||
try {
|
||||
const mapped = chatService.mapRowsToMessagesForApi([row])
|
||||
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async openMemberMessageCursor(
|
||||
chatroomId: string,
|
||||
batchSize: number,
|
||||
ascending: boolean,
|
||||
startTime: number,
|
||||
endTime: number
|
||||
): Promise<{ success: boolean; cursor?: number; error?: string }> {
|
||||
const beginTimestamp = this.normalizeCursorTimestamp(startTime)
|
||||
const endTimestamp = this.normalizeCursorTimestamp(endTime)
|
||||
const liteResult = await wcdbService.openMessageCursorLite(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
if (liteResult.success && liteResult.cursor) return liteResult
|
||||
return wcdbService.openMessageCursor(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
}
|
||||
|
||||
private async collectMessagesByMember(
|
||||
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<string, boolean>()
|
||||
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||
const key = String(sender || '').trim().toLowerCase()
|
||||
if (!key) return false
|
||||
const cached = senderMatchCache.get(key)
|
||||
if (typeof cached === 'boolean') return cached
|
||||
const matched = this.isSameAccountIdentity(memberUsername, sender)
|
||||
senderMatchCache.set(key, matched)
|
||||
return matched
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const 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<string, any>[] : []
|
||||
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 }
|
||||
@@ -832,57 +900,93 @@ class GroupAnalyticsService {
|
||||
: 0
|
||||
|
||||
const matchedMessages: Message[] = []
|
||||
const batchSize = Math.max(limit * 2, 100)
|
||||
const senderMatchCache = new Map<string, boolean>()
|
||||
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||
const key = String(sender || '').trim().toLowerCase()
|
||||
if (!key) return false
|
||||
const cached = senderMatchCache.get(key)
|
||||
if (typeof cached === 'boolean') return cached
|
||||
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
|
||||
senderMatchCache.set(key, matched)
|
||||
return matched
|
||||
}
|
||||
const batchSize = Math.max(limit * 4, 240)
|
||||
let hasMore = false
|
||||
|
||||
while (matchedMessages.length < limit) {
|
||||
const batch = await chatService.getMessages(
|
||||
normalizedChatroomId,
|
||||
cursor,
|
||||
batchSize,
|
||||
startTimeValue,
|
||||
endTimeValue,
|
||||
false
|
||||
)
|
||||
if (!batch.success || !batch.messages) {
|
||||
return { success: false, error: batch.error || '获取群成员消息失败' }
|
||||
}
|
||||
const cursorResult = await this.openMemberMessageCursor(
|
||||
normalizedChatroomId,
|
||||
batchSize,
|
||||
false,
|
||||
startTimeValue,
|
||||
endTimeValue
|
||||
)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '创建群成员消息游标失败' }
|
||||
}
|
||||
|
||||
const currentMessages = batch.messages
|
||||
const nextCursor = typeof batch.nextOffset === 'number'
|
||||
? Math.max(cursor, Math.floor(batch.nextOffset))
|
||||
: cursor + currentMessages.length
|
||||
let consumedRows = 0
|
||||
const dbCursor = cursorResult.cursor
|
||||
|
||||
let overflowMatchFound = false
|
||||
for (const message of currentMessages) {
|
||||
if (!this.isSameAccountIdentity(normalizedMemberUsername, message.senderUsername)) {
|
||||
continue
|
||||
try {
|
||||
while (matchedMessages.length < limit) {
|
||||
const batch = await wcdbService.fetchMessageBatch(dbCursor)
|
||||
if (!batch.success) {
|
||||
return { success: false, error: batch.error || '获取群成员消息失败' }
|
||||
}
|
||||
|
||||
if (matchedMessages.length < limit) {
|
||||
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||
if (rows.length === 0) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
|
||||
let startIndex = 0
|
||||
if (cursor > consumedRows) {
|
||||
const skipCount = Math.min(cursor - consumedRows, rows.length)
|
||||
consumedRows += skipCount
|
||||
startIndex = skipCount
|
||||
if (startIndex >= rows.length) {
|
||||
if (!batch.hasMore) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = startIndex; index < rows.length; index += 1) {
|
||||
const row = rows[index]
|
||||
consumedRows += 1
|
||||
|
||||
const senderFromRow = this.extractRowSenderUsername(row)
|
||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const message = this.parseSingleMessageRow(row)
|
||||
if (!message) continue
|
||||
if (!matchesTargetSender(message.senderUsername)) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchedMessages.push(message)
|
||||
} else {
|
||||
overflowMatchFound = true
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
cursor = nextCursor
|
||||
|
||||
if (overflowMatchFound) {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
|
||||
if (currentMessages.length === 0 || !batch.hasMore) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
|
||||
if (matchedMessages.length >= limit) {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(dbCursor)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -103,6 +103,8 @@ class HttpService {
|
||||
private port: number = 5031
|
||||
private running: boolean = false
|
||||
private connections: Set<import('net').Socket> = new Set()
|
||||
private messagePushClients: Set<http.ServerResponse> = new Set()
|
||||
private messagePushHeartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
private connectionMutex: boolean = false
|
||||
|
||||
constructor() {
|
||||
@@ -153,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 })
|
||||
})
|
||||
@@ -165,6 +168,16 @@ class HttpService {
|
||||
async stop(): Promise<void> {
|
||||
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)
|
||||
@@ -211,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<string, unknown>): void {
|
||||
if (!this.running || this.messagePushClients.size === 0) return
|
||||
const eventBody = `event: message.new\ndata: ${JSON.stringify(payload)}\n\n`
|
||||
|
||||
for (const client of Array.from(this.messagePushClients)) {
|
||||
try {
|
||||
if (client.writableEnded || client.destroyed) {
|
||||
this.messagePushClients.delete(client)
|
||||
continue
|
||||
}
|
||||
client.write(eventBody)
|
||||
} catch {
|
||||
this.messagePushClients.delete(client)
|
||||
try { client.end() } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 请求
|
||||
*/
|
||||
@@ -233,6 +268,8 @@ 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') {
|
||||
@@ -252,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/', '')
|
||||
|
||||
@@ -55,14 +55,19 @@ 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
|
||||
}
|
||||
|
||||
export class ImageDecryptService {
|
||||
private configService = new ConfigService()
|
||||
private hardlinkCache = new Map<string, HardlinkState>()
|
||||
private resolvedCache = new Map<string, string>()
|
||||
private pending = new Map<string, Promise<DecryptResult>>()
|
||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||
@@ -106,7 +111,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
await this.ensureCacheIndexed()
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
@@ -116,7 +121,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 +129,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 +141,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 +149,53 @@ 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<DecryptResult> {
|
||||
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
|
||||
await this.ensureCacheIndexed()
|
||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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,8 +215,44 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
async preloadImageHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||
const normalizedList = Array.from(
|
||||
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
|
||||
)
|
||||
if (normalizedList.length === 0) return
|
||||
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
if (!wxid || !dbPath) return
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return
|
||||
|
||||
try {
|
||||
const ready = await this.ensureWcdbReady()
|
||||
if (!ready) return
|
||||
const requests = normalizedList.map((md5) => ({ md5, accountDir }))
|
||||
const result = await wcdbService.resolveImageHardlinkBatch(requests)
|
||||
if (!result.success || !Array.isArray(result.rows)) return
|
||||
|
||||
for (const row of result.rows) {
|
||||
const md5 = String(row?.md5 || '').trim().toLowerCase()
|
||||
if (!md5) continue
|
||||
const fullPath = String(row?.data?.full_path || '').trim()
|
||||
if (!fullPath || !existsSync(fullPath)) continue
|
||||
this.cacheDatPath(accountDir, md5, fullPath)
|
||||
const fileName = String(row?.data?.file_name || '').trim().toLowerCase()
|
||||
if (fileName) {
|
||||
this.cacheDatPath(accountDir, fileName, fullPath)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
}
|
||||
}
|
||||
|
||||
private async decryptImageInternal(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean },
|
||||
payload: DecryptImagePayload,
|
||||
cacheKey: string
|
||||
): Promise<DecryptResult> {
|
||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
|
||||
@@ -225,10 +292,9 @@ 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 }
|
||||
}
|
||||
|
||||
@@ -240,10 +306,9 @@ export class ImageDecryptService {
|
||||
// 如果要求高清但找到的是缩略图,继续解密高清图
|
||||
if (!(payload.force && !isHd)) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
|
||||
const dataUrl = this.fileToDataUrl(existing)
|
||||
const localPath = dataUrl || this.filePathToUrl(existing)
|
||||
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
}
|
||||
@@ -303,9 +368,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 })
|
||||
@@ -654,45 +721,19 @@ export class ImageDecryptService {
|
||||
|
||||
private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise<string | null> {
|
||||
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)) {
|
||||
@@ -701,57 +742,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
|
||||
@@ -759,35 +754,6 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
private async getHardlinkState(accountDir: string, hardlinkPath: string): Promise<HardlinkState> {
|
||||
const cached = this.hardlinkCache.get(hardlinkPath)
|
||||
if (cached) return cached
|
||||
|
||||
const imageResult = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1"
|
||||
)
|
||||
const dirResult = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1"
|
||||
)
|
||||
const imageTable = imageResult.success && imageResult.rows && imageResult.rows.length > 0
|
||||
? this.getRowValue(imageResult.rows[0], 'name')
|
||||
: undefined
|
||||
const dirTable = dirResult.success && dirResult.rows && dirResult.rows.length > 0
|
||||
? this.getRowValue(dirResult.rows[0], 'name')
|
||||
: undefined
|
||||
const state: HardlinkState = {
|
||||
imageTable: imageTable ? String(imageTable) : undefined,
|
||||
dirTable: dirTable ? String(dirTable) : undefined
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink state', { hardlinkPath, imageTable: state.imageTable, dirTable: state.dirTable })
|
||||
this.hardlinkCache.set(hardlinkPath, state)
|
||||
return state
|
||||
}
|
||||
|
||||
private async ensureWcdbReady(): Promise<boolean> {
|
||||
if (wcdbService.isReady()) return true
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
@@ -1572,6 +1538,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()
|
||||
@@ -1963,7 +1939,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
|
||||
|
||||
292
electron/services/keyServiceLinux.ts
Normal file
292
electron/services/keyServiceLinux.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { execFile, exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||
|
||||
export class KeyServiceLinux {
|
||||
private sudo: any
|
||||
|
||||
constructor() {
|
||||
try {
|
||||
this.sudo = require('sudo-prompt');
|
||||
} catch (e) {
|
||||
console.error('Failed to load sudo-prompt', e);
|
||||
}
|
||||
}
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const candidates: string[] = []
|
||||
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
|
||||
} else {
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
|
||||
}
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p
|
||||
}
|
||||
throw new Error('找不到 xkey_helper_linux,请检查路径')
|
||||
}
|
||||
|
||||
public async autoGetDbKey(
|
||||
timeoutMs = 60_000,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<DbKeyResult> {
|
||||
try {
|
||||
onStatus?.('正在尝试结束当前微信进程...', 0)
|
||||
await execAsync('killall -9 wechat wechat-bin xwechat').catch(() => {})
|
||||
// 稍微等待进程完全退出
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
|
||||
onStatus?.('正在尝试拉起微信...', 0)
|
||||
const startCmds = [
|
||||
'nohup wechat >/dev/null 2>&1 &',
|
||||
'nohup wechat-bin >/dev/null 2>&1 &',
|
||||
'nohup xwechat >/dev/null 2>&1 &'
|
||||
]
|
||||
for (const cmd of startCmds) execAsync(cmd).catch(() => {})
|
||||
|
||||
onStatus?.('等待微信进程出现...', 0)
|
||||
let pid = 0
|
||||
for (let i = 0; i < 15; i++) { // 最多等 15 秒
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' }))
|
||||
const pids = stdout.trim().split(/\s+/).filter(p => p)
|
||||
if (pids.length > 0) {
|
||||
pid = parseInt(pids[0], 10)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!pid) {
|
||||
const err = '未能自动启动微信,请手动启动并登录。'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
onStatus?.(`捕获到微信 PID: ${pid},准备获取密钥...`, 0)
|
||||
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
|
||||
return await this.getDbKey(pid, onStatus)
|
||||
} catch (err: any) {
|
||||
const errMsg = '自动获取微信 PID 失败: ' + err.message
|
||||
onStatus?.(errMsg, 2)
|
||||
return { success: false, error: errMsg }
|
||||
}
|
||||
}
|
||||
|
||||
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise<DbKeyResult> {
|
||||
try {
|
||||
const helperPath = this.getHelperPath()
|
||||
|
||||
onStatus?.('正在扫描数据库基址...', 0)
|
||||
const { stdout: scanOut } = await execFileAsync(helperPath, ['db_scan', pid.toString()])
|
||||
const scanRes = JSON.parse(scanOut.trim())
|
||||
|
||||
if (!scanRes.success) {
|
||||
const err = scanRes.result || '扫描失败,请确保微信已完全登录'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
const targetAddr = scanRes.target_addr
|
||||
onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0)
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const options = { name: 'WeFlow' }
|
||||
const command = `"${helperPath}" db_hook ${pid} ${targetAddr}`
|
||||
|
||||
this.sudo.exec(command, options, (error, stdout) => {
|
||||
execAsync(`kill -CONT ${pid}`).catch(() => {})
|
||||
if (error) {
|
||||
onStatus?.('授权失败或被取消', 2)
|
||||
resolve({ success: false, error: `授权失败或被取消: ${error.message}` })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const hookRes = JSON.parse((stdout as string).trim())
|
||||
if (hookRes.success) {
|
||||
onStatus?.('密钥获取成功', 1)
|
||||
resolve({ success: true, key: hookRes.key })
|
||||
} else {
|
||||
onStatus?.(hookRes.result, 2)
|
||||
resolve({ success: false, error: hookRes.result })
|
||||
}
|
||||
} catch (e) {
|
||||
onStatus?.('解析 Hook 结果失败', 2)
|
||||
resolve({ success: false, error: '解析 Hook 结果失败' })
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (err: any) {
|
||||
onStatus?.(err.message, 2)
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
public async autoGetImageKey(
|
||||
accountPath?: string,
|
||||
onProgress?: (msg: string) => void,
|
||||
wxid?: string
|
||||
): Promise<ImageKeyResult> {
|
||||
try {
|
||||
onProgress?.('正在初始化缓存扫描...');
|
||||
const helperPath = this.getHelperPath()
|
||||
const { stdout } = await execFileAsync(helperPath, ['image_local'])
|
||||
const res = JSON.parse(stdout.trim())
|
||||
if (!res.success) return { success: false, error: res.result }
|
||||
|
||||
const accounts = res.data.accounts || []
|
||||
let account = accounts.find((a: any) => a.wxid === wxid)
|
||||
if (!account && accounts.length > 0) account = accounts[0]
|
||||
|
||||
if (account && account.keys && account.keys.length > 0) {
|
||||
onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`);
|
||||
const keyObj = account.keys[0]
|
||||
return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey }
|
||||
}
|
||||
return { success: false, error: '未在缓存中找到匹配的图片密钥' }
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
public async autoGetImageKeyByMemoryScan(
|
||||
accountPath: string,
|
||||
onProgress?: (msg: string) => void
|
||||
): Promise<ImageKeyResult> {
|
||||
try {
|
||||
onProgress?.('正在查找模板文件...')
|
||||
let result = await this._findTemplateData(accountPath, 32)
|
||||
let { ciphertext, xorKey } = result
|
||||
|
||||
if (ciphertext && xorKey === null) {
|
||||
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
|
||||
result = await this._findTemplateData(accountPath, 100)
|
||||
xorKey = result.xorKey
|
||||
}
|
||||
|
||||
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
||||
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' }
|
||||
|
||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||
|
||||
// 2. 找微信 PID
|
||||
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' }))
|
||||
const pids = stdout.trim().split(/\s+/).filter(p => p)
|
||||
if (pids.length === 0) return { success: false, error: '微信未运行,无法扫描内存' }
|
||||
const pid = parseInt(pids[0], 10)
|
||||
|
||||
onProgress?.(`已找到微信进程 PID=${pid},正在提权扫描进程内存...`);
|
||||
|
||||
// 3. 将 Buffer 转换为 hex 传递给 helper
|
||||
const ciphertextHex = ciphertext.toString('hex')
|
||||
const helperPath = this.getHelperPath()
|
||||
|
||||
try {
|
||||
console.log(`[Debug] 准备执行 Helper: ${helperPath} image_mem ${pid} ${ciphertextHex}`);
|
||||
|
||||
const { stdout: memOut, stderr } = await execFileAsync(helperPath, ['image_mem', pid.toString(), ciphertextHex])
|
||||
|
||||
console.log(`[Debug] Helper stdout: ${memOut}`);
|
||||
if (stderr) {
|
||||
console.warn(`[Debug] Helper stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
if (!memOut || memOut.trim() === '') {
|
||||
return { success: false, error: 'Helper 返回为空,请检查是否有足够的权限(如需sudo)读取进程内存。' }
|
||||
}
|
||||
|
||||
const res = JSON.parse(memOut.trim())
|
||||
|
||||
if (res.success) {
|
||||
onProgress?.('内存扫描成功');
|
||||
return { success: true, xorKey, aesKey: res.key }
|
||||
}
|
||||
return { success: false, error: res.result || '未知错误' }
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('[Debug] 执行或解析 Helper 时发生崩溃:', err);
|
||||
return {
|
||||
success: false,
|
||||
error: `内存扫描失败: ${err.message}\nstdout: ${err.stdout || '无'}\nstderr: ${err.stderr || '无'}`
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `内存扫描失败: ${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
||||
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||
|
||||
// 递归收集 *_t.dat 文件
|
||||
const collect = (dir: string, results: string[], maxFiles: number) => {
|
||||
if (results.length >= maxFiles) return
|
||||
try {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (results.length >= maxFiles) break
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) collect(full, results, maxFiles)
|
||||
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
|
||||
}
|
||||
} catch { /* 忽略无权限目录 */ }
|
||||
}
|
||||
|
||||
const files: string[] = []
|
||||
collect(userDir, files, limit)
|
||||
|
||||
// 按修改时间降序
|
||||
files.sort((a, b) => {
|
||||
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
|
||||
})
|
||||
|
||||
let ciphertext: Buffer | null = null
|
||||
const tailCounts: Record<string, number> = {}
|
||||
|
||||
for (const f of files.slice(0, 32)) {
|
||||
try {
|
||||
const data = readFileSync(f)
|
||||
if (data.length < 8) continue
|
||||
|
||||
// 统计末尾两字节用于 XOR 密钥
|
||||
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
|
||||
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
|
||||
tailCounts[key] = (tailCounts[key] ?? 0) + 1
|
||||
}
|
||||
|
||||
// 提取密文(取第一个有效的)
|
||||
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
|
||||
ciphertext = data.subarray(0xF, 0x1F)
|
||||
}
|
||||
} catch { /* 忽略 */ }
|
||||
}
|
||||
|
||||
// 计算 XOR 密钥
|
||||
let xorKey: number | null = null
|
||||
let maxCount = 0
|
||||
for (const [key, count] of Object.entries(tailCounts)) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count
|
||||
const [x, y] = key.split('_').map(Number)
|
||||
const k = x ^ 0xFF
|
||||
if (k === (y ^ 0xD9)) xorKey = k
|
||||
}
|
||||
}
|
||||
|
||||
return { ciphertext, xorKey }
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export class KeyServiceMac {
|
||||
if (sipStatus.enabled) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. 重启 Mac 并按住 Command + R 进入恢复模式\n2. 打开终端,输入: csrutil disable\n3. 重启电脑'
|
||||
error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. Intel 芯片:重启 Mac 并按住 Command + R 进入恢复模式\n2. Apple 芯片(M 系列):关机后长按开机(指纹)键,选择“设置(选项)”进入恢复模式\n3. 打开终端,输入: csrutil disable\n4. 重启电脑'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
371
electron/services/messagePushService.ts
Normal file
371
electron/services/messagePushService.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { ConfigService } from './config'
|
||||
import { chatService, type ChatSession, type Message } from './chatService'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { httpService } from './httpService'
|
||||
|
||||
interface SessionBaseline {
|
||||
lastTimestamp: number
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
interface MessagePushPayload {
|
||||
event: 'message.new'
|
||||
sessionId: string
|
||||
messageKey: string
|
||||
avatarUrl?: string
|
||||
sourceName: string
|
||||
groupName?: string
|
||||
content: string | null
|
||||
}
|
||||
|
||||
const PUSH_CONFIG_KEYS = new Set([
|
||||
'messagePushEnabled',
|
||||
'dbPath',
|
||||
'decryptKey',
|
||||
'myWxid'
|
||||
])
|
||||
|
||||
class MessagePushService {
|
||||
private readonly configService: ConfigService
|
||||
private readonly sessionBaseline = new Map<string, SessionBaseline>()
|
||||
private readonly recentMessageKeys = new Map<string, number>()
|
||||
private readonly groupNicknameCache = new Map<string, { nicknames: Record<string, string>; updatedAt: number }>()
|
||||
private readonly debounceMs = 350
|
||||
private readonly recentMessageTtlMs = 10 * 60 * 1000
|
||||
private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private processing = false
|
||||
private rerunRequested = false
|
||||
private started = false
|
||||
private baselineReady = false
|
||||
|
||||
constructor() {
|
||||
this.configService = ConfigService.getInstance()
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
void this.refreshConfiguration('startup')
|
||||
}
|
||||
|
||||
handleDbMonitorChange(type: string, json: string): void {
|
||||
if (!this.started) return
|
||||
if (!this.isPushEnabled()) return
|
||||
|
||||
let payload: Record<string, unknown> | null = null
|
||||
try {
|
||||
payload = JSON.parse(json)
|
||||
} catch {
|
||||
payload = null
|
||||
}
|
||||
|
||||
const tableName = String(payload?.table || '').trim().toLowerCase()
|
||||
if (tableName && tableName !== 'session') {
|
||||
return
|
||||
}
|
||||
|
||||
this.scheduleSync()
|
||||
}
|
||||
|
||||
async handleConfigChanged(key: string): Promise<void> {
|
||||
if (!PUSH_CONFIG_KEYS.has(String(key || '').trim())) return
|
||||
if (key === 'dbPath' || key === 'decryptKey' || key === 'myWxid') {
|
||||
this.resetRuntimeState()
|
||||
chatService.close()
|
||||
}
|
||||
await this.refreshConfiguration(`config:${key}`)
|
||||
}
|
||||
|
||||
handleConfigCleared(): void {
|
||||
this.resetRuntimeState()
|
||||
chatService.close()
|
||||
}
|
||||
|
||||
private isPushEnabled(): boolean {
|
||||
return this.configService.get('messagePushEnabled') === true
|
||||
}
|
||||
|
||||
private resetRuntimeState(): void {
|
||||
this.sessionBaseline.clear()
|
||||
this.recentMessageKeys.clear()
|
||||
this.groupNicknameCache.clear()
|
||||
this.baselineReady = false
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshConfiguration(reason: string): Promise<void> {
|
||||
if (!this.isPushEnabled()) {
|
||||
this.resetRuntimeState()
|
||||
return
|
||||
}
|
||||
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) {
|
||||
console.warn(`[MessagePushService] Bootstrap connect failed (${reason}):`, connectResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
await this.bootstrapBaseline()
|
||||
}
|
||||
|
||||
private async bootstrapBaseline(): Promise<void> {
|
||||
const sessionsResult = await chatService.getSessions()
|
||||
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||
return
|
||||
}
|
||||
this.setBaseline(sessionsResult.sessions as ChatSession[])
|
||||
this.baselineReady = true
|
||||
}
|
||||
|
||||
private scheduleSync(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
}
|
||||
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = null
|
||||
void this.flushPendingChanges()
|
||||
}, this.debounceMs)
|
||||
}
|
||||
|
||||
private async flushPendingChanges(): Promise<void> {
|
||||
if (this.processing) {
|
||||
this.rerunRequested = true
|
||||
return
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
try {
|
||||
if (!this.isPushEnabled()) return
|
||||
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) {
|
||||
console.warn('[MessagePushService] Sync connect failed:', connectResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
const sessionsResult = await chatService.getSessions()
|
||||
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessions = sessionsResult.sessions as ChatSession[]
|
||||
if (!this.baselineReady) {
|
||||
this.setBaseline(sessions)
|
||||
this.baselineReady = true
|
||||
return
|
||||
}
|
||||
|
||||
const previousBaseline = new Map(this.sessionBaseline)
|
||||
this.setBaseline(sessions)
|
||||
|
||||
const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session))
|
||||
for (const session of candidates) {
|
||||
await this.pushSessionMessages(session, previousBaseline.get(session.username))
|
||||
}
|
||||
} finally {
|
||||
this.processing = false
|
||||
if (this.rerunRequested) {
|
||||
this.rerunRequested = false
|
||||
this.scheduleSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setBaseline(sessions: ChatSession[]): void {
|
||||
this.sessionBaseline.clear()
|
||||
for (const session of sessions) {
|
||||
this.sessionBaseline.set(session.username, {
|
||||
lastTimestamp: Number(session.lastTimestamp || 0),
|
||||
unreadCount: Number(session.unreadCount || 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
||||
const sessionId = String(session.username || '').trim()
|
||||
if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const summary = String(session.summary || '').trim()
|
||||
if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const lastTimestamp = Number(session.lastTimestamp || 0)
|
||||
const unreadCount = Number(session.unreadCount || 0)
|
||||
|
||||
if (!previous) {
|
||||
return unreadCount > 0 && lastTimestamp > 0
|
||||
}
|
||||
|
||||
if (lastTimestamp <= previous.lastTimestamp) {
|
||||
return false
|
||||
}
|
||||
|
||||
// unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送
|
||||
return unreadCount > previous.unreadCount
|
||||
}
|
||||
|
||||
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
|
||||
const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1)
|
||||
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
|
||||
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const message of newMessagesResult.messages) {
|
||||
const messageKey = String(message.messageKey || '').trim()
|
||||
if (!messageKey) continue
|
||||
if (message.isSend === 1) continue
|
||||
|
||||
if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (this.isRecentMessage(messageKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const payload = await this.buildPayload(session, message)
|
||||
if (!payload) continue
|
||||
|
||||
httpService.broadcastMessagePush(payload)
|
||||
this.rememberMessageKey(messageKey)
|
||||
}
|
||||
}
|
||||
|
||||
private async buildPayload(session: ChatSession, message: Message): Promise<MessagePushPayload | null> {
|
||||
const sessionId = String(session.username || '').trim()
|
||||
const messageKey = String(message.messageKey || '').trim()
|
||||
if (!sessionId || !messageKey) return null
|
||||
|
||||
const isGroup = sessionId.endsWith('@chatroom')
|
||||
const content = this.getMessageDisplayContent(message)
|
||||
|
||||
if (isGroup) {
|
||||
const groupInfo = await chatService.getContactAvatar(sessionId)
|
||||
const groupName = session.displayName || groupInfo?.displayName || sessionId
|
||||
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
|
||||
return {
|
||||
event: 'message.new',
|
||||
sessionId,
|
||||
messageKey,
|
||||
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
|
||||
groupName,
|
||||
sourceName,
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
const contactInfo = await chatService.getContactAvatar(sessionId)
|
||||
return {
|
||||
event: 'message.new',
|
||||
sessionId,
|
||||
messageKey,
|
||||
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
|
||||
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
private getMessageDisplayContent(message: Message): string | null {
|
||||
switch (Number(message.localType || 0)) {
|
||||
case 1:
|
||||
return message.rawContent || null
|
||||
case 3:
|
||||
return '[图片]'
|
||||
case 34:
|
||||
return '[语音]'
|
||||
case 43:
|
||||
return '[视频]'
|
||||
case 47:
|
||||
return '[表情]'
|
||||
case 42:
|
||||
return message.cardNickname || '[名片]'
|
||||
case 48:
|
||||
return '[位置]'
|
||||
case 49:
|
||||
return message.linkTitle || message.fileName || '[消息]'
|
||||
default:
|
||||
return message.parsedContent || message.rawContent || null
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveGroupSourceName(chatroomId: string, message: Message, session: ChatSession): Promise<string> {
|
||||
const senderUsername = String(message.senderUsername || '').trim()
|
||||
if (!senderUsername) {
|
||||
return session.lastSenderDisplayName || '未知发送者'
|
||||
}
|
||||
|
||||
const groupNicknames = await this.getGroupNicknames(chatroomId)
|
||||
const normalizedSender = this.normalizeAccountId(senderUsername)
|
||||
const nickname = groupNicknames[senderUsername]
|
||||
|| groupNicknames[senderUsername.toLowerCase()]
|
||||
|| groupNicknames[normalizedSender]
|
||||
|| groupNicknames[normalizedSender.toLowerCase()]
|
||||
|
||||
if (nickname) {
|
||||
return nickname
|
||||
}
|
||||
|
||||
const contactInfo = await chatService.getContactAvatar(senderUsername)
|
||||
return contactInfo?.displayName || senderUsername
|
||||
}
|
||||
|
||||
private async getGroupNicknames(chatroomId: string): Promise<Record<string, string>> {
|
||||
const cacheKey = String(chatroomId || '').trim()
|
||||
if (!cacheKey) return {}
|
||||
|
||||
const cached = this.groupNicknameCache.get(cacheKey)
|
||||
if (cached && Date.now() - cached.updatedAt < this.groupNicknameCacheTtlMs) {
|
||||
return cached.nicknames
|
||||
}
|
||||
|
||||
const result = await wcdbService.getGroupNicknames(cacheKey)
|
||||
const nicknames = result.success && result.nicknames ? result.nicknames : {}
|
||||
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
|
||||
return nicknames
|
||||
}
|
||||
|
||||
private normalizeAccountId(value: string): string {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return trimmed
|
||||
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return match ? match[1] : trimmed
|
||||
}
|
||||
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
private isRecentMessage(messageKey: string): boolean {
|
||||
this.pruneRecentMessageKeys()
|
||||
const timestamp = this.recentMessageKeys.get(messageKey)
|
||||
return typeof timestamp === 'number' && Date.now() - timestamp < this.recentMessageTtlMs
|
||||
}
|
||||
|
||||
private rememberMessageKey(messageKey: string): void {
|
||||
this.recentMessageKeys.set(messageKey, Date.now())
|
||||
this.pruneRecentMessageKeys()
|
||||
}
|
||||
|
||||
private pruneRecentMessageKeys(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, timestamp] of this.recentMessageKeys.entries()) {
|
||||
if (now - timestamp > this.recentMessageTtlMs) {
|
||||
this.recentMessageKeys.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const messagePushService = new MessagePushService()
|
||||
@@ -663,100 +663,24 @@ class SnsService {
|
||||
}
|
||||
|
||||
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||
const collect = (rows?: any[]): string[] => {
|
||||
if (!Array.isArray(rows)) return []
|
||||
const usernames: string[] = []
|
||||
for (const row of rows) {
|
||||
const raw = row?.user_name ?? row?.userName ?? row?.username ?? Object.values(row || {})[0]
|
||||
const username = typeof raw === 'string' ? raw.trim() : String(raw || '').trim()
|
||||
if (username) usernames.push(username)
|
||||
}
|
||||
return usernames
|
||||
const result = await wcdbService.getSnsUsernames()
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || '获取朋友圈联系人失败' }
|
||||
}
|
||||
|
||||
const primary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT DISTINCT user_name FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
|
||||
)
|
||||
const fallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT DISTINCT userName FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
|
||||
)
|
||||
|
||||
const merged = Array.from(new Set([
|
||||
...collect(primary.rows),
|
||||
...collect(fallback.rows)
|
||||
]))
|
||||
|
||||
// 任一查询成功且拿到用户名即视为成功,避免因为列名差异导致误判为空。
|
||||
if (merged.length > 0) {
|
||||
return { success: true, usernames: merged }
|
||||
}
|
||||
|
||||
// 两条查询都成功但无数据,说明确实没有朋友圈发布者。
|
||||
if (primary.success || fallback.success) {
|
||||
return { success: true, usernames: [] }
|
||||
}
|
||||
|
||||
return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人失败' }
|
||||
return { success: true, usernames: result.usernames || [] }
|
||||
}
|
||||
|
||||
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||
let totalPosts = 0
|
||||
let totalFriends = 0
|
||||
let myPosts: number | null = null
|
||||
|
||||
const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine')
|
||||
if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) {
|
||||
totalPosts = this.parseCountValue(postCountResult.rows[0])
|
||||
}
|
||||
|
||||
if (totalPosts > 0) {
|
||||
const friendCountPrimary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
|
||||
)
|
||||
if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) {
|
||||
totalFriends = this.parseCountValue(friendCountPrimary.rows[0])
|
||||
} else {
|
||||
const friendCountFallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
|
||||
)
|
||||
if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) {
|
||||
totalFriends = this.parseCountValue(friendCountFallback.rows[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedMyWxid = this.toOptionalString(myWxid)
|
||||
if (normalizedMyWxid) {
|
||||
const myPostPrimary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = ?",
|
||||
[normalizedMyWxid]
|
||||
)
|
||||
if (myPostPrimary.success && myPostPrimary.rows && myPostPrimary.rows.length > 0) {
|
||||
myPosts = this.parseCountValue(myPostPrimary.rows[0])
|
||||
} else {
|
||||
const myPostFallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = ?",
|
||||
[normalizedMyWxid]
|
||||
)
|
||||
if (myPostFallback.success && myPostFallback.rows && myPostFallback.rows.length > 0) {
|
||||
myPosts = this.parseCountValue(myPostFallback.rows[0])
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
return { totalPosts, totalFriends, myPosts }
|
||||
}
|
||||
|
||||
async getExportStats(options?: {
|
||||
|
||||
@@ -5,316 +5,553 @@ import { ConfigService } from './config'
|
||||
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<T> {
|
||||
value: T
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
interface VideoIndexEntry {
|
||||
videoPath?: string
|
||||
coverPath?: string
|
||||
thumbPath?: string
|
||||
}
|
||||
|
||||
class VideoService {
|
||||
private configService: ConfigService
|
||||
private configService: ConfigService
|
||||
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
|
||||
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
|
||||
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
|
||||
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
||||
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
|
||||
private readonly videoIndexCacheTtlMs = 90 * 1000
|
||||
private readonly maxCacheEntries = 2000
|
||||
private readonly maxIndexEntries = 6
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
}
|
||||
|
||||
private log(message: string, meta?: Record<string, unknown>): void {
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
const logDir = join(app.getPath('userData'), 'logs')
|
||||
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
|
||||
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
|
||||
} catch { }
|
||||
}
|
||||
|
||||
private readTimedCache<T>(cache: Map<string, TimedCacheEntry<T>>, key: string): T | undefined {
|
||||
const hit = cache.get(key)
|
||||
if (!hit) return undefined
|
||||
if (hit.expiresAt <= Date.now()) {
|
||||
cache.delete(key)
|
||||
return undefined
|
||||
}
|
||||
return hit.value
|
||||
}
|
||||
|
||||
private writeTimedCache<T>(
|
||||
cache: Map<string, TimedCacheEntry<T>>,
|
||||
key: string,
|
||||
value: T,
|
||||
ttlMs: number,
|
||||
maxEntries: number
|
||||
): void {
|
||||
cache.set(key, { value, expiresAt: Date.now() + ttlMs })
|
||||
if (cache.size <= maxEntries) return
|
||||
|
||||
const now = Date.now()
|
||||
for (const [cacheKey, entry] of cache) {
|
||||
if (entry.expiresAt <= now) {
|
||||
cache.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
private log(message: string, meta?: Record<string, unknown>): void {
|
||||
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<Map<string, string>> {
|
||||
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||
const normalizedList = Array.from(
|
||||
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
|
||||
)
|
||||
const resolvedMap = new Map<string, string>()
|
||||
const unresolvedSet = new Set(normalizedList)
|
||||
|
||||
for (const md5 of normalizedList) {
|
||||
const cacheKey = `${scopeKey}|${md5}`
|
||||
const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey)
|
||||
if (cached === undefined) continue
|
||||
if (cached) resolvedMap.set(md5, cached)
|
||||
unresolvedSet.delete(md5)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存目录(解密后的数据库存放位置)
|
||||
*/
|
||||
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 表查询视频文件名
|
||||
* 使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
*/
|
||||
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
|
||||
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, dbPath })
|
||||
|
||||
if (!wxid) {
|
||||
this.log('queryVideoFileName: wxid 为空')
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
if (dbPath) {
|
||||
const dbPathLower = dbPath.toLowerCase()
|
||||
const wxidLower = wxid.toLowerCase()
|
||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||
|
||||
const encryptedDbPaths: string[] = []
|
||||
if (dbPathContainsWxid) {
|
||||
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||
} else {
|
||||
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||
}
|
||||
|
||||
for (const p of encryptedDbPaths) {
|
||||
if (existsSync(p)) {
|
||||
try {
|
||||
this.log('尝试加密 hardlink.db', { path: p })
|
||||
const escapedMd5 = md5.replace(/'/g, "''")
|
||||
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
||||
const result = await wcdbService.execQuery('media', p, sql)
|
||||
|
||||
if (result.success && result.rows && result.rows.length > 0) {
|
||||
const row = result.rows[0]
|
||||
if (row?.file_name) {
|
||||
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
||||
this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 })
|
||||
return realMd5
|
||||
}
|
||||
}
|
||||
this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) })
|
||||
} catch (e) {
|
||||
this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) })
|
||||
}
|
||||
} else {
|
||||
this.log('加密 hardlink.db 不存在', { path: p })
|
||||
}
|
||||
}
|
||||
}
|
||||
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件转换为 data URL
|
||||
*/
|
||||
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
|
||||
try {
|
||||
if (!existsSync(filePath)) return undefined
|
||||
const buffer = readFileSync(filePath)
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据视频MD5获取视频文件信息
|
||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||
*/
|
||||
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
|
||||
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
|
||||
|
||||
if (!dbPath || !wxid || !videoMd5) {
|
||||
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
// 先尝试从数据库查询真正的视频文件名
|
||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
||||
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
|
||||
|
||||
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
||||
const dbPathLower = dbPath.toLowerCase()
|
||||
const wxidLower = wxid.toLowerCase()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
|
||||
let videoBaseDir: string
|
||||
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
|
||||
videoBaseDir = join(dbPath, 'msg', 'video')
|
||||
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<string | undefined> {
|
||||
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
|
||||
this.log('queryVideoFileName 开始', { md5: normalizedMd5, wxid, cleanedWxid, dbPath })
|
||||
|
||||
if (!normalizedMd5 || !wxid || !dbPath) {
|
||||
this.log('queryVideoFileName: 参数缺失', { hasMd5: !!normalizedMd5, hasWxid: !!wxid, hasDbPath: !!dbPath })
|
||||
return undefined
|
||||
}
|
||||
|
||||
const resolvedMap = await this.resolveVideoHardlinks([normalizedMd5], dbPath, wxid, cleanedWxid)
|
||||
const resolved = resolvedMap.get(normalizedMd5)
|
||||
if (resolved) {
|
||||
this.log('queryVideoFileName 命中', { input: normalizedMd5, resolved })
|
||||
return resolved
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
if (!dbPath || !wxid) return
|
||||
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件转换为 data URL
|
||||
*/
|
||||
private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined {
|
||||
try {
|
||||
if (!filePath || !existsSync(filePath)) return undefined
|
||||
const buffer = readFileSync(filePath)
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private getOrBuildVideoIndex(videoBaseDir: string): Map<string, VideoIndexEntry> {
|
||||
const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir)
|
||||
if (cached) return cached
|
||||
|
||||
const index = new Map<string, VideoIndexEntry>()
|
||||
const ensureEntry = (key: string): VideoIndexEntry => {
|
||||
let entry = index.get(key)
|
||||
if (!entry) {
|
||||
entry = {}
|
||||
index.set(key, entry)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
try {
|
||||
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||
.filter((dir) => {
|
||||
const dirPath = join(videoBaseDir, dir)
|
||||
try {
|
||||
return statSync(dirPath).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.localeCompare(a))
|
||||
|
||||
for (const yearMonth of yearMonthDirs) {
|
||||
const dirPath = join(videoBaseDir, yearMonth)
|
||||
let files: string[] = []
|
||||
try {
|
||||
// 收集所有 md5 相关属性,方便对比
|
||||
const allMd5Attrs: string[] = []
|
||||
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
|
||||
let match
|
||||
while ((match = md5Regex.exec(content)) !== null) {
|
||||
allMd5Attrs.push(match[0])
|
||||
}
|
||||
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
|
||||
|
||||
// 方法1:从 <videomsg md5="..."> 提取(收到的视频)
|
||||
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (videoMsgMd5Match) {
|
||||
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
|
||||
return videoMsgMd5Match[1].toLowerCase()
|
||||
}
|
||||
|
||||
// 方法2:从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5)
|
||||
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (rawMd5Match) {
|
||||
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
|
||||
return rawMd5Match[1].toLowerCase()
|
||||
}
|
||||
|
||||
// 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
|
||||
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (attrMatch) {
|
||||
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
|
||||
return attrMatch[1].toLowerCase()
|
||||
}
|
||||
|
||||
// 方法4:<md5>...</md5> 标签
|
||||
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||
if (md5TagMatch) {
|
||||
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
|
||||
return md5TagMatch[1].toLowerCase()
|
||||
}
|
||||
|
||||
// 方法5:兜底取 rawmd5 属性(任意位置)
|
||||
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (rawMd5Fallback) {
|
||||
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
|
||||
return rawMd5Fallback[1].toLowerCase()
|
||||
}
|
||||
|
||||
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
|
||||
} catch (e) {
|
||||
this.log('parseVideoMd5 异常', { error: String(e) })
|
||||
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<string, VideoIndexEntry>, md5: string, includePoster = true): VideoInfo | null {
|
||||
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||
if (!normalizedMd5) return null
|
||||
|
||||
const candidates = [normalizedMd5]
|
||||
const baseMd5 = normalizedMd5.replace(/_raw$/, '')
|
||||
if (baseMd5 !== normalizedMd5) {
|
||||
candidates.push(baseMd5)
|
||||
} else {
|
||||
candidates.push(`${normalizedMd5}_raw`)
|
||||
}
|
||||
|
||||
for (const key of candidates) {
|
||||
const entry = index.get(key)
|
||||
if (!entry?.videoPath) continue
|
||||
if (!existsSync(entry.videoPath)) continue
|
||||
if (!includePoster) {
|
||||
return {
|
||||
videoUrl: entry.videoPath,
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
videoUrl: entry.videoPath,
|
||||
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
|
||||
thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'),
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null {
|
||||
try {
|
||||
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||
.filter((dir) => {
|
||||
const dirPath = join(videoBaseDir, dir)
|
||||
try {
|
||||
return statSync(dirPath).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.localeCompare(a))
|
||||
|
||||
for (const yearMonth of yearMonthDirs) {
|
||||
const dirPath = join(videoBaseDir, yearMonth)
|
||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
||||
if (!existsSync(videoPath)) continue
|
||||
if (!includePoster) {
|
||||
return {
|
||||
videoUrl: videoPath,
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
|
||||
const coverPath = join(dirPath, `${baseMd5}.jpg`)
|
||||
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
||||
return {
|
||||
videoUrl: videoPath,
|
||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||
exists: true
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('fallback 扫描视频目录失败', { error: String(e) })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据视频MD5获取视频文件信息
|
||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||
*/
|
||||
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise<VideoInfo> {
|
||||
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
|
||||
const includePoster = options?.includePoster !== false
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
|
||||
this.log('getVideoInfo 开始', { videoMd5: normalizedMd5, dbPath, wxid })
|
||||
|
||||
if (!dbPath || !wxid || !normalizedMd5) {
|
||||
this.log('getVideoInfo: 参数缺失', { hasDbPath: !!dbPath, hasWxid: !!wxid, hasVideoMd5: !!normalizedMd5 })
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}`
|
||||
|
||||
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
|
||||
if (cachedInfo) return cachedInfo
|
||||
|
||||
const pending = this.pendingVideoInfo.get(cacheKey)
|
||||
if (pending) return pending
|
||||
|
||||
const task = (async (): Promise<VideoInfo> => {
|
||||
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
|
||||
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
|
||||
|
||||
if (!existsSync(videoBaseDir)) {
|
||||
const miss = { exists: false }
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return miss
|
||||
}
|
||||
|
||||
const index = this.getOrBuildVideoIndex(videoBaseDir)
|
||||
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster)
|
||||
if (indexed) {
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return indexed
|
||||
}
|
||||
|
||||
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster)
|
||||
if (fallback) {
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
return fallback
|
||||
}
|
||||
|
||||
const miss = { exists: false }
|
||||
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||
this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 })
|
||||
return miss
|
||||
})()
|
||||
|
||||
this.pendingVideoInfo.set(cacheKey, task)
|
||||
try {
|
||||
return await task
|
||||
} finally {
|
||||
this.pendingVideoInfo.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据消息内容解析视频MD5
|
||||
*/
|
||||
parseVideoMd5(content: string): string | undefined {
|
||||
if (!content) return undefined
|
||||
|
||||
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
|
||||
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
|
||||
|
||||
try {
|
||||
// 收集所有 md5 相关属性,方便对比
|
||||
const allMd5Attrs: string[] = []
|
||||
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
|
||||
let match
|
||||
while ((match = md5Regex.exec(content)) !== null) {
|
||||
allMd5Attrs.push(match[0])
|
||||
}
|
||||
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
|
||||
|
||||
// 方法1:从 <videomsg md5="..."> 提取(收到的视频)
|
||||
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (videoMsgMd5Match) {
|
||||
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
|
||||
return videoMsgMd5Match[1].toLowerCase()
|
||||
}
|
||||
|
||||
// 方法2:从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5)
|
||||
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (rawMd5Match) {
|
||||
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
|
||||
return rawMd5Match[1].toLowerCase()
|
||||
}
|
||||
|
||||
// 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
|
||||
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (attrMatch) {
|
||||
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
|
||||
return attrMatch[1].toLowerCase()
|
||||
}
|
||||
|
||||
// 方法4:<md5>...</md5> 标签
|
||||
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||
if (md5TagMatch) {
|
||||
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
|
||||
return md5TagMatch[1].toLowerCase()
|
||||
}
|
||||
|
||||
// 方法5:兜底取 rawmd5 属性(任意位置)
|
||||
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||
if (rawMd5Fallback) {
|
||||
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
|
||||
return rawMd5Fallback[1].toLowerCase()
|
||||
}
|
||||
|
||||
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
|
||||
} catch (e) {
|
||||
this.log('parseVideoMd5 异常', { error: String(e) })
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const videoService = new VideoService()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -222,6 +222,48 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageCounts', { sessionIds })
|
||||
}
|
||||
|
||||
async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
return this.callWorker('getSessionMessageCounts', { sessionIds })
|
||||
}
|
||||
|
||||
async getSessionMessageTypeStats(
|
||||
sessionId: string,
|
||||
beginTimestamp: number = 0,
|
||||
endTimestamp: number = 0
|
||||
): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('getSessionMessageTypeStats', { sessionId, beginTimestamp, endTimestamp })
|
||||
}
|
||||
|
||||
async getSessionMessageTypeStatsBatch(
|
||||
sessionIds: string[],
|
||||
options?: {
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
quickMode?: boolean
|
||||
includeGroupSenderCount?: boolean
|
||||
}
|
||||
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
|
||||
return this.callWorker('getSessionMessageTypeStatsBatch', { sessionIds, options })
|
||||
}
|
||||
|
||||
async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
return this.callWorker('getSessionMessageDateCounts', { sessionId })
|
||||
}
|
||||
|
||||
async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record<string, Record<string, number>>; error?: string }> {
|
||||
return this.callWorker('getSessionMessageDateCountsBatch', { sessionIds })
|
||||
}
|
||||
|
||||
async getMessagesByType(
|
||||
sessionId: string,
|
||||
localType: number,
|
||||
ascending = false,
|
||||
limit = 0,
|
||||
offset = 0
|
||||
): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人昵称
|
||||
*/
|
||||
@@ -287,6 +329,14 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset })
|
||||
}
|
||||
|
||||
async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> {
|
||||
return this.callWorker('getMessageTableColumns', { dbPath, tableName })
|
||||
}
|
||||
|
||||
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('getMessageTableTimeRange', { dbPath, tableName })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人详情
|
||||
*/
|
||||
@@ -301,6 +351,26 @@ export class WcdbService {
|
||||
return this.callWorker('getContactStatus', { usernames })
|
||||
}
|
||||
|
||||
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> {
|
||||
return this.callWorker('getContactTypeCounts')
|
||||
}
|
||||
|
||||
async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> {
|
||||
return this.callWorker('getContactsCompact', { usernames })
|
||||
}
|
||||
|
||||
async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||
return this.callWorker('getContactAliasMap', { usernames })
|
||||
}
|
||||
|
||||
async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record<string, boolean>; error?: string }> {
|
||||
return this.callWorker('getContactFriendFlags', { usernames })
|
||||
}
|
||||
|
||||
async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> {
|
||||
return this.callWorker('getChatRoomExtBuffer', { chatroomId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聚合统计数据
|
||||
*/
|
||||
@@ -372,7 +442,7 @@ export class WcdbService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 SQL 查询(支持参数化查询)
|
||||
* 执行 SQL 查询(仅主进程内部使用:fallback/diagnostic/低频兼容)
|
||||
*/
|
||||
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
return this.callWorker('execQuery', { kind, path, sql, params })
|
||||
@@ -417,6 +487,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<string, string>; error?: string }> {
|
||||
return this.callWorker('getHeadImageBuffers', { usernames })
|
||||
}
|
||||
|
||||
async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('resolveImageHardlink', { md5, accountDir })
|
||||
}
|
||||
|
||||
async resolveImageHardlinkBatch(
|
||||
requests: Array<{ md5: string; accountDir?: string }>
|
||||
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
|
||||
return this.callWorker('resolveImageHardlinkBatch', { requests })
|
||||
}
|
||||
|
||||
async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath })
|
||||
}
|
||||
|
||||
async resolveVideoHardlinkMd5Batch(
|
||||
requests: Array<{ md5: string; dbPath?: string }>
|
||||
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
|
||||
return this.callWorker('resolveVideoHardlinkMd5Batch', { requests })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取朋友圈
|
||||
*/
|
||||
@@ -431,6 +535,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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装朋友圈删除拦截
|
||||
*/
|
||||
|
||||
@@ -59,6 +59,24 @@ if (parentPort) {
|
||||
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
|
||||
@@ -89,12 +107,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
|
||||
@@ -149,12 +188,39 @@ if (parentPort) {
|
||||
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
|
||||
|
||||
18
package.json
18
package.json
@@ -40,6 +40,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -88,6 +89,17 @@
|
||||
],
|
||||
"icon": "public/icon.ico"
|
||||
},
|
||||
"linux": {
|
||||
"icon": "public/icon.png",
|
||||
"target": [
|
||||
"pacman",
|
||||
"deb",
|
||||
"tar.gz"
|
||||
],
|
||||
"category": "Utility",
|
||||
"executableName": "weflow",
|
||||
"synopsis": "WeFlow for Linux"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"differentialPackage": false,
|
||||
@@ -118,6 +130,10 @@
|
||||
"from": "public/icon.ico",
|
||||
"to": "icon.ico"
|
||||
},
|
||||
{
|
||||
"from": "public/icon.png",
|
||||
"to": "icon.png"
|
||||
},
|
||||
{
|
||||
"from": "electron/assets/wasm/",
|
||||
"to": "assets/wasm/"
|
||||
@@ -154,4 +170,4 @@
|
||||
],
|
||||
"icon": "resources/icon.icns"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
BIN
resources/linux/libwcdb_api.so
Executable file
BIN
resources/linux/libwcdb_api.so
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
resources/xkey_helper_linux
Executable file
BIN
resources/xkey_helper_linux
Executable file
Binary file not shown.
@@ -75,7 +75,7 @@ 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'
|
||||
@@ -660,6 +660,7 @@ function App() {
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
</RouteGuard>
|
||||
</main>
|
||||
|
||||
@@ -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 = () => {
|
||||
<div className="batch-progress-toast-header">
|
||||
<div className="batch-progress-toast-title">
|
||||
<Loader2 size={14} className="spin" />
|
||||
<span>批量转写中{sessionName ? `(${sessionName})` : ''}</span>
|
||||
<span>{taskType === 'decrypt' ? '批量解密语音中' : '批量转写中'}{sessionName ? `(${sessionName})` : ''}</span>
|
||||
</div>
|
||||
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
||||
<X size={14} />
|
||||
@@ -108,8 +109,8 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
||||
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
|
||||
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="batch-modal-header">
|
||||
<CheckCircle size={20} />
|
||||
<h3>转写完成</h3>
|
||||
{taskType === 'decrypt' ? <Mic size={20} /> : <CheckCircle size={20} />}
|
||||
<h3>{taskType === 'decrypt' ? '语音解密完成' : '转写完成'}</h3>
|
||||
</div>
|
||||
<div className="batch-modal-body">
|
||||
<div className="result-summary">
|
||||
@@ -129,7 +130,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
||||
{result.fail > 0 && (
|
||||
<div className="result-tip">
|
||||
<AlertCircle size={16} />
|
||||
<span>部分语音转写失败,可能是语音文件损坏或网络问题</span>
|
||||
<span>{taskType === 'decrypt' ? '部分语音解密失败,可能是语音未缓存或文件损坏' : '部分语音转写失败,可能是语音文件损坏或网络问题'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -137,18 +137,22 @@
|
||||
margin-top: 1px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: #16a34a;
|
||||
color: var(--primary, #07c160);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.selected .day-count {
|
||||
color: #86efac;
|
||||
color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%);
|
||||
}
|
||||
|
||||
.jump-date-popover .day-count-loading {
|
||||
position: static;
|
||||
margin-top: 1px;
|
||||
color: #22c55e;
|
||||
color: var(--primary, #07c160);
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.selected .day-count-loading {
|
||||
color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%);
|
||||
}
|
||||
|
||||
.jump-date-popover .spin {
|
||||
|
||||
@@ -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,8 +31,9 @@
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
padding: 14px 0 0;
|
||||
|
||||
&.error-item {
|
||||
padding: 12px;
|
||||
@@ -43,65 +45,70 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
.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;
|
||||
@@ -109,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;
|
||||
@@ -118,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ 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<string, string>()
|
||||
|
||||
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<ChatRecordItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -30,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 = /<!\[CDATA\[([\s\S]*?)\]\]>/g
|
||||
let cdataMatch: RegExpExecArray | null
|
||||
while ((cdataMatch = cdataRegex.exec(source)) !== null) {
|
||||
const cdataInner = cdataMatch[1] || ''
|
||||
if (!cdataInner) continue
|
||||
segments.push(cdataInner)
|
||||
const decodedInner = decodeHtmlEntities(cdataInner)
|
||||
if (decodedInner && decodedInner !== cdataInner) {
|
||||
segments.push(decodedInner)
|
||||
}
|
||||
}
|
||||
|
||||
const items: ChatRecordItem[] = []
|
||||
const dedupe = new Set<string>()
|
||||
for (const segment of segments) {
|
||||
if (!segment) continue
|
||||
const dataItems = extractTopLevelXmlElements(segment, 'dataitem')
|
||||
for (const dataItem of dataItems) {
|
||||
const item = parseChatRecordDataItem(dataItem.inner || '', dataItem.attrs || '')
|
||||
if (!item) continue
|
||||
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
|
||||
if (!dedupe.has(key)) {
|
||||
dedupe.add(key)
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 0) return items
|
||||
const fallback = parseChatRecordDataItem(source, '')
|
||||
return fallback ? [fallback] : []
|
||||
}
|
||||
|
||||
// 前端兜底解析合并转发聊天记录
|
||||
const parseChatHistory = (content: string): ChatRecordItem[] | undefined => {
|
||||
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('<recorditem')) return undefined
|
||||
|
||||
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
|
||||
if (!match) return undefined
|
||||
|
||||
const innerXml = match[1]
|
||||
const items: ChatRecordItem[] = []
|
||||
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g
|
||||
let itemMatch: RegExpExecArray | null
|
||||
const dedupe = new Set<string>()
|
||||
const recordItemRegex = /<recorditem>([\s\S]*?)<\/recorditem>/gi
|
||||
let recordItemMatch: RegExpExecArray | null
|
||||
while ((recordItemMatch = recordItemRegex.exec(decodedContent)) !== null) {
|
||||
const parsedItems = parseChatRecordContainer(recordItemMatch[1] || '')
|
||||
for (const item of parsedItems) {
|
||||
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
|
||||
if (!dedupe.has(key)) {
|
||||
dedupe.add(key)
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while ((itemMatch = itemRegex.exec(innerXml)) !== null) {
|
||||
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('<dataitem')) {
|
||||
const parsedItems = parseChatRecordContainer(decodedContent)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : undefined
|
||||
@@ -115,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)
|
||||
@@ -153,7 +329,7 @@ export default function ChatHistoryPage() {
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [params.sessionId, params.messageId, location.pathname])
|
||||
}, [ids.mid, ids.sid, location.pathname, payloadId])
|
||||
|
||||
return (
|
||||
<div className="chat-history-page">
|
||||
@@ -168,7 +344,7 @@ export default function ChatHistoryPage() {
|
||||
) : (
|
||||
recordList.map((item, i) => (
|
||||
<ErrorBoundary key={i} fallback={<div className="history-item error-item">消息解析失败</div>}>
|
||||
<HistoryItem item={item} />
|
||||
<HistoryItem item={item} sessionId={ids.sid} />
|
||||
</ErrorBoundary>
|
||||
))
|
||||
)}
|
||||
@@ -177,9 +353,198 @@ export default function ChatHistoryPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
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<string | undefined>(() => forwardedImageCache.get(cacheKey))
|
||||
const [loading, setLoading] = useState(!forwardedImageCache.has(cacheKey))
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (localPath || error) return
|
||||
|
||||
let cancelled = false
|
||||
const candidateMd5s = Array.from(new Set([
|
||||
item.thumbfullmd5,
|
||||
item.fullmd5,
|
||||
item.md5
|
||||
].filter(Boolean) as string[]))
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
|
||||
for (const imageMd5 of candidateMd5s) {
|
||||
const cached = await window.electronAPI.image.resolveCache({ imageMd5 })
|
||||
if (cached.success && cached.localPath) {
|
||||
if (!cancelled) {
|
||||
forwardedImageCache.set(cacheKey, cached.localPath)
|
||||
setLocalPath(cached.localPath)
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for (const imageMd5 of candidateMd5s) {
|
||||
const decrypted = await window.electronAPI.image.decrypt({ imageMd5 })
|
||||
if (decrypted.success && decrypted.localPath) {
|
||||
if (!cancelled) {
|
||||
forwardedImageCache.set(cacheKey, decrypted.localPath)
|
||||
setLocalPath(decrypted.localPath)
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId && item.srcMsgLocalid) {
|
||||
const fallback = await window.electronAPI.chat.getImageData(sessionId, String(item.srcMsgLocalid))
|
||||
if (fallback.success && fallback.data) {
|
||||
const dataUrl = `data:${detectImageMimeFromBase64(fallback.data)};base64,${fallback.data}`
|
||||
if (!cancelled) {
|
||||
forwardedImageCache.set(cacheKey, dataUrl)
|
||||
setLocalPath(dataUrl)
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const remoteSrc = item.dataurl || item.datathumburl || item.datacdnurl
|
||||
if (remoteSrc && /^https?:\/\//i.test(remoteSrc)) {
|
||||
if (!cancelled) {
|
||||
setLocalPath(remoteSrc)
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setError(true)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load().catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [cacheKey, error, item.dataurl, item.datacdnurl, item.datathumburl, item.fullmd5, item.md5, item.messageuuid, item.srcMsgLocalid, item.thumbfullmd5, localPath, sessionId])
|
||||
|
||||
if (localPath) {
|
||||
return (
|
||||
<div className="media-content">
|
||||
<img src={localPath} alt="图片" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="media-tip">图片加载中...</div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="media-tip">图片未索引到本地缓存</div>
|
||||
}
|
||||
|
||||
return <div className="media-placeholder">[图片]</div>
|
||||
}
|
||||
|
||||
function NestedChatRecordCard({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) {
|
||||
const previewItems = (item.chatRecordList || []).slice(0, 3)
|
||||
const title = normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录'
|
||||
const description = normalizeChatRecordText(item.chatRecordDesc) || normalizeChatRecordText(item.datadesc)
|
||||
const canOpen = Boolean(sessionId && item.chatRecordList && item.chatRecordList.length > 0)
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!canOpen) return
|
||||
window.electronAPI.window.openChatHistoryPayloadWindow({
|
||||
sessionId,
|
||||
title,
|
||||
recordList: item.chatRecordList || []
|
||||
}).catch(() => { })
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`nested-chat-record-card${canOpen ? ' clickable' : ''}`}
|
||||
onClick={handleOpen}
|
||||
disabled={!canOpen}
|
||||
title={canOpen ? '点击打开聊天记录' : undefined}
|
||||
>
|
||||
<div className="nested-chat-record-title">{title}</div>
|
||||
{previewItems.length > 0 ? (
|
||||
<div className="nested-chat-record-list">
|
||||
{previewItems.map((previewItem, index) => (
|
||||
<div key={`${previewItem.messageuuid || previewItem.srcMsgLocalid || index}`} className="nested-chat-record-line">
|
||||
{getChatRecordPreviewText(previewItem)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : description ? (
|
||||
<div className="nested-chat-record-list">
|
||||
<div className="nested-chat-record-line">{description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="nested-chat-record-footer">聊天记录</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryItem({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) {
|
||||
// sourcetime 在合并转发里有两种格式:
|
||||
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
|
||||
let time = ''
|
||||
@@ -191,31 +556,18 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||
}
|
||||
}
|
||||
|
||||
const senderDisplayName = item.sourcename ?? '未知发送者'
|
||||
|
||||
const renderContent = () => {
|
||||
if (item.datatype === 1) {
|
||||
// 文本消息
|
||||
return <div className="text-content">{item.datadesc || ''}</div>
|
||||
}
|
||||
if (item.datatype === 3) {
|
||||
// 图片
|
||||
const src = item.datathumburl || item.datacdnurl
|
||||
if (src) {
|
||||
return (
|
||||
<div className="media-content">
|
||||
{imageError ? (
|
||||
<div className="media-tip">图片无法加载</div>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
alt="图片"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className="media-placeholder">[图片]</div>
|
||||
if (item.datatype === 2 || item.datatype === 3) {
|
||||
return <ForwardedImage item={item} sessionId={sessionId} />
|
||||
}
|
||||
if (item.datatype === 17) {
|
||||
return <NestedChatRecordCard item={item} sessionId={sessionId} />
|
||||
}
|
||||
if (item.datatype === 43) {
|
||||
return <div className="media-placeholder">[视频] {item.datatitle}</div>
|
||||
@@ -229,21 +581,20 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||
|
||||
return (
|
||||
<div className="history-item">
|
||||
<div className="avatar">
|
||||
{item.sourceheadurl ? (
|
||||
<img src={item.sourceheadurl} alt="" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="avatar-placeholder">
|
||||
{item.sourcename?.slice(0, 1)}
|
||||
</div>
|
||||
)}
|
||||
<div className="history-avatar">
|
||||
<Avatar
|
||||
src={item.sourceheadurl}
|
||||
name={senderDisplayName}
|
||||
size={36}
|
||||
className="avatar-inner"
|
||||
/>
|
||||
</div>
|
||||
<div className="content-wrapper">
|
||||
<div className="header">
|
||||
<span className="sender">{item.sourcename || '未知发送者'}</span>
|
||||
<span className="sender">{senderDisplayName}</span>
|
||||
<span className="time">{time}</span>
|
||||
</div>
|
||||
<div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}>
|
||||
<div className={`bubble ${(item.datatype === 2 || item.datatype === 3) ? 'image-bubble' : ''}`}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -566,7 +566,8 @@
|
||||
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;
|
||||
@@ -600,7 +601,8 @@
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
@@ -1748,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;
|
||||
@@ -1777,6 +1780,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.message-virtuoso {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading-messages.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1834,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);
|
||||
@@ -1851,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;
|
||||
}
|
||||
|
||||
@@ -1894,6 +1901,8 @@
|
||||
.message-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 16px;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
&.sent {
|
||||
@@ -2060,6 +2069,10 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.emoji-message-wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.emoji-loading {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
@@ -3294,13 +3307,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3374,75 +3463,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天记录消息 - 复用 link-message 基础样式
|
||||
.chat-record-message {
|
||||
cursor: pointer;
|
||||
|
||||
.link-header {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-record-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-record-meta-line {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 70px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-record-item {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.chat-record-more {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.chat-record-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chat-record-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: var(--primary-gradient);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 小程序消息
|
||||
.miniapp-message {
|
||||
display: flex;
|
||||
@@ -3539,23 +3559,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);
|
||||
@@ -3563,14 +3578,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);
|
||||
@@ -3651,11 +3662,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;
|
||||
}
|
||||
@@ -3679,7 +3690,7 @@
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
svg {
|
||||
color: var(--primary-color);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -3700,6 +3711,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);
|
||||
@@ -3717,7 +3758,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;
|
||||
@@ -3726,7 +3767,7 @@
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary-color);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3759,9 +3800,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 {
|
||||
@@ -3804,7 +3850,7 @@
|
||||
.value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.batch-concurrency-field {
|
||||
@@ -3930,7 +3976,7 @@
|
||||
|
||||
&.btn-primary,
|
||||
&.batch-transcribe-start-btn {
|
||||
background: var(--primary-color);
|
||||
background: var(--primary);
|
||||
color: #000;
|
||||
|
||||
&:hover {
|
||||
@@ -4177,43 +4223,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天记录消息外观
|
||||
.chat-record-message {
|
||||
background: var(--card-inner-bg) !important;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
}
|
||||
|
||||
.chat-record-list {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.6;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
.chat-record-item {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.source-name {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-record-more {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 公众号文章图文消息外观 (大图模式)
|
||||
.official-message {
|
||||
display: flex;
|
||||
@@ -4800,6 +4809,18 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局消息搜索结果面板
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type UIEvent, type WheelEvent } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
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'
|
||||
@@ -104,6 +105,16 @@ interface TaskProgress {
|
||||
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'
|
||||
@@ -166,7 +177,7 @@ interface ExportDialogState {
|
||||
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 = 12
|
||||
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
|
||||
@@ -254,7 +265,17 @@ const createEmptyProgress = (): TaskProgress => ({
|
||||
phase: '',
|
||||
phaseLabel: '',
|
||||
phaseProgress: 0,
|
||||
phaseTotal: 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 => ({
|
||||
@@ -1280,6 +1301,45 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
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
|
||||
@@ -1300,9 +1360,11 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
/>
|
||||
</div>
|
||||
<div className="task-progress-text">
|
||||
{completedSessionTotal > 0
|
||||
? `已完成 ${completedSessionCount} / ${completedSessionTotal}`
|
||||
: '处理中'}
|
||||
{`${sessionProgressLabel} · ${effectiveMessageProgressLabel}`}
|
||||
{phaseMetricLabel ? ` · ${phaseMetricLabel}` : ''}
|
||||
{mediaLiveMetricLabel ? ` · ${mediaLiveMetricLabel}` : ''}
|
||||
{mediaCacheMetricLabel ? ` · ${mediaCacheMetricLabel}` : ''}
|
||||
{mediaDedupMetricLabel ? ` · ${mediaDedupMetricLabel}` : ''}
|
||||
{task.status === 'running' && currentSessionRatio !== null
|
||||
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)`
|
||||
: ''}
|
||||
@@ -1387,6 +1449,8 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
})
|
||||
|
||||
function ExportPage() {
|
||||
const navigate = useNavigate()
|
||||
const { setCurrentSession } = useChatStore()
|
||||
const location = useLocation()
|
||||
const isExportRoute = location.pathname === '/export'
|
||||
|
||||
@@ -2787,6 +2851,7 @@ function ExportPage() {
|
||||
}, [])
|
||||
|
||||
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) {
|
||||
@@ -2976,6 +3041,7 @@ function ExportPage() {
|
||||
}, [])
|
||||
|
||||
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) {
|
||||
@@ -3025,13 +3091,27 @@ function ExportPage() {
|
||||
const runSessionMediaMetricWorker = useCallback(async (runId: number) => {
|
||||
if (sessionMediaMetricWorkerRunningRef.current) return
|
||||
sessionMediaMetricWorkerRunningRef.current = true
|
||||
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number, stage: string): Promise<T> => {
|
||||
let timer: number | null = null
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, 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 (isLoadingSessionCountsRef.current || detailStatsPriorityRef.current) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 80))
|
||||
if (activeTaskCountRef.current > 0) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 150))
|
||||
continue
|
||||
}
|
||||
|
||||
if (sessionMediaMetricQueueRef.current.length === 0) break
|
||||
|
||||
const batchSessionIds: string[] = []
|
||||
@@ -3050,9 +3130,13 @@ function ExportPage() {
|
||||
patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'loading')
|
||||
|
||||
try {
|
||||
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
batchSessionIds,
|
||||
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||
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) {
|
||||
@@ -3061,15 +3145,26 @@ function ExportPage() {
|
||||
|
||||
const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId))
|
||||
if (missingSessionIds.length > 0) {
|
||||
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
missingSessionIds,
|
||||
{ includeRelations: false, allowStaleCache: true }
|
||||
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<string, SessionExportMetric>)
|
||||
}
|
||||
}
|
||||
|
||||
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', {
|
||||
@@ -3100,12 +3195,11 @@ function ExportPage() {
|
||||
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage])
|
||||
|
||||
const scheduleSessionMediaMetricWorker = useCallback(() => {
|
||||
if (!isSessionCountStageReady) return
|
||||
if (isLoadingSessionCountsRef.current) return
|
||||
if (activeTaskCountRef.current > 0) return
|
||||
if (sessionMediaMetricWorkerRunningRef.current) return
|
||||
const runId = sessionMediaMetricRunIdRef.current
|
||||
void runSessionMediaMetricWorker(runId)
|
||||
}, [isSessionCountStageReady, runSessionMediaMetricWorker])
|
||||
}, [runSessionMediaMetricWorker])
|
||||
|
||||
const loadSessionMutualFriendsMetric = useCallback(async (sessionId: string): Promise<SessionMutualFriendsMetric> => {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
@@ -3150,6 +3244,10 @@ function ExportPage() {
|
||||
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
|
||||
@@ -3196,6 +3294,7 @@ function ExportPage() {
|
||||
])
|
||||
|
||||
const scheduleSessionMutualFriendsWorker = useCallback(() => {
|
||||
if (activeTaskCountRef.current > 0) return
|
||||
if (!isSessionCountStageReady) return
|
||||
if (hasPendingMetricLoads()) return
|
||||
if (sessionMutualFriendsWorkerRunningRef.current) return
|
||||
@@ -3291,9 +3390,6 @@ function ExportPage() {
|
||||
|
||||
setIsLoadingSessionCounts(true)
|
||||
try {
|
||||
if (detailStatsPriorityRef.current) {
|
||||
return { ...accumulatedCounts }
|
||||
}
|
||||
if (prioritizedSessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading')
|
||||
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds)
|
||||
@@ -3311,9 +3407,6 @@ function ExportPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (detailStatsPriorityRef.current) {
|
||||
return { ...accumulatedCounts }
|
||||
}
|
||||
if (remainingSessionIds.length > 0) {
|
||||
patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading')
|
||||
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds)
|
||||
@@ -4135,6 +4228,168 @@ function ExportPage() {
|
||||
|
||||
progressUnsubscribeRef.current?.()
|
||||
const settledSessionIdsFromProgress = new Set<string>()
|
||||
const sessionMessageProgress = new Map<string, { exported: number; total: number; knownTotal: boolean }>()
|
||||
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)
|
||||
)
|
||||
? [...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 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 => {
|
||||
@@ -4148,7 +4403,17 @@ function ExportPage() {
|
||||
phase: 'exporting',
|
||||
phaseLabel: payload.status || '',
|
||||
phaseProgress: payload.total > 0 ? payload.current : 0,
|
||||
phaseTotal: payload.total || 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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -4157,6 +4422,7 @@ function ExportPage() {
|
||||
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 || '')
|
||||
@@ -4172,33 +4438,7 @@ function ExportPage() {
|
||||
markSessionExportRecords([currentSessionId], taskExportContentLabel, next.payload.outputDir, now)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
? [...settledSessionIds, currentSessionId]
|
||||
: settledSessionIds
|
||||
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
|
||||
},
|
||||
settledSessionIds: nextSettledSessionIds,
|
||||
performance
|
||||
}
|
||||
})
|
||||
queueProgressUpdate(payload)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4310,6 +4550,8 @@ function ExportPage() {
|
||||
performance: finalizeTaskPerformance(task, doneAt)
|
||||
}))
|
||||
} finally {
|
||||
clearQueuedProgress()
|
||||
flushQueuedProgress()
|
||||
progressUnsubscribeRef.current?.()
|
||||
progressUnsubscribeRef.current = null
|
||||
runningTaskIdRef.current = null
|
||||
@@ -4715,10 +4957,22 @@ function ExportPage() {
|
||||
return new Date(value).toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}, [])
|
||||
|
||||
const getLoadDetailStatusLabel = useCallback((loaded: number, total: number, hasStarted: boolean): string => {
|
||||
const getLoadDetailStatusLabel = useCallback((
|
||||
loaded: number,
|
||||
total: number,
|
||||
hasStarted: boolean,
|
||||
hasLoading: boolean,
|
||||
failedCount: number
|
||||
): string => {
|
||||
if (total <= 0) return '待加载'
|
||||
if (loaded >= total) return `已完成 ${total}`
|
||||
if (hasStarted) return `加载中 ${loaded}/${total}`
|
||||
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 '待加载'
|
||||
}, [])
|
||||
|
||||
@@ -4728,7 +4982,9 @@ function ExportPage() {
|
||||
): 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
|
||||
@@ -4742,6 +4998,12 @@ function ExportPage() {
|
||||
: 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
|
||||
}
|
||||
@@ -4759,9 +5021,9 @@ function ExportPage() {
|
||||
return {
|
||||
total,
|
||||
loaded,
|
||||
statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted),
|
||||
statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted, hasLoading, failedCount),
|
||||
startedAt: earliestStart,
|
||||
finishedAt: loaded >= total ? latestFinish : undefined,
|
||||
finishedAt: (loaded + failedCount) >= total ? latestFinish : undefined,
|
||||
latestProgressAt
|
||||
}
|
||||
}, [getLoadDetailStatusLabel, sessionLoadTraceMap])
|
||||
@@ -4907,7 +5169,6 @@ function ExportPage() {
|
||||
const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex
|
||||
sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex }
|
||||
sessionMutualFriendsVisibleRangeRef.current = { startIndex, endIndex }
|
||||
if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return
|
||||
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
|
||||
if (visibleTargets.length === 0) return
|
||||
enqueueSessionMediaMetricRequests(visibleTargets, { front: true })
|
||||
@@ -4923,13 +5184,13 @@ function ExportPage() {
|
||||
enqueueSessionMediaMetricRequests,
|
||||
enqueueSessionMutualFriendsRequests,
|
||||
filteredContacts,
|
||||
isSessionCountStageReady,
|
||||
scheduleSessionMediaMetricWorker,
|
||||
scheduleSessionMutualFriendsWorker
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSessionCountStageReady || filteredContacts.length === 0) return
|
||||
if (activeTaskCount > 0) return
|
||||
if (filteredContacts.length === 0) return
|
||||
const runId = sessionMediaMetricRunIdRef.current
|
||||
const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts)
|
||||
if (visibleTargets.length > 0) {
|
||||
@@ -4946,7 +5207,6 @@ function ExportPage() {
|
||||
let cursor = 0
|
||||
const feedNext = () => {
|
||||
if (runId !== sessionMediaMetricRunIdRef.current) return
|
||||
if (isLoadingSessionCountsRef.current) return
|
||||
const batchIds: string[] = []
|
||||
while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) {
|
||||
const contact = filteredContacts[cursor]
|
||||
@@ -4976,15 +5236,61 @@ function ExportPage() {
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activeTaskCount,
|
||||
collectVisibleSessionMetricTargets,
|
||||
enqueueSessionMediaMetricRequests,
|
||||
filteredContacts,
|
||||
isSessionCountStageReady,
|
||||
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)
|
||||
@@ -5031,6 +5337,7 @@ function ExportPage() {
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activeTaskCount,
|
||||
collectVisibleSessionMutualFriendsTargets,
|
||||
enqueueSessionMutualFriendsRequests,
|
||||
filteredContacts,
|
||||
@@ -5348,16 +5655,16 @@ function ExportPage() {
|
||||
|
||||
const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0
|
||||
const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS
|
||||
const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
|
||||
const shouldRunBackgroundRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale))
|
||||
|
||||
if (shouldRunPreciseRefresh) {
|
||||
if (shouldRunBackgroundRefresh) {
|
||||
setIsRefreshingSessionDetailStats(true)
|
||||
void (async () => {
|
||||
try {
|
||||
// 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。
|
||||
// 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列。
|
||||
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true }
|
||||
{ includeRelations: false, forceRefresh: true }
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
if (freshResult.success && freshResult.data) {
|
||||
@@ -6083,14 +6390,10 @@ function ExportPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="row-open-chat-link"
|
||||
title="在新窗口打开该会话"
|
||||
title="切换到聊天页查看该会话"
|
||||
onClick={() => {
|
||||
void window.electronAPI.window.openSessionChatWindow(contact.username, {
|
||||
source: 'export',
|
||||
initialDisplayName: contact.displayName || contact.username,
|
||||
initialAvatarUrl: contact.avatarUrl,
|
||||
initialContactType: contact.type
|
||||
})
|
||||
setCurrentSession(contact.username)
|
||||
navigate('/chat')
|
||||
}}
|
||||
>
|
||||
{openChatLabel}
|
||||
@@ -6198,6 +6501,7 @@ function ExportPage() {
|
||||
)
|
||||
}, [
|
||||
lastExportBySession,
|
||||
navigate,
|
||||
nowTick,
|
||||
openContactSnsTimeline,
|
||||
openSessionDetail,
|
||||
@@ -6219,6 +6523,7 @@ function ExportPage() {
|
||||
shouldShowSnsColumn,
|
||||
snsUserPostCounts,
|
||||
snsUserPostCountsStatus,
|
||||
setCurrentSession,
|
||||
toggleSelectSession
|
||||
])
|
||||
const handleContactsListWheelCapture = useCallback((event: WheelEvent<HTMLDivElement>) => {
|
||||
|
||||
@@ -30,6 +30,16 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'about', label: '关于', icon: Info }
|
||||
]
|
||||
|
||||
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
||||
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||
|
||||
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
||||
const dbPathPlaceholder = isMac
|
||||
? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
|
||||
: isLinux
|
||||
? '例如: ~/.local/share/WeChat/xwechat_files 或者 ~/Documents/xwechat_files'
|
||||
: '例如: C:\\Users\\xxx\\Documents\\xwechat_files'
|
||||
|
||||
|
||||
interface WxidOption {
|
||||
wxid: string
|
||||
@@ -161,6 +171,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
|
||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||
const [showApiWarning, setShowApiWarning] = useState(false)
|
||||
const [messagePushEnabled, setMessagePushEnabled] = useState(false)
|
||||
|
||||
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
|
||||
|
||||
@@ -286,6 +297,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedNotificationPosition = await configService.getNotificationPosition()
|
||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
||||
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
|
||||
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
||||
|
||||
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
||||
@@ -322,6 +334,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setNotificationPosition(savedNotificationPosition)
|
||||
setNotificationFilterMode(savedNotificationFilterMode)
|
||||
setNotificationFilterList(savedNotificationFilterList)
|
||||
setMessagePushEnabled(savedMessagePushEnabled)
|
||||
setWindowCloseBehavior(savedWindowCloseBehavior)
|
||||
|
||||
const savedExcludeWords = await configService.getWordCloudExcludeWords()
|
||||
@@ -1371,7 +1384,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<span className="form-hint">xwechat_files 目录</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="例如: C:\Users\xxx\Documents\xwechat_files"
|
||||
placeholder={dbPathPlaceholder}
|
||||
value={dbPath}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
@@ -1736,6 +1749,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
showMessage('已复制 API 地址', true)
|
||||
}
|
||||
|
||||
const handleToggleMessagePush = async (enabled: boolean) => {
|
||||
setMessagePushEnabled(enabled)
|
||||
await configService.setMessagePushEnabled(enabled)
|
||||
showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true)
|
||||
}
|
||||
|
||||
const renderApiTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
@@ -1802,6 +1821,70 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
|
||||
<div className="form-group">
|
||||
<label>主动推送</label>
|
||||
<span className="form-hint">检测到新收到的消息后,会通过当前 API 端口下的固定 SSE 地址主动推送给外部订阅端</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">
|
||||
{messagePushEnabled ? '已开启' : '已关闭'}
|
||||
</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={messagePushEnabled}
|
||||
onChange={(e) => { void handleToggleMessagePush(e.target.checked) }}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>推送地址</label>
|
||||
<span className="form-hint">外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务`</span>
|
||||
<div className="api-url-display">
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
value={`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`)
|
||||
showMessage('已复制推送地址', true)
|
||||
}}
|
||||
title="复制"
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>推送内容</label>
|
||||
<span className="form-hint">SSE 事件名为 `message.new`;私聊推送 `avatarUrl/sourceName/content`,群聊额外附带 `groupName`</span>
|
||||
<div className="api-docs">
|
||||
<div className="api-item">
|
||||
<div className="api-endpoint">
|
||||
<span className="method get">GET</span>
|
||||
<code>{`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}</code>
|
||||
</div>
|
||||
<p className="api-desc">通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。</p>
|
||||
<div className="api-params">
|
||||
{['event', 'sessionId', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => (
|
||||
<span key={param} className="param">
|
||||
<code>{param}</code>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showApiWarning && (
|
||||
<div className="modal-overlay" onClick={() => setShowApiWarning(false)}>
|
||||
<div className="api-warning-modal" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -11,9 +11,19 @@ import {
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import './WelcomePage.scss'
|
||||
|
||||
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
||||
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||
|
||||
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
||||
const dbPathPlaceholder = isMac
|
||||
? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
|
||||
: isLinux
|
||||
? '例如: ~/.local/share/WeChat/xwechat_files 或者 ~/Documents/xwechat_files'
|
||||
: '例如: C:\\Users\\xxx\\Documents\\xwechat_files'
|
||||
|
||||
const steps = [
|
||||
{ id: 'intro', title: '欢迎', desc: '准备开始你的本地数据探索' },
|
||||
{ id: 'db', title: '数据库目录', desc: '定位 xwechat_files 目录' },
|
||||
{ id: 'db', title: '数据库目录', desc: `定位 ${dbDirName}` },
|
||||
{ id: 'cache', title: '缓存目录', desc: '设置本地缓存存储位置(可选)' },
|
||||
{ id: 'key', title: '解密密钥', desc: '获取密钥与自动识别账号' },
|
||||
{ id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' },
|
||||
@@ -637,7 +647,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="例如:C:\\Users\\xxx\\Documents\\xwechat_files"
|
||||
placeholder={dbPathPlaceholder}
|
||||
value={dbPath}
|
||||
onChange={(e) => setDbPath(e.target.value)}
|
||||
/>
|
||||
@@ -888,13 +898,17 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDbKeyConfirm}
|
||||
title="开始获取数据库密钥"
|
||||
message={`当开始获取后 WeFlow 将会执行准备操作
|
||||
|
||||
当 WeFlow 内的提示条变为绿色显示允许登录或看到来自WeFlow的登录通知时,登录你的微信或退出当前登录并重新登录。`}
|
||||
onConfirm={handleDbKeyConfirm}
|
||||
onCancel={() => setShowDbKeyConfirm(false)}
|
||||
open={showDbKeyConfirm}
|
||||
title="开始获取数据库密钥"
|
||||
message={`当开始获取后 WeFlow 将会执行准备操作。
|
||||
${isLinux ? `
|
||||
【⚠️ Linux 用户特别注意】
|
||||
如果您在微信里勾选了“自动登录”,请务必先关闭自动登录,然后再点击下方确认!
|
||||
(因为授权弹窗输入密码需要时间,若自动登录太快会导致获取失败)
|
||||
` : ''}
|
||||
当 WeFlow 内的提示条变为绿色显示允许登录或看到来自 WeFlow 的登录通知时,请在手机上确认登录微信。`}
|
||||
onConfirm={handleDbKeyConfirm}
|
||||
onCancel={() => setShowDbKeyConfirm(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,7 @@ export const CONFIG_KEYS = {
|
||||
NOTIFICATION_POSITION: 'notificationPosition',
|
||||
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
||||
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
||||
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
|
||||
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
||||
|
||||
// 词云
|
||||
@@ -1362,6 +1363,15 @@ export async function setNotificationFilterList(list: string[]): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
|
||||
}
|
||||
|
||||
export async function getMessagePushEnabled(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_ENABLED)
|
||||
return value === true
|
||||
}
|
||||
|
||||
export async function setMessagePushEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.MESSAGE_PUSH_ENABLED, enabled)
|
||||
}
|
||||
|
||||
export async function getWindowCloseBehavior(): Promise<WindowCloseBehavior> {
|
||||
const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR)
|
||||
if (value === 'tray' || value === 'quit') return value
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type BatchVoiceTaskType = 'transcribe' | 'decrypt'
|
||||
|
||||
export interface BatchTranscribeState {
|
||||
/** 是否正在批量转写 */
|
||||
isBatchTranscribing: boolean
|
||||
/** 当前批量任务类型 */
|
||||
taskType: BatchVoiceTaskType
|
||||
/** 转写进度 */
|
||||
progress: { current: number; total: number }
|
||||
/** 是否显示进度浮窗 */
|
||||
@@ -16,7 +20,7 @@ export interface BatchTranscribeState {
|
||||
sessionName: string
|
||||
|
||||
// Actions
|
||||
startTranscribe: (total: number, sessionName: string) => void
|
||||
startTranscribe: (total: number, sessionName: string, taskType?: BatchVoiceTaskType) => void
|
||||
updateProgress: (current: number, total: number) => void
|
||||
finishTranscribe: (success: number, fail: number) => void
|
||||
setShowToast: (show: boolean) => void
|
||||
@@ -26,6 +30,7 @@ export interface BatchTranscribeState {
|
||||
|
||||
export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
||||
isBatchTranscribing: false,
|
||||
taskType: 'transcribe',
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResult: false,
|
||||
@@ -33,8 +38,9 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
||||
sessionName: '',
|
||||
startTime: 0,
|
||||
|
||||
startTranscribe: (total, sessionName) => set({
|
||||
startTranscribe: (total, sessionName, taskType = 'transcribe') => set({
|
||||
isBatchTranscribing: true,
|
||||
taskType,
|
||||
showToast: true,
|
||||
progress: { current: 0, total },
|
||||
showResult: false,
|
||||
@@ -60,6 +66,7 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
||||
|
||||
reset: () => set({
|
||||
isBatchTranscribing: false,
|
||||
taskType: 'transcribe',
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResult: false,
|
||||
|
||||
@@ -81,13 +81,48 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
appendMessages: (newMessages, prepend = false) => set((state) => {
|
||||
const getMsgKey = (m: Message) => {
|
||||
if (m.messageKey) return m.messageKey
|
||||
const buildPrimaryKey = (m: Message): string => {
|
||||
if (m.messageKey) return String(m.messageKey)
|
||||
return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}`
|
||||
}
|
||||
const buildAliasKeys = (m: Message): string[] => {
|
||||
const keys = [buildPrimaryKey(m)]
|
||||
const localId = Math.max(0, Number(m.localId || 0))
|
||||
const serverId = Math.max(0, Number(m.serverId || 0))
|
||||
const createTime = Math.max(0, Number(m.createTime || 0))
|
||||
const localType = Math.floor(Number(m.localType || 0))
|
||||
const sender = String(m.senderUsername || '')
|
||||
const isSend = Number(m.isSend ?? -1)
|
||||
|
||||
if (localId > 0) {
|
||||
keys.push(`lid:${localId}`)
|
||||
}
|
||||
if (serverId > 0) {
|
||||
keys.push(`sid:${serverId}`)
|
||||
}
|
||||
if (localType === 3) {
|
||||
const imageIdentity = String(m.imageMd5 || m.imageDatName || '').trim()
|
||||
if (imageIdentity) {
|
||||
keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
const currentMessages = state.messages || []
|
||||
const existingKeys = new Set(currentMessages.map(getMsgKey))
|
||||
const filtered = newMessages.filter(m => !existingKeys.has(getMsgKey(m)))
|
||||
const existingAliases = new Set<string>()
|
||||
currentMessages.forEach((msg) => {
|
||||
buildAliasKeys(msg).forEach((key) => existingAliases.add(key))
|
||||
})
|
||||
|
||||
const filtered: Message[] = []
|
||||
newMessages.forEach((msg) => {
|
||||
const aliasKeys = buildAliasKeys(msg)
|
||||
const exists = aliasKeys.some((key) => existingAliases.has(key))
|
||||
if (exists) return
|
||||
filtered.push(msg)
|
||||
aliasKeys.forEach((key) => existingAliases.add(key))
|
||||
})
|
||||
|
||||
if (filtered.length === 0) return state
|
||||
|
||||
|
||||
@@ -530,4 +530,4 @@ body {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
src/types/electron.d.ts
vendored
17
src/types/electron.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import type { ChatSession, Message, Contact, ContactInfo } from './models'
|
||||
import type { ChatSession, Message, Contact, ContactInfo, ChatRecordItem } from './models'
|
||||
|
||||
export interface SessionChatWindowOpenOptions {
|
||||
source?: 'chat' | 'export'
|
||||
@@ -24,6 +24,8 @@ export interface ElectronAPI {
|
||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
||||
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
|
||||
openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: ChatRecordItem[] }) => Promise<boolean>
|
||||
getChatHistoryPayload: (payloadId: string) => Promise<{ success: boolean; payload?: { sessionId: string; title?: string; recordList: ChatRecordItem[] }; error?: string }>
|
||||
openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise<boolean>
|
||||
}
|
||||
config: {
|
||||
@@ -319,8 +321,7 @@ export interface ElectronAPI {
|
||||
getMessageDateCounts: (sessionId: string) => Promise<{ success: boolean; counts?: Record<string, number>; error?: string }>
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
|
||||
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => () => void
|
||||
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
|
||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
|
||||
}
|
||||
@@ -862,6 +863,16 @@ export interface ExportProgress {
|
||||
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
|
||||
}
|
||||
|
||||
export interface WxidInfo {
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface Message {
|
||||
messageKey: string
|
||||
localId: number
|
||||
serverId: number
|
||||
serverIdRaw?: string
|
||||
localType: number
|
||||
createTime: number
|
||||
sortSeq: number
|
||||
@@ -128,11 +129,19 @@ export interface ChatRecordItem {
|
||||
dataurl?: string // 数据URL
|
||||
datathumburl?: string // 缩略图URL
|
||||
datacdnurl?: string // CDN URL
|
||||
cdndatakey?: string // CDN 数据 key
|
||||
cdnthumbkey?: string // CDN 缩略图 key
|
||||
aeskey?: string // AES密钥
|
||||
md5?: string // MD5
|
||||
fullmd5?: string // 原图 MD5
|
||||
thumbfullmd5?: string // 缩略图 MD5
|
||||
srcMsgLocalid?: number // 源消息 LocalId
|
||||
imgheight?: number // 图片高度
|
||||
imgwidth?: number // 图片宽度
|
||||
duration?: number // 时长(毫秒)
|
||||
chatRecordTitle?: string // 嵌套聊天记录标题
|
||||
chatRecordDesc?: string // 嵌套聊天记录描述
|
||||
chatRecordList?: ChatRecordItem[] // 嵌套聊天记录列表
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ export default defineConfig({
|
||||
'whisper-node',
|
||||
'shelljs',
|
||||
'exceljs',
|
||||
'node-llama-cpp'
|
||||
'node-llama-cpp',
|
||||
'sudo-prompt'
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -126,6 +127,26 @@ export default defineConfig({
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
entry: 'electron/exportWorker.ts',
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'better-sqlite3',
|
||||
'koffi',
|
||||
'fsevents',
|
||||
'exceljs'
|
||||
],
|
||||
output: {
|
||||
entryFileNames: 'exportWorker.js',
|
||||
inlineDynamicImports: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
entry: 'electron/preload.ts',
|
||||
onstart(options) {
|
||||
|
||||
Reference in New Issue
Block a user