mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3c17010c1 | ||
|
|
2389aaf314 | ||
|
|
4f1dd7a5fb | ||
|
|
4b203a93b6 | ||
|
|
f219b1a580 | ||
|
|
004ee5bbf0 | ||
|
|
5640db9cbd | ||
|
|
52b26533a2 | ||
|
|
d334a214a4 | ||
|
|
1aab8dfc4e | ||
|
|
e56ee1ff4a | ||
|
|
0393e7aff7 | ||
|
|
c988e4accf | ||
|
|
63ac715792 | ||
|
|
fe0e2e6592 | ||
|
|
ca1a386146 | ||
|
|
7c9d0a39c3 | ||
|
|
a5777027b1 | ||
|
|
c3e911e6fa | ||
|
|
4d03110df2 | ||
|
|
8cb640f565 | ||
|
|
494bd4f539 | ||
|
|
38169691cd | ||
|
|
bd995bc736 | ||
|
|
6e05e74d5e | ||
|
|
d3a1db4efe | ||
|
|
a19f2a57c3 | ||
|
|
666a53f6ba | ||
|
|
b156a08f0d | ||
|
|
9c76aa2189 | ||
|
|
a54c95b6ac | ||
|
|
9cb0ada1b7 | ||
|
|
54378a132f | ||
|
|
4d1632a9b9 | ||
|
|
1eab835458 | ||
|
|
fcbc7fead8 | ||
|
|
ec783e4ccc | ||
|
|
b6f97b102c | ||
|
|
e4ce9a3bd7 | ||
|
|
64d5e721af | ||
|
|
d7419669d6 | ||
|
|
ff2f6799c8 | ||
|
|
2d573896f9 | ||
|
|
8483babd10 | ||
|
|
79648cd9d5 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -57,4 +57,6 @@ Thumbs.db
|
||||
|
||||
wcdb/
|
||||
*info
|
||||
*.md
|
||||
概述.md
|
||||
chatlab-format.md
|
||||
*.bak
|
||||
16
README.md
16
README.md
@@ -38,6 +38,22 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
- 统计分析与群聊画像
|
||||
- 年度报告与可视化概览
|
||||
- 导出聊天记录为 HTML 等格式
|
||||
- HTTP API 接口(供开发者集成)
|
||||
|
||||
|
||||
## HTTP API
|
||||
|
||||
> [!WARNING]
|
||||
> 此功能目前处于早期阶段,接口可能会有变动,请等待后续更新完善。
|
||||
|
||||
WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可用于与其他工具集成或二次开发。
|
||||
|
||||
- **启用方式**:设置 → API 服务 → 启动服务
|
||||
- **默认端口**:5031
|
||||
- **访问地址**:`http://127.0.0.1:5031`
|
||||
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
||||
|
||||
📖 完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
312
docs/HTTP-API.md
Normal file
312
docs/HTTP-API.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# WeFlow HTTP API 接口文档
|
||||
|
||||
WeFlow 提供 HTTP API 服务,支持通过 HTTP 接口查询消息数据,支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出。
|
||||
|
||||
## 启用 API 服务
|
||||
|
||||
在设置页面 → API 服务 → 点击「启动服务」按钮。
|
||||
|
||||
默认端口:`5031`
|
||||
|
||||
## 基础地址
|
||||
|
||||
```
|
||||
http://127.0.0.1:5031
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. 健康检查
|
||||
|
||||
检查 API 服务是否正常运行。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /health
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取消息列表
|
||||
|
||||
获取指定会话的消息,支持 ChatLab 格式输出。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/v1/messages
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
||||
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
|
||||
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
|
||||
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
|
||||
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
|
||||
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
|
||||
|
||||
**示例请求**
|
||||
|
||||
```bash
|
||||
# 获取消息(原始格式)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50
|
||||
|
||||
# 获取消息(ChatLab 格式)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1
|
||||
|
||||
# 带时间范围查询
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
|
||||
```
|
||||
|
||||
**响应(原始格式)**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"talker": "wxid_xxx",
|
||||
"count": 50,
|
||||
"hasMore": true,
|
||||
"messages": [
|
||||
{
|
||||
"localId": 123,
|
||||
"talker": "wxid_xxx",
|
||||
"type": 1,
|
||||
"content": "消息内容",
|
||||
"createTime": 1738713600000,
|
||||
"isSelf": false,
|
||||
"sender": "wxid_sender"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**响应(ChatLab 格式)**
|
||||
```json
|
||||
{
|
||||
"chatlab": {
|
||||
"version": "0.0.2",
|
||||
"exportedAt": 1738713600000,
|
||||
"generator": "WeFlow",
|
||||
"description": "Exported from WeFlow"
|
||||
},
|
||||
"meta": {
|
||||
"name": "会话名称",
|
||||
"platform": "wechat",
|
||||
"type": "private",
|
||||
"ownerId": "wxid_me"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
"platformId": "wxid_xxx",
|
||||
"accountName": "用户名",
|
||||
"groupNickname": "群昵称"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"sender": "wxid_xxx",
|
||||
"accountName": "用户名",
|
||||
"timestamp": 1738713600000,
|
||||
"type": 0,
|
||||
"content": "消息内容"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 获取会话列表
|
||||
|
||||
获取所有会话列表。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/v1/sessions
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
||||
|
||||
**示例请求**
|
||||
```bash
|
||||
GET http://127.0.0.1:5031/api/v1/sessions
|
||||
|
||||
GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"count": 50,
|
||||
"total": 100,
|
||||
"sessions": [
|
||||
{
|
||||
"username": "wxid_xxx",
|
||||
"displayName": "用户名",
|
||||
"lastMessage": "最后一条消息",
|
||||
"lastTime": 1738713600000,
|
||||
"unreadCount": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 获取联系人列表
|
||||
|
||||
获取所有联系人信息。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/v1/contacts
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `keyword` | string | ❌ | 搜索关键词 |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
||||
|
||||
**示例请求**
|
||||
```bash
|
||||
GET http://127.0.0.1:5031/api/v1/contacts
|
||||
|
||||
GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
|
||||
```
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"count": 50,
|
||||
"contacts": [
|
||||
{
|
||||
"userName": "wxid_xxx",
|
||||
"alias": "微信号",
|
||||
"nickName": "昵称",
|
||||
"remark": "备注名"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ChatLab 格式说明
|
||||
|
||||
ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2。
|
||||
|
||||
### 消息类型映射
|
||||
|
||||
| ChatLab Type | 值 | 说明 |
|
||||
|--------------|-----|------|
|
||||
| TEXT | 0 | 文本消息 |
|
||||
| IMAGE | 1 | 图片 |
|
||||
| VOICE | 2 | 语音 |
|
||||
| VIDEO | 3 | 视频 |
|
||||
| FILE | 4 | 文件 |
|
||||
| EMOJI | 5 | 表情 |
|
||||
| LINK | 7 | 链接 |
|
||||
| LOCATION | 8 | 位置 |
|
||||
| RED_PACKET | 20 | 红包 |
|
||||
| TRANSFER | 21 | 转账 |
|
||||
| CALL | 23 | 通话 |
|
||||
| SYSTEM | 80 | 系统消息 |
|
||||
| RECALL | 81 | 撤回消息 |
|
||||
| OTHER | 99 | 其他 |
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### PowerShell
|
||||
|
||||
```powershell
|
||||
# 健康检查
|
||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
||||
|
||||
# 获取会话列表
|
||||
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
|
||||
|
||||
# 获取消息
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
||||
|
||||
# 获取 ChatLab 格式
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://127.0.0.1:5031/health
|
||||
|
||||
# 获取会话列表
|
||||
curl http://127.0.0.1:5031/api/v1/sessions
|
||||
|
||||
# 获取消息(ChatLab 格式)
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5031"
|
||||
|
||||
# 获取会话列表
|
||||
sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json()
|
||||
print(sessions)
|
||||
|
||||
# 获取消息
|
||||
messages = requests.get(f"{BASE_URL}/api/v1/messages", params={
|
||||
"talker": "wxid_xxx",
|
||||
"limit": 100,
|
||||
"chatlab": 1
|
||||
}).json()
|
||||
print(messages)
|
||||
```
|
||||
|
||||
### JavaScript / Node.js
|
||||
|
||||
```javascript
|
||||
const BASE_URL = "http://127.0.0.1:5031";
|
||||
|
||||
// 获取会话列表
|
||||
const sessions = await fetch(`${BASE_URL}/api/v1/sessions`).then(r => r.json());
|
||||
console.log(sessions);
|
||||
|
||||
// 获取消息(ChatLab 格式)
|
||||
const messages = await fetch(`${BASE_URL}/api/v1/messages?talker=wxid_xxx&chatlab=1`)
|
||||
.then(r => r.json());
|
||||
console.log(messages);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. API 仅监听本地地址 `127.0.0.1`,不对外网开放
|
||||
2. 需要先连接数据库才能查询数据
|
||||
3. 时间参数格式为 `YYYYMMDD`(如 20260205)
|
||||
4. 支持 CORS,可从浏览器前端直接调用
|
||||
@@ -23,6 +23,7 @@ import { contactExportService } from './services/contactExportService'
|
||||
import { windowsHelloService } from './services/windowsHelloService'
|
||||
import { llamaService } from './services/llamaService'
|
||||
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||
import { httpService } from './services/httpService'
|
||||
|
||||
|
||||
// 配置自动更新
|
||||
@@ -863,6 +864,10 @@ function registerIpcHandlers() {
|
||||
return await chatService.getContactAvatar(username)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:resolveTransferDisplayNames', async (_, chatroomId: string, payerUsername: string, receiverUsername: string) => {
|
||||
return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getContacts', async () => {
|
||||
return await chatService.getContacts()
|
||||
})
|
||||
@@ -895,6 +900,9 @@ function registerIpcHandlers() {
|
||||
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => {
|
||||
return chatService.getVoiceData(sessionId, msgId, createTime, serverId)
|
||||
})
|
||||
ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => {
|
||||
return chatService.getAllVoiceMessages(sessionId)
|
||||
})
|
||||
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
|
||||
return chatService.resolveVoiceCache(sessionId, msgId)
|
||||
})
|
||||
@@ -951,6 +959,10 @@ function registerIpcHandlers() {
|
||||
})
|
||||
|
||||
// 导出相关
|
||||
ipcMain.handle('export:getExportStats', async (_, sessionIds: string[], options: any) => {
|
||||
return exportService.getExportStats(sessionIds, options)
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||
const onProgress = (progress: ExportProgress) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
@@ -1282,6 +1294,23 @@ function registerIpcHandlers() {
|
||||
})
|
||||
})
|
||||
|
||||
// HTTP API 服务
|
||||
ipcMain.handle('http:start', async (_, port?: number) => {
|
||||
return httpService.start(port || 5031)
|
||||
})
|
||||
|
||||
ipcMain.handle('http:stop', async () => {
|
||||
await httpService.stop()
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('http:status', async () => {
|
||||
return {
|
||||
running: httpService.isRunning(),
|
||||
port: httpService.getPort()
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// 主窗口引用
|
||||
|
||||
@@ -131,6 +131,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
|
||||
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
||||
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
|
||||
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
|
||||
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
|
||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
||||
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
||||
@@ -139,6 +141,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
||||
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
||||
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
||||
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) => {
|
||||
@@ -236,6 +239,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// 导出
|
||||
export: {
|
||||
getExportStats: (sessionIds: string[], options: any) =>
|
||||
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||
@@ -286,5 +291,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('llama:downloadProgress', listener)
|
||||
return () => ipcRenderer.removeListener('llama:downloadProgress', listener)
|
||||
}
|
||||
},
|
||||
|
||||
// HTTP API 服务
|
||||
http: {
|
||||
start: (port?: number) => ipcRenderer.invoke('http:start', port),
|
||||
stop: () => ipcRenderer.invoke('http:stop'),
|
||||
status: () => ipcRenderer.invoke('http:status')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -193,11 +193,15 @@ class AnnualReportService {
|
||||
if (!raw) return ''
|
||||
if (typeof raw === 'string') {
|
||||
if (raw.length === 0) return ''
|
||||
if (this.looksLikeHex(raw)) {
|
||||
// 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码
|
||||
// 短字符串(如 "123456" 等纯数字)容易被误判为 hex
|
||||
if (raw.length > 16 && this.looksLikeHex(raw)) {
|
||||
const bytes = Buffer.from(raw, 'hex')
|
||||
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
||||
}
|
||||
if (this.looksLikeBase64(raw)) {
|
||||
// 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
|
||||
// 短字符串(如 "test", "home" 等)容易被误判为 base64
|
||||
if (raw.length > 16 && this.looksLikeBase64(raw)) {
|
||||
try {
|
||||
const bytes = Buffer.from(raw, 'base64')
|
||||
return this.decodeBinaryContent(bytes)
|
||||
|
||||
@@ -117,10 +117,13 @@ class ChatService {
|
||||
private voiceWavCache = new Map<string, Buffer>()
|
||||
private voiceTranscriptCache = new Map<string, string>()
|
||||
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
|
||||
private transcriptCacheLoaded = false
|
||||
private transcriptCacheDirty = false
|
||||
private transcriptFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private mediaDbsCache: string[] | null = null
|
||||
private mediaDbsCacheTime = 0
|
||||
private readonly mediaDbsCacheTtl = 300000 // 5分钟
|
||||
private readonly voiceCacheMaxEntries = 50
|
||||
private readonly voiceWavCacheMaxEntries = 50
|
||||
// 缓存 media.db 的表结构信息
|
||||
private mediaDbSchemaCache = new Map<string, {
|
||||
voiceTable: string
|
||||
@@ -1120,6 +1123,9 @@ class ChatService {
|
||||
// 名片消息
|
||||
let cardUsername: string | undefined
|
||||
let cardNickname: string | undefined
|
||||
// 转账消息
|
||||
let transferPayerUsername: string | undefined
|
||||
let transferReceiverUsername: string | undefined
|
||||
// 聊天记录
|
||||
let chatRecordTitle: string | undefined
|
||||
let chatRecordList: Array<{
|
||||
@@ -1151,8 +1157,8 @@ class ChatService {
|
||||
const cardInfo = this.parseCardInfo(content)
|
||||
cardUsername = cardInfo.username
|
||||
cardNickname = cardInfo.nickname
|
||||
} else if (localType === 49 && content) {
|
||||
// Type 49 消息(链接、文件、小程序、转账等)
|
||||
} else if ((localType === 49 || localType === 8589934592049) && content) {
|
||||
// Type 49 消息(链接、文件、小程序、转账等),8589934592049 也是转账类型
|
||||
const type49Info = this.parseType49Message(content)
|
||||
xmlType = type49Info.xmlType
|
||||
linkTitle = type49Info.linkTitle
|
||||
@@ -1163,6 +1169,8 @@ class ChatService {
|
||||
fileExt = type49Info.fileExt
|
||||
chatRecordTitle = type49Info.chatRecordTitle
|
||||
chatRecordList = type49Info.chatRecordList
|
||||
transferPayerUsername = type49Info.transferPayerUsername
|
||||
transferReceiverUsername = type49Info.transferReceiverUsername
|
||||
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
|
||||
const quoteInfo = this.parseQuoteMessage(content)
|
||||
quotedContent = quoteInfo.content
|
||||
@@ -1199,6 +1207,8 @@ class ChatService {
|
||||
xmlType,
|
||||
cardUsername,
|
||||
cardNickname,
|
||||
transferPayerUsername,
|
||||
transferReceiverUsername,
|
||||
chatRecordTitle,
|
||||
chatRecordList
|
||||
})
|
||||
@@ -1663,6 +1673,8 @@ class ChatService {
|
||||
fileName?: string
|
||||
fileSize?: number
|
||||
fileExt?: string
|
||||
transferPayerUsername?: string
|
||||
transferReceiverUsername?: string
|
||||
chatRecordTitle?: string
|
||||
chatRecordList?: Array<{
|
||||
datatype: number
|
||||
@@ -1786,6 +1798,16 @@ class ChatService {
|
||||
} else if (feedesc) {
|
||||
result.linkTitle = feedesc
|
||||
}
|
||||
|
||||
// 提取转账双方 wxid
|
||||
const payerUsername = this.extractXmlValue(content, 'payer_username')
|
||||
const receiverUsername = this.extractXmlValue(content, 'receiver_username')
|
||||
if (payerUsername) {
|
||||
result.transferPayerUsername = payerUsername
|
||||
}
|
||||
if (receiverUsername) {
|
||||
result.transferReceiverUsername = receiverUsername
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -2168,19 +2190,32 @@ class ChatService {
|
||||
|
||||
/**
|
||||
* 清理拍一拍消息
|
||||
* 格式示例: 我拍了拍 "梨绒" ງ໐໐໓ ຖiງht620000wxid_...
|
||||
* 格式示例:
|
||||
* 纯文本: 我拍了拍 "梨绒" ງ໐໐໓ ຖiງht620000wxid_...
|
||||
* XML: <msg><appmsg...><title>"有幸"拍了拍"浩天空"相信未来!</title>...</msg>
|
||||
*/
|
||||
private cleanPatMessage(content: string): string {
|
||||
if (!content) return '[拍一拍]'
|
||||
|
||||
// 1. 尝试匹配标准的 "A拍了拍B" 格式
|
||||
// 这里的正则比较宽泛,为了兼容不同的语言环境
|
||||
// 1. 优先从 XML <title> 标签提取内容
|
||||
const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content)
|
||||
if (titleMatch) {
|
||||
const title = titleMatch[1]
|
||||
.replace(/<!\[CDATA\[/g, '')
|
||||
.replace(/\]\]>/g, '')
|
||||
.trim()
|
||||
if (title) {
|
||||
return `[拍一拍] ${title}`
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试匹配标准的 "A拍了拍B" 格式
|
||||
const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content)
|
||||
if (match) {
|
||||
return `[拍一拍] ${match[1].trim()}`
|
||||
}
|
||||
|
||||
// 2. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码)
|
||||
// 3. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码)
|
||||
let cleaned = content.replace(/wxid_[a-zA-Z0-9_-]+/g, '') // 移除 wxid
|
||||
cleaned = cleaned.replace(/[ງ໐໓ຖiht]+/g, ' ') // 移除已知的乱码字符
|
||||
cleaned = cleaned.replace(/\d{6,}/g, '') // 移除长数字
|
||||
@@ -2227,7 +2262,9 @@ class ChatService {
|
||||
if (raw.length === 0) return ''
|
||||
|
||||
// 检查是否是 hex 编码
|
||||
if (this.looksLikeHex(raw)) {
|
||||
// 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码
|
||||
// 短字符串(如 "123456" 等纯数字)容易被误判为 hex
|
||||
if (raw.length > 16 && this.looksLikeHex(raw)) {
|
||||
const bytes = Buffer.from(raw, 'hex')
|
||||
if (bytes.length > 0) {
|
||||
const result = this.decodeBinaryContent(bytes, raw)
|
||||
@@ -2237,7 +2274,9 @@ class ChatService {
|
||||
}
|
||||
|
||||
// 检查是否是 base64 编码
|
||||
if (this.looksLikeBase64(raw)) {
|
||||
// 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
|
||||
// 短字符串(如 "test", "home" 等)容易被误判为 base64
|
||||
if (raw.length > 16 && this.looksLikeBase64(raw)) {
|
||||
try {
|
||||
const bytes = Buffer.from(raw, 'base64')
|
||||
return this.decodeBinaryContent(bytes, raw)
|
||||
@@ -2384,6 +2423,75 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析转账消息中的付款方和收款方显示名称
|
||||
* 优先使用群昵称,群昵称为空时回退到微信昵称/备注
|
||||
*/
|
||||
async resolveTransferDisplayNames(
|
||||
chatroomId: string,
|
||||
payerUsername: string,
|
||||
receiverUsername: string
|
||||
): Promise<{ payerName: string; receiverName: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) {
|
||||
return { payerName: payerUsername, receiverName: receiverUsername }
|
||||
}
|
||||
|
||||
// 如果是群聊,尝试获取群昵称
|
||||
let groupNicknames: Record<string, string> = {}
|
||||
if (chatroomId.endsWith('@chatroom')) {
|
||||
const nickResult = await wcdbService.getGroupNicknames(chatroomId)
|
||||
if (nickResult.success && nickResult.nicknames) {
|
||||
groupNicknames = nickResult.nicknames
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前用户 wxid,用于识别"自己"
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
|
||||
|
||||
// 解析付款方名称:自己 > 群昵称 > 备注 > 昵称 > alias > wxid
|
||||
const resolveName = async (username: string): Promise<string> => {
|
||||
// 特判:如果是当前用户自己(contact 表通常不包含自己)
|
||||
if (myWxid && (username === myWxid || username === cleanedMyWxid)) {
|
||||
// 先查群昵称中是否有自己
|
||||
const myGroupNick = groupNicknames[username]
|
||||
if (myGroupNick) return myGroupNick
|
||||
// 尝试从缓存获取自己的昵称
|
||||
const cached = this.avatarCache.get(username) || this.avatarCache.get(myWxid)
|
||||
if (cached?.displayName) return cached.displayName
|
||||
return '我'
|
||||
}
|
||||
|
||||
// 先查群昵称
|
||||
const groupNick = groupNicknames[username]
|
||||
if (groupNick) return groupNick
|
||||
|
||||
// 再查联系人信息
|
||||
const contact = await this.getContact(username)
|
||||
if (contact) {
|
||||
return contact.remark || contact.nickName || contact.alias || username
|
||||
}
|
||||
|
||||
// 兜底:查缓存
|
||||
const cached = this.avatarCache.get(username)
|
||||
if (cached?.displayName) return cached.displayName
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
const [payerName, receiverName] = await Promise.all([
|
||||
resolveName(payerUsername),
|
||||
resolveName(receiverUsername)
|
||||
])
|
||||
|
||||
return { payerName, receiverName }
|
||||
} catch {
|
||||
return { payerName: payerUsername, receiverName: receiverUsername }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的头像 URL
|
||||
*/
|
||||
@@ -3406,6 +3514,8 @@ class ChatService {
|
||||
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||
const startTime = Date.now()
|
||||
|
||||
// 确保磁盘缓存已加载
|
||||
this.loadTranscriptCacheIfNeeded()
|
||||
|
||||
try {
|
||||
let msgCreateTime = createTime
|
||||
@@ -3533,17 +3643,136 @@ class ChatService {
|
||||
|
||||
private cacheVoiceWav(cacheKey: string, wavData: Buffer): void {
|
||||
this.voiceWavCache.set(cacheKey, wavData)
|
||||
if (this.voiceWavCache.size > this.voiceCacheMaxEntries) {
|
||||
if (this.voiceWavCache.size > this.voiceWavCacheMaxEntries) {
|
||||
const oldestKey = this.voiceWavCache.keys().next().value
|
||||
if (oldestKey) this.voiceWavCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取持久化转写缓存文件路径 */
|
||||
private getTranscriptCachePath(): string {
|
||||
const cachePath = this.configService.get('cachePath')
|
||||
const base = cachePath || join(app.getPath('documents'), 'WeFlow')
|
||||
return join(base, 'Voices', 'transcripts.json')
|
||||
}
|
||||
|
||||
/** 首次访问时从磁盘加载转写缓存 */
|
||||
private loadTranscriptCacheIfNeeded(): void {
|
||||
if (this.transcriptCacheLoaded) return
|
||||
this.transcriptCacheLoaded = true
|
||||
try {
|
||||
const filePath = this.getTranscriptCachePath()
|
||||
if (existsSync(filePath)) {
|
||||
const raw = readFileSync(filePath, 'utf-8')
|
||||
const data = JSON.parse(raw) as Record<string, string>
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (typeof v === 'string') this.voiceTranscriptCache.set(k, v)
|
||||
}
|
||||
console.log(`[Transcribe] 从磁盘加载了 ${this.voiceTranscriptCache.size} 条转写缓存`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Transcribe] 加载转写缓存失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 将转写缓存持久化到磁盘(防抖 3 秒) */
|
||||
private scheduleTranscriptFlush(): void {
|
||||
if (this.transcriptFlushTimer) return
|
||||
this.transcriptFlushTimer = setTimeout(() => {
|
||||
this.transcriptFlushTimer = null
|
||||
this.flushTranscriptCache()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
/** 立即写入转写缓存到磁盘 */
|
||||
flushTranscriptCache(): void {
|
||||
if (!this.transcriptCacheDirty) return
|
||||
try {
|
||||
const filePath = this.getTranscriptCachePath()
|
||||
const dir = dirname(filePath)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
const obj: Record<string, string> = {}
|
||||
for (const [k, v] of this.voiceTranscriptCache) obj[k] = v
|
||||
writeFileSync(filePath, JSON.stringify(obj), 'utf-8')
|
||||
this.transcriptCacheDirty = false
|
||||
} catch (e) {
|
||||
console.error('[Transcribe] 写入转写缓存失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
private cacheVoiceTranscript(cacheKey: string, transcript: string): void {
|
||||
this.voiceTranscriptCache.set(cacheKey, transcript)
|
||||
if (this.voiceTranscriptCache.size > this.voiceCacheMaxEntries) {
|
||||
const oldestKey = this.voiceTranscriptCache.keys().next().value
|
||||
if (oldestKey) this.voiceTranscriptCache.delete(oldestKey)
|
||||
this.transcriptCacheDirty = true
|
||||
this.scheduleTranscriptFlush()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查某个语音消息是否已有缓存的转写结果
|
||||
*/
|
||||
hasTranscriptCache(sessionId: string, msgId: string, createTime?: number): boolean {
|
||||
this.loadTranscriptCacheIfNeeded()
|
||||
const cacheKey = this.getVoiceCacheKey(sessionId, msgId, createTime)
|
||||
return this.voiceTranscriptCache.has(cacheKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某会话的所有语音消息(localType=34),用于批量转写
|
||||
*/
|
||||
async getAllVoiceMessages(sessionId: string): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
|
||||
// 获取会话表信息
|
||||
let tables = this.sessionTablesCache.get(sessionId)
|
||||
if (!tables) {
|
||||
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||
if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) {
|
||||
return { success: false, error: '未找到会话消息表' }
|
||||
}
|
||||
tables = tableStats.tables
|
||||
.map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path }))
|
||||
.filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }>
|
||||
if (tables.length > 0) {
|
||||
this.sessionTablesCache.set(sessionId, tables)
|
||||
setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl)
|
||||
}
|
||||
}
|
||||
|
||||
let allVoiceMessages: Message[] = []
|
||||
|
||||
for (const { tableName, dbPath } of tables) {
|
||||
try {
|
||||
const sql = `SELECT * FROM ${tableName} WHERE local_type = 34 ORDER BY create_time DESC`
|
||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||
if (result.success && result.rows && result.rows.length > 0) {
|
||||
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
|
||||
allVoiceMessages.push(...mapped)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[ChatService] 查询语音消息失败 (${dbPath}):`, e)
|
||||
}
|
||||
}
|
||||
|
||||
// 按 createTime 降序排序
|
||||
allVoiceMessages.sort((a, b) => b.createTime - a.createTime)
|
||||
|
||||
// 去重
|
||||
const seen = new Set<string>()
|
||||
allVoiceMessages = allVoiceMessages.filter(msg => {
|
||||
const key = `${msg.serverId}-${msg.localId}-${msg.createTime}-${msg.sortSeq}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
console.log(`[ChatService] 共找到 ${allVoiceMessages.length} 条语音消息(去重后)`)
|
||||
return { success: true, messages: allVoiceMessages }
|
||||
} catch (e) {
|
||||
console.error('[ChatService] 获取所有语音消息失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,11 +106,15 @@ class DualReportService {
|
||||
if (!raw) return ''
|
||||
if (typeof raw === 'string') {
|
||||
if (raw.length === 0) return ''
|
||||
if (this.looksLikeHex(raw)) {
|
||||
// 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码
|
||||
// 短字符串(如 "123456" 等纯数字)容易被误判为 hex
|
||||
if (raw.length > 16 && this.looksLikeHex(raw)) {
|
||||
const bytes = Buffer.from(raw, 'hex')
|
||||
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
||||
}
|
||||
if (this.looksLikeBase64(raw)) {
|
||||
// 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
|
||||
// 短字符串(如 "test", "home" 等)容易被误判为 base64
|
||||
if (raw.length > 16 && this.looksLikeBase64(raw)) {
|
||||
try {
|
||||
const bytes = Buffer.from(raw, 'base64')
|
||||
return this.decodeBinaryContent(bytes)
|
||||
|
||||
@@ -25,83 +25,87 @@ body {
|
||||
|
||||
.page {
|
||||
max-width: 1080px;
|
||||
margin: 32px auto 60px;
|
||||
padding: 0 20px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 20px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||
padding: 12px 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
margin: 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
display: inline;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.meta span {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control label {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.control input,
|
||||
.control select,
|
||||
.control button {
|
||||
border-radius: 12px;
|
||||
.controls input,
|
||||
.controls button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.control button {
|
||||
.controls input[type="search"] {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.controls input[type="datetime-local"] {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.control button:active {
|
||||
.controls button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
@@ -248,50 +252,11 @@ body {
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
body[data-theme="cloud-dancer"] {
|
||||
--accent: #6b8cff;
|
||||
--sent: #e0e7ff;
|
||||
--received: #ffffff;
|
||||
--border: #d8e0f7;
|
||||
--bg: #f6f7fb;
|
||||
}
|
||||
|
||||
body[data-theme="corundum-blue"] {
|
||||
--accent: #2563eb;
|
||||
--sent: #dbeafe;
|
||||
--received: #ffffff;
|
||||
--border: #c7d2fe;
|
||||
--bg: #eef2ff;
|
||||
}
|
||||
|
||||
body[data-theme="kiwi-green"] {
|
||||
--accent: #16a34a;
|
||||
--sent: #dcfce7;
|
||||
--received: #ffffff;
|
||||
--border: #bbf7d0;
|
||||
--bg: #f0fdf4;
|
||||
}
|
||||
|
||||
body[data-theme="spicy-red"] {
|
||||
--accent: #e11d48;
|
||||
--sent: #ffe4e6;
|
||||
--received: #ffffff;
|
||||
--border: #fecdd3;
|
||||
--bg: #fff1f2;
|
||||
}
|
||||
|
||||
body[data-theme="teal-water"] {
|
||||
--accent: #0f766e;
|
||||
--sent: #ccfbf1;
|
||||
--received: #ffffff;
|
||||
--border: #99f6e4;
|
||||
--bg: #f0fdfa;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: 18px;
|
||||
transition: outline-color 0.3s;
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -300,32 +265,29 @@ body[data-theme="teal-water"] {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Virtual Scroll */
|
||||
.virtual-scroll-container {
|
||||
height: calc(100vh - 180px);
|
||||
/* Adjust based on header height */
|
||||
/* Scroll Container */
|
||||
.scroll-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
margin-top: 20px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.virtual-scroll-spacer {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
width: 1px;
|
||||
.scroll-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.virtual-scroll-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
.scroll-container::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
/* Override message-list to be inside virtual scroll */
|
||||
display: block;
|
||||
.load-sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
@@ -25,83 +25,87 @@ body {
|
||||
|
||||
.page {
|
||||
max-width: 1080px;
|
||||
margin: 32px auto 60px;
|
||||
padding: 0 20px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 20px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||
padding: 12px 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
margin: 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
display: inline;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.meta span {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control label {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.control input,
|
||||
.control select,
|
||||
.control button {
|
||||
border-radius: 12px;
|
||||
.controls input,
|
||||
.controls button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.control button {
|
||||
.controls input[type="search"] {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.controls input[type="datetime-local"] {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.control button:active {
|
||||
.controls button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
@@ -248,50 +252,11 @@ body {
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
body[data-theme="cloud-dancer"] {
|
||||
--accent: #6b8cff;
|
||||
--sent: #e0e7ff;
|
||||
--received: #ffffff;
|
||||
--border: #d8e0f7;
|
||||
--bg: #f6f7fb;
|
||||
}
|
||||
|
||||
body[data-theme="corundum-blue"] {
|
||||
--accent: #2563eb;
|
||||
--sent: #dbeafe;
|
||||
--received: #ffffff;
|
||||
--border: #c7d2fe;
|
||||
--bg: #eef2ff;
|
||||
}
|
||||
|
||||
body[data-theme="kiwi-green"] {
|
||||
--accent: #16a34a;
|
||||
--sent: #dcfce7;
|
||||
--received: #ffffff;
|
||||
--border: #bbf7d0;
|
||||
--bg: #f0fdf4;
|
||||
}
|
||||
|
||||
body[data-theme="spicy-red"] {
|
||||
--accent: #e11d48;
|
||||
--sent: #ffe4e6;
|
||||
--received: #ffffff;
|
||||
--border: #fecdd3;
|
||||
--bg: #fff1f2;
|
||||
}
|
||||
|
||||
body[data-theme="teal-water"] {
|
||||
--accent: #0f766e;
|
||||
--sent: #ccfbf1;
|
||||
--received: #ffffff;
|
||||
--border: #99f6e4;
|
||||
--bg: #f0fdfa;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: 18px;
|
||||
transition: outline-color 0.3s;
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -299,4 +264,32 @@ body[data-theme="teal-water"] {
|
||||
color: var(--muted);
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Scroll Container */
|
||||
.scroll-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.scroll-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scroll-container::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.load-sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,19 +105,166 @@ class GroupAnalyticsService {
|
||||
/**
|
||||
* 从 DLL 获取群成员的群昵称
|
||||
*/
|
||||
private async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
|
||||
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||
try {
|
||||
const result = await wcdbService.getGroupNicknames(chatroomId)
|
||||
if (result.success && result.nicknames) {
|
||||
return new Map(Object.entries(result.nicknames))
|
||||
}
|
||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
||||
const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`
|
||||
const result = await wcdbService.execQuery('contact', null, sql)
|
||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||
return new Map<string, string>()
|
||||
}
|
||||
|
||||
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
||||
if (!extBuffer) return new Map<string, string>()
|
||||
return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates)
|
||||
} catch (e) {
|
||||
console.error('getGroupNicknamesForRoom error:', e)
|
||||
return new Map<string, string>()
|
||||
}
|
||||
}
|
||||
|
||||
private looksLikeHex(s: string): boolean {
|
||||
if (s.length % 2 !== 0) return false
|
||||
return /^[0-9a-fA-F]+$/.test(s)
|
||||
}
|
||||
|
||||
private looksLikeBase64(s: string): boolean {
|
||||
if (s.length % 4 !== 0) return false
|
||||
return /^[A-Za-z0-9+/=]+$/.test(s)
|
||||
}
|
||||
|
||||
private decodeExtBuffer(value: unknown): Buffer | null {
|
||||
if (!value) return null
|
||||
if (Buffer.isBuffer(value)) return value
|
||||
if (value instanceof Uint8Array) return Buffer.from(value)
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const raw = value.trim()
|
||||
if (!raw) return null
|
||||
|
||||
if (this.looksLikeHex(raw)) {
|
||||
try { return Buffer.from(raw, 'hex') } catch { }
|
||||
}
|
||||
if (this.looksLikeBase64(raw)) {
|
||||
try { return Buffer.from(raw, 'base64') } catch { }
|
||||
}
|
||||
|
||||
try { return Buffer.from(raw, 'hex') } catch { }
|
||||
try { return Buffer.from(raw, 'base64') } catch { }
|
||||
try { return Buffer.from(raw, 'utf8') } catch { }
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private readVarint(buffer: Buffer, offset: number, limit: number = buffer.length): { value: number; next: number } | null {
|
||||
let value = 0
|
||||
let shift = 0
|
||||
let pos = offset
|
||||
while (pos < limit && shift <= 53) {
|
||||
const byte = buffer[pos]
|
||||
value += (byte & 0x7f) * Math.pow(2, shift)
|
||||
pos += 1
|
||||
if ((byte & 0x80) === 0) return { value, next: pos }
|
||||
shift += 7
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private isLikelyMemberId(value: string): boolean {
|
||||
const id = String(value || '').trim()
|
||||
if (!id) return false
|
||||
if (id.includes('@chatroom')) return false
|
||||
if (id.length < 4 || id.length > 80) return false
|
||||
return /^[A-Za-z][A-Za-z0-9_.@-]*$/.test(id)
|
||||
}
|
||||
|
||||
private isLikelyNickname(value: string): boolean {
|
||||
const cleaned = this.normalizeGroupNickname(value)
|
||||
if (!cleaned) return false
|
||||
if (/^wxid_[a-z0-9_]+$/i.test(cleaned)) return false
|
||||
if (cleaned.includes('@chatroom')) return false
|
||||
if (!/[\u4E00-\u9FFF\u3400-\u4DBF\w]/.test(cleaned)) return false
|
||||
if (cleaned.length === 1) {
|
||||
const code = cleaned.charCodeAt(0)
|
||||
const isCjk = code >= 0x3400 && code <= 0x9fff
|
||||
if (!isCjk) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private parseGroupNicknamesFromExtBuffer(buffer: Buffer, candidates: string[] = []): Map<string, string> {
|
||||
const nicknameMap = new Map<string, string>()
|
||||
if (!buffer || buffer.length === 0) return nicknameMap
|
||||
|
||||
try {
|
||||
const candidateSet = new Set(this.buildIdCandidates(candidates).map((id) => id.toLowerCase()))
|
||||
|
||||
for (let i = 0; i < buffer.length - 2; i += 1) {
|
||||
if (buffer[i] !== 0x0a) continue
|
||||
|
||||
const idLenInfo = this.readVarint(buffer, i + 1)
|
||||
if (!idLenInfo) continue
|
||||
const idLen = idLenInfo.value
|
||||
if (!Number.isFinite(idLen) || idLen <= 0 || idLen > 96) continue
|
||||
|
||||
const idStart = idLenInfo.next
|
||||
const idEnd = idStart + idLen
|
||||
if (idEnd > buffer.length) continue
|
||||
|
||||
const memberId = buffer.toString('utf8', idStart, idEnd).trim()
|
||||
if (!this.isLikelyMemberId(memberId)) continue
|
||||
|
||||
const memberIdLower = memberId.toLowerCase()
|
||||
if (candidateSet.size > 0 && !candidateSet.has(memberIdLower)) {
|
||||
i = idEnd - 1
|
||||
continue
|
||||
}
|
||||
|
||||
const cursor = idEnd
|
||||
if (cursor >= buffer.length || buffer[cursor] !== 0x12) {
|
||||
i = idEnd - 1
|
||||
continue
|
||||
}
|
||||
|
||||
const nickLenInfo = this.readVarint(buffer, cursor + 1)
|
||||
if (!nickLenInfo) {
|
||||
i = idEnd - 1
|
||||
continue
|
||||
}
|
||||
|
||||
const nickLen = nickLenInfo.value
|
||||
if (!Number.isFinite(nickLen) || nickLen <= 0 || nickLen > 128) {
|
||||
i = idEnd - 1
|
||||
continue
|
||||
}
|
||||
|
||||
const nickStart = nickLenInfo.next
|
||||
const nickEnd = nickStart + nickLen
|
||||
if (nickEnd > buffer.length) {
|
||||
i = idEnd - 1
|
||||
continue
|
||||
}
|
||||
|
||||
const rawNick = buffer.toString('utf8', nickStart, nickEnd)
|
||||
const nickname = this.normalizeGroupNickname(rawNick.replace(/[\x00-\x1F\x7F]/g, '').trim())
|
||||
if (!this.isLikelyNickname(nickname)) {
|
||||
i = nickEnd - 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (!nicknameMap.has(memberId)) nicknameMap.set(memberId, nickname)
|
||||
if (!nicknameMap.has(memberIdLower)) nicknameMap.set(memberIdLower, nickname)
|
||||
i = nickEnd - 1
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse chat_room.ext_buffer:', e)
|
||||
}
|
||||
|
||||
return nicknameMap
|
||||
}
|
||||
|
||||
private escapeCsvValue(value: string): string {
|
||||
if (value == null) return ''
|
||||
const str = String(value)
|
||||
@@ -127,14 +274,54 @@ class GroupAnalyticsService {
|
||||
return str
|
||||
}
|
||||
|
||||
private normalizeGroupNickname(value: string, wxid: string, fallback: string): string {
|
||||
private normalizeGroupNickname(value: string): string {
|
||||
const trimmed = (value || '').trim()
|
||||
if (!trimmed) return fallback
|
||||
if (/^["'@]+$/.test(trimmed)) return fallback
|
||||
if (trimmed.toLowerCase() === (wxid || '').toLowerCase()) return fallback
|
||||
if (!trimmed) return ''
|
||||
if (/^["'@]+$/.test(trimmed)) return ''
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private buildIdCandidates(values: Array<string | undefined | null>): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const rawValue of values) {
|
||||
const raw = String(rawValue || '').trim()
|
||||
if (!raw) continue
|
||||
set.add(raw)
|
||||
const cleaned = this.cleanAccountDirName(raw)
|
||||
if (cleaned && cleaned !== raw) {
|
||||
set.add(cleaned)
|
||||
}
|
||||
}
|
||||
return Array.from(set)
|
||||
}
|
||||
|
||||
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
|
||||
const idCandidates = this.buildIdCandidates(candidates)
|
||||
if (idCandidates.length === 0) return ''
|
||||
|
||||
for (const id of idCandidates) {
|
||||
const exact = this.normalizeGroupNickname(groupNicknames.get(id) || '')
|
||||
if (exact) return exact
|
||||
}
|
||||
|
||||
for (const id of idCandidates) {
|
||||
const lower = id.toLowerCase()
|
||||
let found = ''
|
||||
let matched = 0
|
||||
for (const [key, value] of groupNicknames.entries()) {
|
||||
if (String(key || '').toLowerCase() !== lower) continue
|
||||
const normalized = this.normalizeGroupNickname(value || '')
|
||||
if (!normalized) continue
|
||||
found = normalized
|
||||
matched += 1
|
||||
if (matched > 1) return ''
|
||||
}
|
||||
if (matched === 1 && found) return found
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
private sanitizeWorksheetName(name: string): string {
|
||||
const cleaned = (name || '').replace(/[*?:\\/\\[\\]]/g, '_').trim()
|
||||
const limited = cleaned.slice(0, 31)
|
||||
@@ -219,15 +406,24 @@ class GroupAnalyticsService {
|
||||
return { success: false, error: result.error || '获取群成员失败' }
|
||||
}
|
||||
|
||||
const members = result.members as { username: string; avatarUrl?: string }[]
|
||||
const members = result.members as Array<{
|
||||
username: string
|
||||
avatarUrl?: string
|
||||
originalName?: string
|
||||
}>
|
||||
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||
|
||||
const [displayNames, groupNicknames] = await Promise.all([
|
||||
wcdbService.getDisplayNames(usernames),
|
||||
this.getGroupNicknamesForRoom(chatroomId)
|
||||
])
|
||||
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
|
||||
|
||||
const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>()
|
||||
const contactMap = new Map<string, {
|
||||
remark?: string
|
||||
nickName?: string
|
||||
alias?: string
|
||||
username?: string
|
||||
userName?: string
|
||||
encryptUsername?: string
|
||||
encryptUserName?: string
|
||||
}>()
|
||||
const concurrency = 6
|
||||
await this.parallelLimit(usernames, concurrency, async (username) => {
|
||||
const contactResult = await wcdbService.getContact(username)
|
||||
@@ -236,13 +432,29 @@ class GroupAnalyticsService {
|
||||
contactMap.set(username, {
|
||||
remark: contact.remark || '',
|
||||
nickName: contact.nickName || contact.nick_name || '',
|
||||
alias: contact.alias || ''
|
||||
alias: contact.alias || '',
|
||||
username: contact.username || '',
|
||||
userName: contact.userName || contact.user_name || '',
|
||||
encryptUsername: contact.encryptUsername || contact.encrypt_username || '',
|
||||
encryptUserName: contact.encryptUserName || ''
|
||||
})
|
||||
} else {
|
||||
contactMap.set(username, { remark: '', nickName: '', alias: '' })
|
||||
}
|
||||
})
|
||||
|
||||
const displayNames = await displayNamesPromise
|
||||
const nicknameCandidates = this.buildIdCandidates([
|
||||
...members.map((m) => m.username),
|
||||
...members.map((m) => m.originalName),
|
||||
...Array.from(contactMap.values()).map((c) => c?.username),
|
||||
...Array.from(contactMap.values()).map((c) => c?.userName),
|
||||
...Array.from(contactMap.values()).map((c) => c?.encryptUsername),
|
||||
...Array.from(contactMap.values()).map((c) => c?.encryptUserName),
|
||||
...Array.from(contactMap.values()).map((c) => c?.alias)
|
||||
])
|
||||
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||
|
||||
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||
const data: GroupMember[] = members.map((m) => {
|
||||
const wxid = m.username || ''
|
||||
@@ -251,13 +463,20 @@ class GroupAnalyticsService {
|
||||
const nickname = contact?.nickName || ''
|
||||
const remark = contact?.remark || ''
|
||||
const alias = contact?.alias || ''
|
||||
const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || ''
|
||||
const normalizedWxid = this.cleanAccountDirName(wxid)
|
||||
const groupNickname = this.normalizeGroupNickname(
|
||||
rawGroupNickname,
|
||||
normalizedWxid === myWxid ? myWxid : wxid,
|
||||
''
|
||||
)
|
||||
const lookupCandidates = this.buildIdCandidates([
|
||||
wxid,
|
||||
m.originalName,
|
||||
contact?.username,
|
||||
contact?.userName,
|
||||
contact?.encryptUsername,
|
||||
contact?.encryptUserName,
|
||||
alias
|
||||
])
|
||||
if (normalizedWxid === myWxid) {
|
||||
lookupCandidates.push(myWxid)
|
||||
}
|
||||
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
|
||||
|
||||
return {
|
||||
username: wxid,
|
||||
@@ -418,18 +637,27 @@ class GroupAnalyticsService {
|
||||
return { success: false, error: membersResult.error || '获取群成员失败' }
|
||||
}
|
||||
|
||||
const members = membersResult.members as { username: string; avatarUrl?: string }[]
|
||||
const members = membersResult.members as Array<{
|
||||
username: string
|
||||
avatarUrl?: string
|
||||
originalName?: string
|
||||
}>
|
||||
if (members.length === 0) {
|
||||
return { success: false, error: '群成员为空' }
|
||||
}
|
||||
|
||||
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||
const [displayNames, groupNicknames] = await Promise.all([
|
||||
wcdbService.getDisplayNames(usernames),
|
||||
this.getGroupNicknamesForRoom(chatroomId)
|
||||
])
|
||||
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
|
||||
|
||||
const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>()
|
||||
const contactMap = new Map<string, {
|
||||
remark?: string
|
||||
nickName?: string
|
||||
alias?: string
|
||||
username?: string
|
||||
userName?: string
|
||||
encryptUsername?: string
|
||||
encryptUserName?: string
|
||||
}>()
|
||||
const concurrency = 6
|
||||
await this.parallelLimit(usernames, concurrency, async (username) => {
|
||||
const result = await wcdbService.getContact(username)
|
||||
@@ -438,7 +666,11 @@ class GroupAnalyticsService {
|
||||
contactMap.set(username, {
|
||||
remark: contact.remark || '',
|
||||
nickName: contact.nickName || contact.nick_name || '',
|
||||
alias: contact.alias || ''
|
||||
alias: contact.alias || '',
|
||||
username: contact.username || '',
|
||||
userName: contact.userName || contact.user_name || '',
|
||||
encryptUsername: contact.encryptUsername || contact.encrypt_username || '',
|
||||
encryptUserName: contact.encryptUserName || ''
|
||||
})
|
||||
} else {
|
||||
contactMap.set(username, { remark: '', nickName: '', alias: '' })
|
||||
@@ -453,6 +685,18 @@ class GroupAnalyticsService {
|
||||
const rows: string[][] = [infoTitleRow, infoRow, metaRow, header]
|
||||
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||
|
||||
const displayNames = await displayNamesPromise
|
||||
const nicknameCandidates = this.buildIdCandidates([
|
||||
...members.map((m) => m.username),
|
||||
...members.map((m) => m.originalName),
|
||||
...Array.from(contactMap.values()).map((c) => c?.username),
|
||||
...Array.from(contactMap.values()).map((c) => c?.userName),
|
||||
...Array.from(contactMap.values()).map((c) => c?.encryptUsername),
|
||||
...Array.from(contactMap.values()).map((c) => c?.encryptUserName),
|
||||
...Array.from(contactMap.values()).map((c) => c?.alias)
|
||||
])
|
||||
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||
|
||||
for (const member of members) {
|
||||
const wxid = member.username
|
||||
const normalizedWxid = this.cleanAccountDirName(wxid || '')
|
||||
@@ -460,13 +704,20 @@ class GroupAnalyticsService {
|
||||
const fallbackName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || '') : ''
|
||||
const nickName = contact?.nickName || fallbackName || ''
|
||||
const remark = contact?.remark || ''
|
||||
const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || ''
|
||||
const alias = contact?.alias || ''
|
||||
const groupNickname = this.normalizeGroupNickname(
|
||||
rawGroupNickname,
|
||||
normalizedWxid === myWxid ? myWxid : wxid,
|
||||
''
|
||||
)
|
||||
const lookupCandidates = this.buildIdCandidates([
|
||||
wxid,
|
||||
member.originalName,
|
||||
contact?.username,
|
||||
contact?.userName,
|
||||
contact?.encryptUsername,
|
||||
contact?.encryptUserName,
|
||||
alias
|
||||
])
|
||||
if (normalizedWxid === myWxid) {
|
||||
lookupCandidates.push(myWxid)
|
||||
}
|
||||
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
|
||||
|
||||
rows.push([nickName, remark, groupNickname, wxid, alias])
|
||||
}
|
||||
|
||||
745
electron/services/httpService.ts
Normal file
745
electron/services/httpService.ts
Normal file
@@ -0,0 +1,745 @@
|
||||
/**
|
||||
* HTTP API 服务
|
||||
* 提供 ChatLab 标准化格式的消息查询 API
|
||||
*/
|
||||
import * as http from 'http'
|
||||
import { URL } from 'url'
|
||||
import { chatService, Message } from './chatService'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
// ChatLab 格式定义
|
||||
interface ChatLabHeader {
|
||||
version: string
|
||||
exportedAt: number
|
||||
generator: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface ChatLabMeta {
|
||||
name: string
|
||||
platform: string
|
||||
type: 'group' | 'private'
|
||||
groupId?: string
|
||||
groupAvatar?: string
|
||||
ownerId?: string
|
||||
}
|
||||
|
||||
interface ChatLabMember {
|
||||
platformId: string
|
||||
accountName: string
|
||||
groupNickname?: string
|
||||
aliases?: string[]
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
interface ChatLabMessage {
|
||||
sender: string
|
||||
accountName: string
|
||||
groupNickname?: string
|
||||
timestamp: number
|
||||
type: number
|
||||
content: string | null
|
||||
platformMessageId?: string
|
||||
replyToMessageId?: string
|
||||
}
|
||||
|
||||
interface ChatLabData {
|
||||
chatlab: ChatLabHeader
|
||||
meta: ChatLabMeta
|
||||
members: ChatLabMember[]
|
||||
messages: ChatLabMessage[]
|
||||
}
|
||||
|
||||
// ChatLab 消息类型映射
|
||||
const ChatLabType = {
|
||||
TEXT: 0,
|
||||
IMAGE: 1,
|
||||
VOICE: 2,
|
||||
VIDEO: 3,
|
||||
FILE: 4,
|
||||
EMOJI: 5,
|
||||
LINK: 7,
|
||||
LOCATION: 8,
|
||||
RED_PACKET: 20,
|
||||
TRANSFER: 21,
|
||||
POKE: 22,
|
||||
CALL: 23,
|
||||
SHARE: 24,
|
||||
REPLY: 25,
|
||||
FORWARD: 26,
|
||||
CONTACT: 27,
|
||||
SYSTEM: 80,
|
||||
RECALL: 81,
|
||||
OTHER: 99
|
||||
} as const
|
||||
|
||||
class HttpService {
|
||||
private server: http.Server | null = null
|
||||
private configService: ConfigService
|
||||
private port: number = 5031
|
||||
private running: boolean = false
|
||||
private connections: Set<import('net').Socket> = new Set()
|
||||
|
||||
constructor() {
|
||||
this.configService = ConfigService.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 HTTP 服务
|
||||
*/
|
||||
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> {
|
||||
if (this.running && this.server) {
|
||||
return { success: true, port: this.port }
|
||||
}
|
||||
|
||||
this.port = port
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.server = http.createServer((req, res) => this.handleRequest(req, res))
|
||||
|
||||
// 跟踪所有连接,以便关闭时能强制断开
|
||||
this.server.on('connection', (socket) => {
|
||||
this.connections.add(socket)
|
||||
socket.on('close', () => {
|
||||
this.connections.delete(socket)
|
||||
})
|
||||
})
|
||||
|
||||
this.server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`[HttpService] Port ${this.port} is already in use`)
|
||||
resolve({ success: false, error: `Port ${this.port} is already in use` })
|
||||
} else {
|
||||
console.error('[HttpService] Server error:', err)
|
||||
resolve({ success: false, error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
this.server.listen(this.port, '127.0.0.1', () => {
|
||||
this.running = true
|
||||
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
|
||||
resolve({ success: true, port: this.port })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止 HTTP 服务
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.server) {
|
||||
// 强制关闭所有活动连接
|
||||
for (const socket of this.connections) {
|
||||
socket.destroy()
|
||||
}
|
||||
this.connections.clear()
|
||||
|
||||
this.server.close(() => {
|
||||
this.running = false
|
||||
this.server = null
|
||||
console.log('[HttpService] HTTP API server stopped')
|
||||
resolve()
|
||||
})
|
||||
} else {
|
||||
this.running = false
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查服务是否运行
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.running
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前端口
|
||||
*/
|
||||
getPort(): number {
|
||||
return this.port
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 请求
|
||||
*/
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
// 设置 CORS 头
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
|
||||
const pathname = url.pathname
|
||||
|
||||
try {
|
||||
// 路由处理
|
||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||
this.sendJson(res, { status: 'ok' })
|
||||
} else if (pathname === '/api/v1/messages') {
|
||||
await this.handleMessages(url, res)
|
||||
} else if (pathname === '/api/v1/sessions') {
|
||||
await this.handleSessions(url, res)
|
||||
} else if (pathname === '/api/v1/contacts') {
|
||||
await this.handleContacts(url, res)
|
||||
} else {
|
||||
this.sendError(res, 404, 'Not Found')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HttpService] Request error:', error)
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取消息(循环游标直到满足 limit)
|
||||
* 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标
|
||||
*/
|
||||
private async fetchMessagesBatch(
|
||||
talker: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
ascending: boolean
|
||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||
try {
|
||||
// 使用固定 batch 大小(与 limit 相同或最大 500)来减少循环次数
|
||||
const batchSize = Math.min(limit, 500)
|
||||
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
||||
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
||||
|
||||
const cursorResult = await wcdbService.openMessageCursor(talker, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
||||
}
|
||||
|
||||
const cursor = cursorResult.cursor
|
||||
try {
|
||||
const allRows: Record<string, any>[] = []
|
||||
let hasMore = true
|
||||
let skipped = 0
|
||||
|
||||
// 循环获取消息,处理 offset 跳过 + limit 累积
|
||||
while (allRows.length < limit && hasMore) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||
if (!batch.success || !batch.rows || batch.rows.length === 0) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
|
||||
let rows = batch.rows
|
||||
hasMore = batch.hasMore === true
|
||||
|
||||
// 处理 offset: 跳过前 N 条
|
||||
if (skipped < offset) {
|
||||
const remaining = offset - skipped
|
||||
if (remaining >= rows.length) {
|
||||
skipped += rows.length
|
||||
continue
|
||||
}
|
||||
rows = rows.slice(remaining)
|
||||
skipped = offset
|
||||
}
|
||||
|
||||
allRows.push(...rows)
|
||||
}
|
||||
|
||||
const trimmedRows = allRows.slice(0, limit)
|
||||
const finalHasMore = hasMore || allRows.length > limit
|
||||
const messages = this.mapRowsToMessagesSimple(trimmedRows)
|
||||
return { success: true, messages, hasMore: finalHasMore }
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursor)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HttpService] fetchMessagesBatch error:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的行数据到 Message 映射(用于 API 输出)
|
||||
*/
|
||||
private mapRowsToMessagesSimple(rows: Record<string, any>[]): Message[] {
|
||||
const myWxid = this.configService.get('myWxid') || ''
|
||||
const messages: Message[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
const content = this.getField(row, ['message_content', 'messageContent', 'content', 'msg_content', 'WCDB_CT_message_content']) || ''
|
||||
const localType = parseInt(this.getField(row, ['local_type', 'localType', 'type', 'msg_type', 'WCDB_CT_local_type']) || '1', 10)
|
||||
const isSendRaw = this.getField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
||||
const senderUsername = this.getField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || ''
|
||||
const createTime = parseInt(this.getField(row, ['create_time', 'createTime', 'msg_create_time', 'WCDB_CT_create_time']) || '0', 10)
|
||||
const localId = parseInt(this.getField(row, ['local_id', 'localId', 'WCDB_CT_local_id', 'rowid']) || '0', 10)
|
||||
const serverId = this.getField(row, ['server_id', 'serverId', 'WCDB_CT_server_id']) || ''
|
||||
|
||||
let isSend: number
|
||||
if (isSendRaw !== null && isSendRaw !== undefined) {
|
||||
isSend = parseInt(isSendRaw, 10)
|
||||
} else if (senderUsername && myWxid) {
|
||||
isSend = senderUsername.toLowerCase() === myWxid.toLowerCase() ? 1 : 0
|
||||
} else {
|
||||
isSend = 0
|
||||
}
|
||||
|
||||
// 解析消息内容中的特殊字段
|
||||
let parsedContent = content
|
||||
let xmlType: string | undefined
|
||||
let linkTitle: string | undefined
|
||||
let fileName: string | undefined
|
||||
let emojiCdnUrl: string | undefined
|
||||
let emojiMd5: string | undefined
|
||||
let imageMd5: string | undefined
|
||||
let videoMd5: string | undefined
|
||||
let cardNickname: string | undefined
|
||||
|
||||
if (localType === 49 && content) {
|
||||
// 提取 type 子标签
|
||||
const typeMatch = /<type>(\d+)<\/type>/i.exec(content)
|
||||
if (typeMatch) xmlType = typeMatch[1]
|
||||
// 提取 title
|
||||
const titleMatch = /<title>([^<]*)<\/title>/i.exec(content)
|
||||
if (titleMatch) linkTitle = titleMatch[1]
|
||||
// 提取文件名
|
||||
const fnMatch = /<title>([^<]*)<\/title>/i.exec(content)
|
||||
if (fnMatch) fileName = fnMatch[1]
|
||||
}
|
||||
|
||||
if (localType === 47 && content) {
|
||||
const cdnMatch = /cdnurl\s*=\s*"([^"]+)"/i.exec(content)
|
||||
if (cdnMatch) emojiCdnUrl = cdnMatch[1]
|
||||
const md5Match = /md5\s*=\s*"([^"]+)"/i.exec(content)
|
||||
if (md5Match) emojiMd5 = md5Match[1]
|
||||
}
|
||||
|
||||
messages.push({
|
||||
localId,
|
||||
talker: '',
|
||||
localType,
|
||||
createTime,
|
||||
sortSeq: createTime,
|
||||
content: parsedContent,
|
||||
isSend,
|
||||
senderUsername,
|
||||
serverId: serverId ? parseInt(serverId, 10) || 0 : 0,
|
||||
rawContent: content,
|
||||
parsedContent: content,
|
||||
emojiCdnUrl,
|
||||
emojiMd5,
|
||||
imageMd5,
|
||||
videoMd5,
|
||||
xmlType,
|
||||
linkTitle,
|
||||
fileName,
|
||||
cardNickname
|
||||
} as Message)
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* 从行数据中获取字段值(兼容多种字段名)
|
||||
*/
|
||||
private getField(row: Record<string, any>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
if (row[key] !== undefined && row[key] !== null) {
|
||||
return String(row[key])
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息查询
|
||||
* GET /api/v1/messages?talker=xxx&limit=100&start=20260101&chatlab=1
|
||||
*/
|
||||
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const talker = url.searchParams.get('talker')
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '100', 10), 10000)
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
|
||||
const startParam = url.searchParams.get('start')
|
||||
const endParam = url.searchParams.get('end')
|
||||
const chatlab = url.searchParams.get('chatlab') === '1'
|
||||
const formatParam = url.searchParams.get('format')
|
||||
const format = formatParam || (chatlab ? 'chatlab' : 'json')
|
||||
|
||||
if (!talker) {
|
||||
this.sendError(res, 400, 'Missing required parameter: talker')
|
||||
return
|
||||
}
|
||||
|
||||
// 解析时间参数 (支持 YYYYMMDD 格式)
|
||||
const startTime = this.parseTimeParam(startParam)
|
||||
const endTime = this.parseTimeParam(endParam, true)
|
||||
|
||||
// 使用批量获取方法,绕过 chatService 的单 batch 限制
|
||||
const result = await this.fetchMessagesBatch(talker, offset, limit, startTime, endTime, true)
|
||||
if (!result.success || !result.messages) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||
return
|
||||
}
|
||||
|
||||
if (format === 'chatlab') {
|
||||
// 获取会话显示名
|
||||
const displayNames = await this.getDisplayNames([talker])
|
||||
const talkerName = displayNames[talker] || talker
|
||||
|
||||
const chatLabData = await this.convertToChatLab(result.messages, talker, talkerName)
|
||||
this.sendJson(res, chatLabData)
|
||||
} else {
|
||||
// 返回原始消息格式
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
talker,
|
||||
count: result.messages.length,
|
||||
hasMore: result.hasMore,
|
||||
messages: result.messages
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理会话列表查询
|
||||
* GET /api/v1/sessions?keyword=xxx&limit=100
|
||||
*/
|
||||
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const keyword = url.searchParams.get('keyword') || ''
|
||||
const limit = parseInt(url.searchParams.get('limit') || '100', 10)
|
||||
|
||||
try {
|
||||
const sessions = await chatService.getSessions()
|
||||
if (!sessions.success || !sessions.sessions) {
|
||||
this.sendError(res, 500, sessions.error || 'Failed to get sessions')
|
||||
return
|
||||
}
|
||||
|
||||
let filteredSessions = sessions.sessions
|
||||
if (keyword) {
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
filteredSessions = sessions.sessions.filter(s =>
|
||||
s.username.toLowerCase().includes(lowerKeyword) ||
|
||||
(s.displayName && s.displayName.toLowerCase().includes(lowerKeyword))
|
||||
)
|
||||
}
|
||||
|
||||
// 应用 limit
|
||||
const limitedSessions = filteredSessions.slice(0, limit)
|
||||
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
count: limitedSessions.length,
|
||||
sessions: limitedSessions.map(s => ({
|
||||
username: s.username,
|
||||
displayName: s.displayName,
|
||||
type: s.type,
|
||||
lastTimestamp: s.lastTimestamp,
|
||||
unreadCount: s.unreadCount
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理联系人查询
|
||||
* GET /api/v1/contacts?keyword=xxx&limit=100
|
||||
*/
|
||||
private async handleContacts(url: URL, res: http.ServerResponse): Promise<void> {
|
||||
const keyword = url.searchParams.get('keyword') || ''
|
||||
const limit = parseInt(url.searchParams.get('limit') || '100', 10)
|
||||
|
||||
try {
|
||||
const contacts = await chatService.getContacts()
|
||||
if (!contacts.success || !contacts.contacts) {
|
||||
this.sendError(res, 500, contacts.error || 'Failed to get contacts')
|
||||
return
|
||||
}
|
||||
|
||||
let filteredContacts = contacts.contacts
|
||||
if (keyword) {
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
filteredContacts = contacts.contacts.filter(c =>
|
||||
c.username.toLowerCase().includes(lowerKeyword) ||
|
||||
(c.nickname && c.nickname.toLowerCase().includes(lowerKeyword)) ||
|
||||
(c.remark && c.remark.toLowerCase().includes(lowerKeyword)) ||
|
||||
(c.displayName && c.displayName.toLowerCase().includes(lowerKeyword))
|
||||
)
|
||||
}
|
||||
|
||||
const limited = filteredContacts.slice(0, limit)
|
||||
|
||||
this.sendJson(res, {
|
||||
success: true,
|
||||
count: limited.length,
|
||||
contacts: limited
|
||||
})
|
||||
} catch (error) {
|
||||
this.sendError(res, 500, String(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析时间参数
|
||||
* 支持 YYYYMMDD 格式,返回秒级时间戳
|
||||
*/
|
||||
private parseTimeParam(param: string | null, isEnd: boolean = false): number {
|
||||
if (!param) return 0
|
||||
|
||||
// 纯数字且长度为8,视为 YYYYMMDD
|
||||
if (/^\d{8}$/.test(param)) {
|
||||
const year = parseInt(param.slice(0, 4), 10)
|
||||
const month = parseInt(param.slice(4, 6), 10) - 1
|
||||
const day = parseInt(param.slice(6, 8), 10)
|
||||
const date = new Date(year, month, day)
|
||||
if (isEnd) {
|
||||
// 结束时间设为当天 23:59:59
|
||||
date.setHours(23, 59, 59, 999)
|
||||
}
|
||||
return Math.floor(date.getTime() / 1000)
|
||||
}
|
||||
|
||||
// 纯数字,视为时间戳
|
||||
if (/^\d+$/.test(param)) {
|
||||
const ts = parseInt(param, 10)
|
||||
// 如果是毫秒级时间戳,转为秒级
|
||||
return ts > 10000000000 ? Math.floor(ts / 1000) : ts
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取显示名称
|
||||
*/
|
||||
private async getDisplayNames(usernames: string[]): Promise<Record<string, string>> {
|
||||
try {
|
||||
const result = await wcdbService.getDisplayNames(usernames)
|
||||
if (result.success && result.map) {
|
||||
return result.map
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HttpService] Failed to get display names:', e)
|
||||
}
|
||||
// 返回空对象,调用方会使用 username 作为备用
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 ChatLab 格式
|
||||
*/
|
||||
private async convertToChatLab(messages: Message[], talkerId: string, talkerName: string): Promise<ChatLabData> {
|
||||
const isGroup = talkerId.endsWith('@chatroom')
|
||||
const myWxid = this.configService.get('myWxid') || ''
|
||||
|
||||
// 收集所有发送者
|
||||
const senderSet = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
if (msg.senderUsername) {
|
||||
senderSet.add(msg.senderUsername)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取发送者显示名
|
||||
const senderNames = await this.getDisplayNames(Array.from(senderSet))
|
||||
|
||||
// 获取群昵称(如果是群聊)
|
||||
let groupNicknamesMap = new Map<string, string>()
|
||||
if (isGroup) {
|
||||
try {
|
||||
const result = await wcdbService.getGroupNicknames(talkerId)
|
||||
if (result.success && result.nicknames) {
|
||||
groupNicknamesMap = new Map(Object.entries(result.nicknames))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HttpService] Failed to get group nicknames:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建成员列表
|
||||
const memberMap = new Map<string, ChatLabMember>()
|
||||
for (const msg of messages) {
|
||||
const sender = msg.senderUsername || ''
|
||||
if (sender && !memberMap.has(sender)) {
|
||||
const displayName = senderNames[sender] || sender
|
||||
const isSelf = sender === myWxid || sender.toLowerCase() === myWxid.toLowerCase()
|
||||
// 获取群昵称(尝试多种方式)
|
||||
const groupNickname = isGroup
|
||||
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
|
||||
: ''
|
||||
memberMap.set(sender, {
|
||||
platformId: sender,
|
||||
accountName: isSelf ? '我' : displayName,
|
||||
groupNickname: groupNickname || undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 转换消息
|
||||
const chatLabMessages: ChatLabMessage[] = messages.map(msg => {
|
||||
const sender = msg.senderUsername || ''
|
||||
const isSelf = msg.isSend === 1 || sender === myWxid
|
||||
const accountName = isSelf ? '我' : (senderNames[sender] || sender)
|
||||
// 获取该发送者的群昵称
|
||||
const groupNickname = isGroup
|
||||
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
|
||||
: ''
|
||||
|
||||
return {
|
||||
sender,
|
||||
accountName,
|
||||
groupNickname: groupNickname || undefined,
|
||||
timestamp: msg.createTime,
|
||||
type: this.mapMessageType(msg.localType, msg),
|
||||
content: this.getMessageContent(msg),
|
||||
platformMessageId: msg.serverId ? String(msg.serverId) : undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
chatlab: {
|
||||
version: '0.0.2',
|
||||
exportedAt: Math.floor(Date.now() / 1000),
|
||||
generator: 'WeFlow'
|
||||
},
|
||||
meta: {
|
||||
name: talkerName,
|
||||
platform: 'wechat',
|
||||
type: isGroup ? 'group' : 'private',
|
||||
groupId: isGroup ? talkerId : undefined,
|
||||
ownerId: myWxid || undefined
|
||||
},
|
||||
members: Array.from(memberMap.values()),
|
||||
messages: chatLabMessages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射 WeChat 消息类型到 ChatLab 类型
|
||||
*/
|
||||
private mapMessageType(localType: number, msg: Message): number {
|
||||
switch (localType) {
|
||||
case 1: // 文本
|
||||
return ChatLabType.TEXT
|
||||
case 3: // 图片
|
||||
return ChatLabType.IMAGE
|
||||
case 34: // 语音
|
||||
return ChatLabType.VOICE
|
||||
case 43: // 视频
|
||||
return ChatLabType.VIDEO
|
||||
case 47: // 动画表情
|
||||
return ChatLabType.EMOJI
|
||||
case 48: // 位置
|
||||
return ChatLabType.LOCATION
|
||||
case 42: // 名片
|
||||
return ChatLabType.CONTACT
|
||||
case 50: // 语音/视频通话
|
||||
return ChatLabType.CALL
|
||||
case 10000: // 系统消息
|
||||
return ChatLabType.SYSTEM
|
||||
case 49: // 复合消息
|
||||
return this.mapType49(msg)
|
||||
case 244813135921: // 引用消息
|
||||
return ChatLabType.REPLY
|
||||
case 266287972401: // 拍一拍
|
||||
return ChatLabType.POKE
|
||||
case 8594229559345: // 红包
|
||||
return ChatLabType.RED_PACKET
|
||||
case 8589934592049: // 转账
|
||||
return ChatLabType.TRANSFER
|
||||
default:
|
||||
return ChatLabType.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射 Type 49 子类型
|
||||
*/
|
||||
private mapType49(msg: Message): number {
|
||||
const xmlType = msg.xmlType
|
||||
|
||||
switch (xmlType) {
|
||||
case '5': // 链接
|
||||
case '49':
|
||||
return ChatLabType.LINK
|
||||
case '6': // 文件
|
||||
return ChatLabType.FILE
|
||||
case '19': // 聊天记录
|
||||
return ChatLabType.FORWARD
|
||||
case '33': // 小程序
|
||||
case '36':
|
||||
return ChatLabType.SHARE
|
||||
case '57': // 引用消息
|
||||
return ChatLabType.REPLY
|
||||
case '2000': // 转账
|
||||
return ChatLabType.TRANSFER
|
||||
case '2001': // 红包
|
||||
return ChatLabType.RED_PACKET
|
||||
default:
|
||||
return ChatLabType.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息内容
|
||||
*/
|
||||
private getMessageContent(msg: Message): string | null {
|
||||
// 优先使用已解析的内容
|
||||
if (msg.parsedContent) {
|
||||
return msg.parsedContent
|
||||
}
|
||||
|
||||
// 根据类型返回占位符
|
||||
switch (msg.localType) {
|
||||
case 1:
|
||||
return msg.rawContent || null
|
||||
case 3:
|
||||
return msg.imageMd5 || '[图片]'
|
||||
case 34:
|
||||
return '[语音]'
|
||||
case 43:
|
||||
return msg.videoMd5 || '[视频]'
|
||||
case 47:
|
||||
return msg.emojiCdnUrl || msg.emojiMd5 || '[表情]'
|
||||
case 42:
|
||||
return msg.cardNickname || '[名片]'
|
||||
case 48:
|
||||
return '[位置]'
|
||||
case 49:
|
||||
return msg.linkTitle || msg.fileName || '[消息]'
|
||||
default:
|
||||
return msg.rawContent || null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 JSON 响应
|
||||
*/
|
||||
private sendJson(res: http.ServerResponse, data: any): void {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.writeHead(200)
|
||||
res.end(JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误响应
|
||||
*/
|
||||
private sendError(res: http.ServerResponse, code: number, message: string): void {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.writeHead(code)
|
||||
res.end(JSON.stringify({ error: message }))
|
||||
}
|
||||
}
|
||||
|
||||
export const httpService = new HttpService()
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "1.5.2",
|
||||
"version": "1.5.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "weflow",
|
||||
"version": "1.5.2",
|
||||
"version": "1.5.4",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "1.5.2",
|
||||
"version": "1.5.4",
|
||||
"description": "WeFlow",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": "cc",
|
||||
|
||||
11
src/App.scss
11
src/App.scss
@@ -6,6 +6,17 @@
|
||||
animation: appFadeIn 0.35s ease-out;
|
||||
}
|
||||
|
||||
.window-drag-region {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 150px; // 预留系统最小化/最大化/关闭按钮区域
|
||||
height: 40px;
|
||||
-webkit-app-region: drag;
|
||||
pointer-events: auto;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -34,6 +34,7 @@ import UpdateDialog from './components/UpdateDialog'
|
||||
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||
import LockScreen from './components/LockScreen'
|
||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate()
|
||||
@@ -345,6 +346,7 @@ function App() {
|
||||
// 主窗口 - 完整布局
|
||||
return (
|
||||
<div className="app-container">
|
||||
<div className="window-drag-region" aria-hidden="true" />
|
||||
{isLocked && (
|
||||
<LockScreen
|
||||
onUnlock={() => setLocked(false)}
|
||||
@@ -360,6 +362,9 @@ function App() {
|
||||
{/* 全局会话监听与通知 */}
|
||||
<GlobalSessionMonitor />
|
||||
|
||||
{/* 全局批量转写进度浮窗 */}
|
||||
<BatchTranscribeGlobal />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
{showAgreement && !agreementLoading && (
|
||||
<div className="agreement-overlay">
|
||||
|
||||
102
src/components/BatchTranscribeGlobal.tsx
Normal file
102
src/components/BatchTranscribeGlobal.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Loader2, X, CheckCircle, XCircle, AlertCircle } from 'lucide-react'
|
||||
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||
import '../styles/batchTranscribe.scss'
|
||||
|
||||
/**
|
||||
* 全局批量转写进度浮窗 + 结果弹窗
|
||||
* 挂载在 App 层,切换页面时不会消失
|
||||
*/
|
||||
export const BatchTranscribeGlobal: React.FC = () => {
|
||||
const {
|
||||
isBatchTranscribing,
|
||||
progress,
|
||||
showToast,
|
||||
showResult,
|
||||
result,
|
||||
sessionName,
|
||||
setShowToast,
|
||||
setShowResult
|
||||
} = useBatchTranscribeStore()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 批量转写进度浮窗(非阻塞) */}
|
||||
{showToast && isBatchTranscribing && createPortal(
|
||||
<div className="batch-progress-toast">
|
||||
<div className="batch-progress-toast-header">
|
||||
<div className="batch-progress-toast-title">
|
||||
<Loader2 size={14} className="spin" />
|
||||
<span>批量转写中{sessionName ? `(${sessionName})` : ''}</span>
|
||||
</div>
|
||||
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="batch-progress-toast-body">
|
||||
<div className="progress-text">
|
||||
<span>{progress.current} / {progress.total}</span>
|
||||
<span className="progress-percent">
|
||||
{progress.total > 0
|
||||
? Math.round((progress.current / progress.total) * 100)
|
||||
: 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{
|
||||
width: `${progress.total > 0
|
||||
? (progress.current / progress.total) * 100
|
||||
: 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* 批量转写结果对话框 */}
|
||||
{showResult && createPortal(
|
||||
<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>
|
||||
</div>
|
||||
<div className="batch-modal-body">
|
||||
<div className="result-summary">
|
||||
<div className="result-item success">
|
||||
<CheckCircle size={18} />
|
||||
<span className="label">成功:</span>
|
||||
<span className="value">{result.success} 条</span>
|
||||
</div>
|
||||
{result.fail > 0 && (
|
||||
<div className="result-item fail">
|
||||
<XCircle size={18} />
|
||||
<span className="label">失败:</span>
|
||||
<span className="value">{result.fail} 条</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{result.fail > 0 && (
|
||||
<div className="result-tip">
|
||||
<AlertCircle size={16} />
|
||||
<span>部分语音转写失败,可能是语音文件损坏或网络问题</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="batch-modal-footer">
|
||||
<button className="btn-primary" onClick={() => setShowResult(false)}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -81,6 +81,11 @@ export function GlobalSessionMonitor() {
|
||||
}
|
||||
|
||||
const checkForNewMessages = async (oldSessions: ChatSession[], newSessions: ChatSession[]) => {
|
||||
if (!oldSessions || oldSessions.length === 0) {
|
||||
console.log('[NotificationFilter] Skipping check on initial load (empty baseline)')
|
||||
return
|
||||
}
|
||||
|
||||
const oldMap = new Map(oldSessions.map(s => [s.username, s]))
|
||||
|
||||
for (const newSession of newSessions) {
|
||||
@@ -140,6 +145,14 @@ export function GlobalSessionMonitor() {
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:如果未读数量没有增加,说明可能是自己在其他设备回复(或者已读),不弹通知
|
||||
const oldUnread = oldSession ? oldSession.unreadCount : 0
|
||||
const newUnread = newSession.unreadCount
|
||||
if (newUnread <= oldUnread) {
|
||||
// 仅仅是状态同步(如自己在手机上发消息 or 已读),跳过通知
|
||||
continue
|
||||
}
|
||||
|
||||
let title = newSession.displayName || newSession.username
|
||||
let avatarUrl = newSession.avatarUrl
|
||||
let content = newSession.summary || '[新消息]'
|
||||
|
||||
@@ -2016,12 +2016,43 @@
|
||||
text-align: right;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
|
||||
&.highlight {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .copy-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.table-list {
|
||||
@@ -2458,6 +2489,13 @@
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.transfer-desc {
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
opacity: 0.9;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.transfer-memo {
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
@@ -2572,3 +2610,210 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量转写按钮
|
||||
.batch-transcribe-btn {
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
&.transcribing {
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 批量转写模态框基础样式(共享样式在 styles/batchTranscribe.scss)
|
||||
|
||||
// 批量转写确认对话框
|
||||
.batch-confirm-modal {
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
|
||||
.batch-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
svg { color: var(--primary-color); }
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.batch-modal-body {
|
||||
padding: 1.5rem;
|
||||
|
||||
p {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.batch-dates-list-wrap {
|
||||
margin-bottom: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.batch-dates-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
|
||||
.batch-dates-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 12px;
|
||||
color: var(--primary-color);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.batch-dates-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
|
||||
li {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.batch-date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: var(--bg-hover); }
|
||||
|
||||
input[type="checkbox"] {
|
||||
accent-color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.batch-date-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.batch-date-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.batch-info {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.batch-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 152, 0, 0.3);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.batch-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: none;
|
||||
|
||||
&.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
&:hover { background: var(--border-color); }
|
||||
}
|
||||
|
||||
&.btn-primary, &.batch-transcribe-start-btn {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
&:hover { opacity: 0.9; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link } from 'lucide-react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, Download, BarChart3 } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||
import type { ChatSession, Message } from '../types/models'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||
@@ -116,6 +118,8 @@ const SessionItem = React.memo(function SessionItem({
|
||||
|
||||
|
||||
function ChatPage(_props: ChatPageProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
@@ -168,12 +172,21 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const [showDetailPanel, setShowDetailPanel] = useState(false)
|
||||
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
||||
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
||||
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
||||
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
||||
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
||||
|
||||
// 批量语音转文字相关状态(进度/结果 由全局 store 管理)
|
||||
const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore()
|
||||
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
|
||||
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
|
||||
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
|
||||
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([])
|
||||
const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set())
|
||||
|
||||
// 联系人信息加载控制
|
||||
const isEnrichingRef = useRef(false)
|
||||
const enrichCancelledRef = useRef(false)
|
||||
@@ -231,6 +244,25 @@ function ChatPage(_props: ChatPageProps) {
|
||||
setShowDetailPanel(!showDetailPanel)
|
||||
}, [showDetailPanel, currentSessionId, loadSessionDetail])
|
||||
|
||||
// 复制字段值到剪贴板
|
||||
const handleCopyField = useCallback(async (text: string, field: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedField(field)
|
||||
setTimeout(() => setCopiedField(null), 1500)
|
||||
} catch {
|
||||
// fallback
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
setCopiedField(field)
|
||||
setTimeout(() => setCopiedField(null), 1500)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 连接数据库
|
||||
const connect = useCallback(async () => {
|
||||
setConnecting(true)
|
||||
@@ -1183,6 +1215,167 @@ function ChatPage(_props: ChatPageProps) {
|
||||
setShowVoiceTranscribeDialog(true)
|
||||
}, [])
|
||||
|
||||
// 批量语音转文字
|
||||
const handleBatchTranscribe = useCallback(async () => {
|
||||
if (!currentSessionId) return
|
||||
const session = sessions.find(s => s.username === currentSessionId)
|
||||
if (!session) {
|
||||
alert('未找到当前会话')
|
||||
return
|
||||
}
|
||||
if (isBatchTranscribing) return
|
||||
|
||||
const result = await window.electronAPI.chat.getAllVoiceMessages(currentSessionId)
|
||||
if (!result.success || !result.messages) {
|
||||
alert(`获取语音消息失败: ${result.error || '未知错误'}`)
|
||||
return
|
||||
}
|
||||
|
||||
const voiceMessages: Message[] = result.messages
|
||||
if (voiceMessages.length === 0) {
|
||||
alert('当前会话没有语音消息')
|
||||
return
|
||||
}
|
||||
|
||||
const dateSet = new Set<string>()
|
||||
voiceMessages.forEach(m => dateSet.add(new Date(m.createTime * 1000).toISOString().slice(0, 10)))
|
||||
const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a))
|
||||
|
||||
setBatchVoiceMessages(voiceMessages)
|
||||
setBatchVoiceCount(voiceMessages.length)
|
||||
setBatchVoiceDates(sortedDates)
|
||||
setBatchSelectedDates(new Set(sortedDates))
|
||||
setShowBatchConfirm(true)
|
||||
}, [sessions, currentSessionId, isBatchTranscribing])
|
||||
|
||||
const handleExportCurrentSession = useCallback(() => {
|
||||
if (!currentSessionId) return
|
||||
navigate('/export', {
|
||||
state: {
|
||||
preselectSessionIds: [currentSessionId]
|
||||
}
|
||||
})
|
||||
}, [currentSessionId, navigate])
|
||||
|
||||
const handleGroupAnalytics = useCallback(() => {
|
||||
if (!currentSessionId || !isGroupChat(currentSessionId)) return
|
||||
navigate('/group-analytics', {
|
||||
state: {
|
||||
preselectGroupIds: [currentSessionId]
|
||||
}
|
||||
})
|
||||
}, [currentSessionId, navigate])
|
||||
|
||||
// 确认批量转写
|
||||
const confirmBatchTranscribe = useCallback(async () => {
|
||||
if (!currentSessionId) return
|
||||
|
||||
const selected = batchSelectedDates
|
||||
if (selected.size === 0) {
|
||||
alert('请至少选择一个日期')
|
||||
return
|
||||
}
|
||||
|
||||
const messages = batchVoiceMessages
|
||||
if (!messages || messages.length === 0) {
|
||||
setShowBatchConfirm(false)
|
||||
return
|
||||
}
|
||||
|
||||
const voiceMessages = messages.filter(m =>
|
||||
selected.has(new Date(m.createTime * 1000).toISOString().slice(0, 10))
|
||||
)
|
||||
if (voiceMessages.length === 0) {
|
||||
alert('所选日期下没有语音消息')
|
||||
return
|
||||
}
|
||||
|
||||
setShowBatchConfirm(false)
|
||||
setBatchVoiceMessages(null)
|
||||
setBatchVoiceDates([])
|
||||
setBatchSelectedDates(new Set())
|
||||
|
||||
const session = sessions.find(s => s.username === currentSessionId)
|
||||
if (!session) return
|
||||
|
||||
startTranscribe(voiceMessages.length, session.displayName || session.username)
|
||||
|
||||
// 检查模型状态
|
||||
const modelStatus = await window.electronAPI.whisper.getModelStatus()
|
||||
if (!modelStatus?.exists) {
|
||||
alert('SenseVoice 模型未下载,请先在设置中下载模型')
|
||||
finishTranscribe(0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
let completedCount = 0
|
||||
const concurrency = 3
|
||||
|
||||
const transcribeOne = async (msg: Message) => {
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getVoiceTranscript(
|
||||
session.username,
|
||||
String(msg.localId),
|
||||
msg.createTime
|
||||
)
|
||||
return { success: result.success }
|
||||
} catch {
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < voiceMessages.length; i += concurrency) {
|
||||
const batch = voiceMessages.slice(i, i + concurrency)
|
||||
const results = await Promise.all(batch.map(msg => transcribeOne(msg)))
|
||||
|
||||
results.forEach(result => {
|
||||
if (result.success) successCount++
|
||||
else failCount++
|
||||
completedCount++
|
||||
updateProgress(completedCount, voiceMessages.length)
|
||||
})
|
||||
}
|
||||
|
||||
finishTranscribe(successCount, failCount)
|
||||
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, startTranscribe, updateProgress, finishTranscribe])
|
||||
|
||||
// 批量转写:按日期的消息数量
|
||||
const batchCountByDate = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
if (!batchVoiceMessages) return map
|
||||
batchVoiceMessages.forEach(m => {
|
||||
const d = new Date(m.createTime * 1000).toISOString().slice(0, 10)
|
||||
map.set(d, (map.get(d) || 0) + 1)
|
||||
})
|
||||
return map
|
||||
}, [batchVoiceMessages])
|
||||
|
||||
// 批量转写:选中日期对应的语音条数
|
||||
const batchSelectedMessageCount = useMemo(() => {
|
||||
if (!batchVoiceMessages) return 0
|
||||
return batchVoiceMessages.filter(m =>
|
||||
batchSelectedDates.has(new Date(m.createTime * 1000).toISOString().slice(0, 10))
|
||||
).length
|
||||
}, [batchVoiceMessages, batchSelectedDates])
|
||||
|
||||
const toggleBatchDate = useCallback((date: string) => {
|
||||
setBatchSelectedDates(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(date)) next.delete(date)
|
||||
else next.add(date)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates])
|
||||
const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), [])
|
||||
|
||||
const formatBatchDateLabel = useCallback((dateStr: string) => {
|
||||
const [y, m, d] = dateStr.split('-').map(Number)
|
||||
return `${y}年${m}月${d}日`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`chat-page ${isResizing ? 'resizing' : ''}`}>
|
||||
{/* 左侧会话列表 */}
|
||||
@@ -1293,6 +1486,41 @@ function ChatPage(_props: ChatPageProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
{isGroupChat(currentSession.username) && (
|
||||
<button
|
||||
className="icon-btn group-analytics-btn"
|
||||
onClick={handleGroupAnalytics}
|
||||
title="群聊分析"
|
||||
>
|
||||
<BarChart3 size={18} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="icon-btn export-session-btn"
|
||||
onClick={handleExportCurrentSession}
|
||||
disabled={!currentSessionId}
|
||||
title="导出当前会话"
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
<button
|
||||
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
|
||||
onClick={() => {
|
||||
if (isBatchTranscribing) {
|
||||
setShowBatchProgress(true)
|
||||
} else {
|
||||
handleBatchTranscribe()
|
||||
}
|
||||
}}
|
||||
disabled={!currentSessionId}
|
||||
title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度` : '批量语音转文字'}
|
||||
>
|
||||
{isBatchTranscribing ? (
|
||||
<Loader2 size={18} className="spin" />
|
||||
) : (
|
||||
<Mic size={18} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn jump-to-time-btn"
|
||||
onClick={() => setShowJumpDialog(true)}
|
||||
@@ -1428,23 +1656,35 @@ function ChatPage(_props: ChatPageProps) {
|
||||
<Hash size={14} />
|
||||
<span className="label">微信ID</span>
|
||||
<span className="value">{sessionDetail.wxid}</span>
|
||||
<button className="copy-btn" title="复制" onClick={() => handleCopyField(sessionDetail.wxid, 'wxid')}>
|
||||
{copiedField === 'wxid' ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
{sessionDetail.remark && (
|
||||
<div className="detail-item">
|
||||
<span className="label">备注</span>
|
||||
<span className="value">{sessionDetail.remark}</span>
|
||||
<button className="copy-btn" title="复制" onClick={() => handleCopyField(sessionDetail.remark!, 'remark')}>
|
||||
{copiedField === 'remark' ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{sessionDetail.nickName && (
|
||||
<div className="detail-item">
|
||||
<span className="label">昵称</span>
|
||||
<span className="value">{sessionDetail.nickName}</span>
|
||||
<button className="copy-btn" title="复制" onClick={() => handleCopyField(sessionDetail.nickName!, 'nickName')}>
|
||||
{copiedField === 'nickName' ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{sessionDetail.alias && (
|
||||
<div className="detail-item">
|
||||
<span className="label">微信号</span>
|
||||
<span className="value">{sessionDetail.alias}</span>
|
||||
<button className="copy-btn" title="复制" onClick={() => handleCopyField(sessionDetail.alias!, 'alias')}>
|
||||
{copiedField === 'alias' ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1542,10 +1782,98 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 批量转写确认对话框 */}
|
||||
{showBatchConfirm && createPortal(
|
||||
<div className="batch-modal-overlay" onClick={() => setShowBatchConfirm(false)}>
|
||||
<div className="batch-modal-content batch-confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="batch-modal-header">
|
||||
<Mic size={20} />
|
||||
<h3>批量语音转文字</h3>
|
||||
</div>
|
||||
<div className="batch-modal-body">
|
||||
<p>选择要转写的日期(仅显示有语音的日期),然后开始转写。</p>
|
||||
{batchVoiceDates.length > 0 && (
|
||||
<div className="batch-dates-list-wrap">
|
||||
<div className="batch-dates-actions">
|
||||
<button type="button" className="batch-dates-btn" onClick={selectAllBatchDates}>全选</button>
|
||||
<button type="button" className="batch-dates-btn" onClick={clearAllBatchDates}>取消全选</button>
|
||||
</div>
|
||||
<ul className="batch-dates-list">
|
||||
{batchVoiceDates.map(dateStr => {
|
||||
const count = batchCountByDate.get(dateStr) ?? 0
|
||||
const checked = batchSelectedDates.has(dateStr)
|
||||
return (
|
||||
<li key={dateStr}>
|
||||
<label className="batch-date-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleBatchDate(dateStr)}
|
||||
/>
|
||||
<span className="batch-date-label">{formatBatchDateLabel(dateStr)}</span>
|
||||
<span className="batch-date-count">{count} 条语音</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="batch-info">
|
||||
<div className="info-item">
|
||||
<span className="label">已选:</span>
|
||||
<span className="value">{batchSelectedDates.size} 天有语音,共 {batchSelectedMessageCount} 条语音</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="label">预计耗时:</span>
|
||||
<span className="value">约 {Math.ceil(batchSelectedMessageCount * 2 / 60)} 分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="batch-warning">
|
||||
<AlertCircle size={16} />
|
||||
<span>批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="batch-modal-footer">
|
||||
<button className="btn-secondary" onClick={() => setShowBatchConfirm(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn-primary batch-transcribe-start-btn" onClick={confirmBatchTranscribe}>
|
||||
<Mic size={16} />
|
||||
开始转写
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 全局语音播放管理器:同一时间只能播放一条语音
|
||||
const globalVoiceManager = {
|
||||
currentAudio: null as HTMLAudioElement | null,
|
||||
currentStopCallback: null as (() => void) | null,
|
||||
play(audio: HTMLAudioElement, onStop: () => void) {
|
||||
// 停止当前正在播放的语音
|
||||
if (this.currentAudio && this.currentAudio !== audio) {
|
||||
this.currentAudio.pause()
|
||||
this.currentAudio.currentTime = 0
|
||||
this.currentStopCallback?.()
|
||||
}
|
||||
this.currentAudio = audio
|
||||
this.currentStopCallback = onStop
|
||||
},
|
||||
stop(audio: HTMLAudioElement) {
|
||||
if (this.currentAudio === audio) {
|
||||
this.currentAudio = null
|
||||
this.currentStopCallback = null
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// 前端表情包缓存
|
||||
const emojiDataUrlCache = new Map<string, string>()
|
||||
const imageDataUrlCache = new Map<string, string>()
|
||||
@@ -1601,6 +1929,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
||||
const voiceAutoDecryptTriggered = useRef(false)
|
||||
|
||||
// 转账消息双方名称
|
||||
const [transferPayerName, setTransferPayerName] = useState<string | undefined>(undefined)
|
||||
const [transferReceiverName, setTransferReceiverName] = useState<string | undefined>(undefined)
|
||||
|
||||
// 视频相关状态
|
||||
const [videoLoading, setVideoLoading] = useState(false)
|
||||
const [videoInfo, setVideoInfo] = useState<{ videoUrl?: string; coverUrl?: string; thumbUrl?: string; exists: boolean } | null>(null)
|
||||
@@ -1765,6 +2097,26 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
}
|
||||
}, [isGroupChat, isSent, message.senderUsername, myAvatarUrl])
|
||||
|
||||
// 解析转账消息的付款方和收款方显示名称
|
||||
useEffect(() => {
|
||||
const payerWxid = (message as any).transferPayerUsername
|
||||
const receiverWxid = (message as any).transferReceiverUsername
|
||||
if (!payerWxid && !receiverWxid) return
|
||||
// 仅对转账消息类型处理
|
||||
if (message.localType !== 49 && message.localType !== 8589934592049) return
|
||||
|
||||
window.electronAPI.chat.resolveTransferDisplayNames(
|
||||
session.username,
|
||||
payerWxid || '',
|
||||
receiverWxid || ''
|
||||
).then((result: { payerName: string; receiverName: string }) => {
|
||||
if (result) {
|
||||
setTransferPayerName(result.payerName)
|
||||
setTransferReceiverName(result.receiverName)
|
||||
}
|
||||
}).catch(() => { })
|
||||
}, [(message as any).transferPayerUsername, (message as any).transferReceiverUsername, session.username])
|
||||
|
||||
// 自动下载表情包
|
||||
useEffect(() => {
|
||||
if (emojiLocalPath) return
|
||||
@@ -1985,6 +2337,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
const handleEnded = () => {
|
||||
setIsVoicePlaying(false)
|
||||
setVoiceCurrentTime(0)
|
||||
globalVoiceManager.stop(audio)
|
||||
}
|
||||
const handleTimeUpdate = () => {
|
||||
setVoiceCurrentTime(audio.currentTime)
|
||||
@@ -1999,6 +2352,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||
return () => {
|
||||
audio.pause()
|
||||
globalVoiceManager.stop(audio)
|
||||
audio.removeEventListener('play', handlePlay)
|
||||
audio.removeEventListener('pause', handlePause)
|
||||
audio.removeEventListener('ended', handleEnded)
|
||||
@@ -2433,6 +2787,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
if (isVoicePlaying) {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
globalVoiceManager.stop(audio)
|
||||
return
|
||||
}
|
||||
if (!voiceDataUrl) {
|
||||
@@ -2467,6 +2822,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
}
|
||||
audio.src = source
|
||||
try {
|
||||
// 停止其他正在播放的语音,确保同一时间只播放一条
|
||||
globalVoiceManager.play(audio, () => {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
})
|
||||
await audio.play()
|
||||
} catch {
|
||||
setVoiceError(true)
|
||||
@@ -2623,6 +2983,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
let url = ''
|
||||
let appMsgType = ''
|
||||
let textAnnouncement = ''
|
||||
let parsedDoc: Document | null = null
|
||||
|
||||
try {
|
||||
const content = message.rawContent || message.parsedContent || ''
|
||||
@@ -2630,13 +2991,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
const xmlContent = content.substring(content.indexOf('<msg>'))
|
||||
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(xmlContent, 'text/xml')
|
||||
parsedDoc = parser.parseFromString(xmlContent, 'text/xml')
|
||||
|
||||
title = doc.querySelector('title')?.textContent || '链接'
|
||||
desc = doc.querySelector('des')?.textContent || ''
|
||||
url = doc.querySelector('url')?.textContent || ''
|
||||
appMsgType = doc.querySelector('appmsg > type')?.textContent || doc.querySelector('type')?.textContent || ''
|
||||
textAnnouncement = doc.querySelector('textannouncement')?.textContent || ''
|
||||
title = parsedDoc.querySelector('title')?.textContent || '链接'
|
||||
desc = parsedDoc.querySelector('des')?.textContent || ''
|
||||
url = parsedDoc.querySelector('url')?.textContent || ''
|
||||
appMsgType = parsedDoc.querySelector('appmsg > type')?.textContent || parsedDoc.querySelector('type')?.textContent || ''
|
||||
textAnnouncement = parsedDoc.querySelector('textannouncement')?.textContent || ''
|
||||
} catch (e) {
|
||||
console.error('解析 AppMsg 失败:', e)
|
||||
}
|
||||
@@ -2764,19 +3125,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
// 转账消息 (type=2000)
|
||||
if (appMsgType === '2000') {
|
||||
try {
|
||||
const content = message.rawContent || message.content || message.parsedContent || ''
|
||||
|
||||
// 添加调试日志
|
||||
|
||||
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(content, 'text/xml')
|
||||
|
||||
const feedesc = doc.querySelector('feedesc')?.textContent || ''
|
||||
const payMemo = doc.querySelector('pay_memo')?.textContent || ''
|
||||
const paysubtype = doc.querySelector('paysubtype')?.textContent || '1'
|
||||
|
||||
|
||||
// 使用外层已解析好的 parsedDoc(已去除 wxid 前缀)
|
||||
const feedesc = parsedDoc?.querySelector('feedesc')?.textContent || ''
|
||||
const payMemo = parsedDoc?.querySelector('pay_memo')?.textContent || ''
|
||||
const paysubtype = parsedDoc?.querySelector('paysubtype')?.textContent || '1'
|
||||
|
||||
// paysubtype: 1=待收款, 3=已收款
|
||||
const isReceived = paysubtype === '3'
|
||||
@@ -2784,16 +3136,29 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
||||
// 如果 feedesc 为空,使用 title 作为降级
|
||||
const displayAmount = feedesc || title || '微信转账'
|
||||
|
||||
// 构建转账描述:A 转账给 B
|
||||
const transferDesc = transferPayerName && transferReceiverName
|
||||
? `${transferPayerName} 转账给 ${transferReceiverName}`
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className={`transfer-message ${isReceived ? 'received' : ''}`}>
|
||||
<div className="transfer-icon">
|
||||
{isReceived ? (
|
||||
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" />
|
||||
<path d="M12 20l6 6 10-12" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" />
|
||||
<path d="M12 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="transfer-info">
|
||||
<div className="transfer-amount">{displayAmount}</div>
|
||||
{transferDesc && <div className="transfer-desc">{transferDesc}</div>}
|
||||
{payMemo && <div className="transfer-memo">{payMemo}</div>}
|
||||
<div className="transfer-label">{isReceived ? '已收款' : '微信转账'}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
|
||||
import * as configService from '../services/config'
|
||||
import './ExportPage.scss'
|
||||
@@ -38,6 +39,7 @@ interface ExportResult {
|
||||
type SessionLayout = 'shared' | 'per-session'
|
||||
|
||||
function ExportPage() {
|
||||
const location = useLocation()
|
||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([])
|
||||
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
|
||||
@@ -46,14 +48,36 @@ function ExportPage() {
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [exportFolder, setExportFolder] = useState<string>('')
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' })
|
||||
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 })
|
||||
const [exportResult, setExportResult] = useState<ExportResult | null>(null)
|
||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||
const [calendarDate, setCalendarDate] = useState(new Date())
|
||||
const [selectingStart, setSelectingStart] = useState(true)
|
||||
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
|
||||
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
|
||||
const [showPreExportDialog, setShowPreExportDialog] = useState(false)
|
||||
const [preExportStats, setPreExportStats] = useState<{
|
||||
totalMessages: number; voiceMessages: number; cachedVoiceCount: number;
|
||||
needTranscribeCount: number; mediaMessages: number; estimatedSeconds: number
|
||||
} | null>(null)
|
||||
const [isLoadingStats, setIsLoadingStats] = useState(false)
|
||||
const [pendingLayout, setPendingLayout] = useState<SessionLayout>('shared')
|
||||
const exportStartTime = useRef<number>(0)
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0)
|
||||
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const preselectAppliedRef = useRef(false)
|
||||
|
||||
const preselectSessionIds = useMemo(() => {
|
||||
const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null
|
||||
const rawList = Array.isArray(state?.preselectSessionIds)
|
||||
? state?.preselectSessionIds
|
||||
: (typeof state?.preselectSessionId === 'string' ? [state.preselectSessionId] : [])
|
||||
|
||||
return rawList
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}, [location.state])
|
||||
|
||||
const [options, setOptions] = useState<ExportOptions>({
|
||||
format: 'excel',
|
||||
@@ -68,7 +92,7 @@ function ExportPage() {
|
||||
exportVoices: true,
|
||||
exportVideos: true,
|
||||
exportEmojis: true,
|
||||
exportVoiceAsText: true,
|
||||
exportVoiceAsText: false,
|
||||
excelCompactColumns: true,
|
||||
txtColumns: defaultTxtColumns,
|
||||
displayNamePreference: 'remark',
|
||||
@@ -159,7 +183,7 @@ function ExportPage() {
|
||||
useAllTime: rangeDefaults.useAllTime,
|
||||
dateRange: rangeDefaults.dateRange,
|
||||
exportMedia: savedMedia ?? false,
|
||||
exportVoiceAsText: savedVoiceAsText ?? true,
|
||||
exportVoiceAsText: savedVoiceAsText ?? false,
|
||||
excelCompactColumns: savedExcelCompactColumns ?? true,
|
||||
txtColumns,
|
||||
exportConcurrency: savedConcurrency ?? 2
|
||||
@@ -175,6 +199,24 @@ function ExportPage() {
|
||||
loadExportDefaults()
|
||||
}, [loadSessions, loadExportPath, loadExportDefaults])
|
||||
|
||||
useEffect(() => {
|
||||
preselectAppliedRef.current = false
|
||||
}, [location.key, preselectSessionIds])
|
||||
|
||||
useEffect(() => {
|
||||
if (preselectAppliedRef.current) return
|
||||
if (sessions.length === 0 || preselectSessionIds.length === 0) return
|
||||
|
||||
const exists = new Set(sessions.map(session => session.username))
|
||||
const matched = preselectSessionIds.filter(id => exists.has(id))
|
||||
preselectAppliedRef.current = true
|
||||
|
||||
if (matched.length > 0) {
|
||||
setSelectedSessions(new Set(matched))
|
||||
setSearchKeyword('')
|
||||
}
|
||||
}, [sessions, preselectSessionIds])
|
||||
|
||||
useEffect(() => {
|
||||
const handleChange = () => {
|
||||
setSelectedSessions(new Set())
|
||||
@@ -189,17 +231,30 @@ function ExportPage() {
|
||||
}, [loadSessions])
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string }) => {
|
||||
const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string; phaseProgress?: number; phaseTotal?: number; phaseLabel?: string }) => {
|
||||
setExportProgress({
|
||||
current: payload.current,
|
||||
total: payload.total,
|
||||
currentName: payload.currentSession
|
||||
currentName: payload.currentSession,
|
||||
phaseLabel: payload.phaseLabel || '',
|
||||
phaseProgress: payload.phaseProgress || 0,
|
||||
phaseTotal: payload.phaseTotal || 0
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
removeListener?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 导出计时器
|
||||
useEffect(() => {
|
||||
if (!isExporting) return
|
||||
const timer = setInterval(() => {
|
||||
setElapsedSeconds(Math.floor((Date.now() - exportStartTime.current) / 1000))
|
||||
}, 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [isExporting])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
@@ -260,8 +315,7 @@ function ExportPage() {
|
||||
exportImages: true,
|
||||
exportVoices: true,
|
||||
exportVideos: true,
|
||||
exportEmojis: true,
|
||||
exportVoiceAsText: true
|
||||
exportEmojis: true
|
||||
}
|
||||
}
|
||||
return next
|
||||
@@ -278,8 +332,10 @@ function ExportPage() {
|
||||
if (selectedSessions.size === 0 || !exportFolder) return
|
||||
|
||||
setIsExporting(true)
|
||||
setExportProgress({ current: 0, total: selectedSessions.size, currentName: '' })
|
||||
setExportProgress({ current: 0, total: selectedSessions.size, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 })
|
||||
setExportResult(null)
|
||||
exportStartTime.current = Date.now()
|
||||
setElapsedSeconds(0)
|
||||
|
||||
try {
|
||||
const sessionList = Array.from(selectedSessions)
|
||||
@@ -322,9 +378,41 @@ function ExportPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const startExport = () => {
|
||||
const startExport = async () => {
|
||||
if (selectedSessions.size === 0 || !exportFolder) return
|
||||
|
||||
// 先获取预估统计
|
||||
setIsLoadingStats(true)
|
||||
setShowPreExportDialog(true)
|
||||
try {
|
||||
const sessionList = Array.from(selectedSessions)
|
||||
const exportOptions = {
|
||||
format: options.format,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
exportMedia: options.exportMedia,
|
||||
exportImages: options.exportMedia && options.exportImages,
|
||||
exportVoices: options.exportMedia && options.exportVoices,
|
||||
exportVideos: options.exportMedia && options.exportVideos,
|
||||
exportEmojis: options.exportMedia && options.exportEmojis,
|
||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
|
||||
} : null
|
||||
}
|
||||
const stats = await window.electronAPI.export.getExportStats(sessionList, exportOptions)
|
||||
setPreExportStats(stats)
|
||||
} catch (e) {
|
||||
console.error('获取导出统计失败:', e)
|
||||
setPreExportStats(null)
|
||||
} finally {
|
||||
setIsLoadingStats(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmExport = () => {
|
||||
setShowPreExportDialog(false)
|
||||
setPreExportStats(null)
|
||||
|
||||
if (options.exportMedia && selectedSessions.size > 1) {
|
||||
setShowMediaLayoutPrompt(true)
|
||||
return
|
||||
@@ -814,6 +902,71 @@ function ExportPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导出前预估弹窗 */}
|
||||
{showPreExportDialog && (
|
||||
<div className="export-overlay">
|
||||
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>导出预估</h3>
|
||||
{isLoadingStats ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '24px 0', justifyContent: 'center' }}>
|
||||
<Loader2 size={20} className="spin" />
|
||||
<span style={{ fontSize: 14, color: 'var(--text-secondary)' }}>正在统计消息...</span>
|
||||
</div>
|
||||
) : preExportStats ? (
|
||||
<div style={{ padding: '12px 0' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 24px', fontSize: 14 }}>
|
||||
<div>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>会话数</span>
|
||||
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{selectedSessions.size}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>总消息</span>
|
||||
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{preExportStats.totalMessages.toLocaleString()}</div>
|
||||
</div>
|
||||
{options.exportVoiceAsText && preExportStats.voiceMessages > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>语音消息</span>
|
||||
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{preExportStats.voiceMessages}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>已有缓存</span>
|
||||
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2, color: 'var(--primary)' }}>{preExportStats.cachedVoiceCount}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{options.exportVoiceAsText && preExportStats.needTranscribeCount > 0 && (
|
||||
<div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--text-warning, #e6a23c)' }}>⚠</span>
|
||||
{' '}需要转写 <b>{preExportStats.needTranscribeCount}</b> 条语音,预计耗时约 <b>{preExportStats.estimatedSeconds > 60
|
||||
? `${Math.round(preExportStats.estimatedSeconds / 60)} 分钟`
|
||||
: `${preExportStats.estimatedSeconds} 秒`
|
||||
}</b>
|
||||
</div>
|
||||
)}
|
||||
{options.exportVoiceAsText && preExportStats.voiceMessages > 0 && preExportStats.needTranscribeCount === 0 && (
|
||||
<div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--text-success, #67c23a)' }}>✓</span>
|
||||
{' '}所有 {preExportStats.voiceMessages} 条语音已有转写缓存,无需重新转写
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', padding: '16px 0' }}>统计信息获取失败,仍可继续导出</p>
|
||||
)}
|
||||
<div className="layout-actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
|
||||
<button className="layout-cancel-btn" onClick={() => { setShowPreExportDialog(false); setPreExportStats(null) }}>
|
||||
取消
|
||||
</button>
|
||||
<button className="layout-option-btn primary" onClick={confirmExport} disabled={isLoadingStats}>
|
||||
<span className="layout-title">开始导出</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导出进度弹窗 */}
|
||||
{isExporting && (
|
||||
<div className="export-overlay">
|
||||
@@ -823,13 +976,31 @@ function ExportPage() {
|
||||
</div>
|
||||
<h3>正在导出</h3>
|
||||
<p className="progress-text">{exportProgress.currentName}</p>
|
||||
{exportProgress.phaseLabel && (
|
||||
<p className="progress-phase-label" style={{ fontSize: 13, color: 'var(--text-secondary)', margin: '4px 0 8px' }}>
|
||||
{exportProgress.phaseLabel}
|
||||
</p>
|
||||
)}
|
||||
{exportProgress.phaseTotal > 0 && (
|
||||
<div className="progress-bar" style={{ marginBottom: 8 }}>
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${(exportProgress.phaseProgress / exportProgress.phaseTotal) * 100}%`, background: 'var(--primary-light, #79bbff)' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${(exportProgress.current / exportProgress.total) * 100}%` }}
|
||||
style={{ width: `${exportProgress.total > 0 ? (exportProgress.current / exportProgress.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="progress-count">{exportProgress.current} / {exportProgress.total}</p>
|
||||
<p className="progress-count">
|
||||
{exportProgress.current} / {exportProgress.total} 个会话
|
||||
<span style={{ marginLeft: 12, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{elapsedSeconds > 0 && `已用 ${elapsedSeconds >= 60 ? `${Math.floor(elapsedSeconds / 60)}分${elapsedSeconds % 60}秒` : `${elapsedSeconds}秒`}`}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
@@ -30,6 +31,7 @@ interface GroupMessageRank {
|
||||
type AnalysisFunction = 'members' | 'ranking' | 'activeHours' | 'mediaStats'
|
||||
|
||||
function GroupAnalyticsPage() {
|
||||
const location = useLocation()
|
||||
const [groups, setGroups] = useState<GroupChatInfo[]>([])
|
||||
const [filteredGroups, setFilteredGroups] = useState<GroupChatInfo[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -58,11 +60,28 @@ function GroupAnalyticsPage() {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(300)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const preselectAppliedRef = useRef(false)
|
||||
|
||||
const preselectGroupIds = useMemo(() => {
|
||||
const state = location.state as { preselectGroupIds?: unknown; preselectGroupId?: unknown } | null
|
||||
const rawList = Array.isArray(state?.preselectGroupIds)
|
||||
? state.preselectGroupIds
|
||||
: (typeof state?.preselectGroupId === 'string' ? [state.preselectGroupId] : [])
|
||||
|
||||
return rawList
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}, [location.state])
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
preselectAppliedRef.current = false
|
||||
}, [location.key, preselectGroupIds])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
setFilteredGroups(groups.filter(g => g.displayName.toLowerCase().includes(searchQuery.toLowerCase())))
|
||||
@@ -71,6 +90,20 @@ function GroupAnalyticsPage() {
|
||||
}
|
||||
}, [searchQuery, groups])
|
||||
|
||||
useEffect(() => {
|
||||
if (preselectAppliedRef.current) return
|
||||
if (groups.length === 0 || preselectGroupIds.length === 0) return
|
||||
|
||||
const matchedGroup = groups.find(group => preselectGroupIds.includes(group.username))
|
||||
preselectAppliedRef.current = true
|
||||
|
||||
if (matchedGroup) {
|
||||
setSelectedGroup(matchedGroup)
|
||||
setSelectedFunction(null)
|
||||
setSearchQuery('')
|
||||
}
|
||||
}, [groups, preselectGroupIds])
|
||||
|
||||
// 拖动调整宽度
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
|
||||
@@ -1191,6 +1191,109 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 通用弹窗覆盖层
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
// API 警告弹窗
|
||||
.api-warning-modal {
|
||||
width: 420px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
|
||||
animation: slideUp 0.25s ease;
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
svg {
|
||||
color: var(--warning, #f59e0b);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px 24px;
|
||||
|
||||
.warning-text {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.warning-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.warning-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
|
||||
.bullet {
|
||||
color: var(--warning, #f59e0b);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 协议弹窗
|
||||
.agreement-overlay {
|
||||
@@ -1856,3 +1959,147 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// API 地址显示样式
|
||||
.api-url-display {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// API 服务设置样式
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.running {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
&.stopped {
|
||||
background: rgba(156, 163, 175, 0.15);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.api-url {
|
||||
display: inline-block;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.api-docs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.api-item {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
.api-endpoint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.method {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.get {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
&.post {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.api-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.api-params {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
.param {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
code {
|
||||
color: var(--primary);
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-block {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px !important;
|
||||
font-size: 12px !important;
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
RotateCcw, Trash2, Plug, Check, Sun, Moon,
|
||||
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
|
||||
ShieldCheck, Fingerprint, Lock, KeyRound, Bell
|
||||
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe
|
||||
} from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import './SettingsPage.scss'
|
||||
|
||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'security' | 'about'
|
||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'api' | 'security' | 'about'
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
@@ -23,6 +23,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'models', label: '模型管理', icon: Mic },
|
||||
{ id: 'export', label: '导出', icon: Download },
|
||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||
{ id: 'api', label: 'API 服务', icon: Globe },
|
||||
{ id: 'security', label: '安全', icon: ShieldCheck },
|
||||
{ id: 'about', label: '关于', icon: Info }
|
||||
]
|
||||
@@ -96,7 +97,7 @@ function SettingsPage() {
|
||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
|
||||
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true)
|
||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
||||
|
||||
@@ -137,6 +138,13 @@ function SettingsPage() {
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [isSettingHello, setIsSettingHello] = useState(false)
|
||||
|
||||
// HTTP API 设置 state
|
||||
const [httpApiEnabled, setHttpApiEnabled] = useState(false)
|
||||
const [httpApiPort, setHttpApiPort] = useState(5031)
|
||||
const [httpApiRunning, setHttpApiRunning] = useState(false)
|
||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||
const [showApiWarning, setShowApiWarning] = useState(false)
|
||||
|
||||
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
|
||||
|
||||
// 检查 Hello 可用性
|
||||
@@ -146,6 +154,22 @@ function SettingsPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 检查 HTTP API 服务状态
|
||||
useEffect(() => {
|
||||
const checkApiStatus = async () => {
|
||||
try {
|
||||
const status = await window.electronAPI.http.status()
|
||||
setHttpApiRunning(status.running)
|
||||
if (status.port) {
|
||||
setHttpApiPort(status.port)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('检查 API 状态失败:', e)
|
||||
}
|
||||
}
|
||||
checkApiStatus()
|
||||
}, [])
|
||||
|
||||
async function sha256(message: string) {
|
||||
const msgBuffer = new TextEncoder().encode(message)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
|
||||
@@ -269,7 +293,7 @@ function SettingsPage() {
|
||||
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
|
||||
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
|
||||
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
||||
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true)
|
||||
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false)
|
||||
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
||||
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
|
||||
|
||||
@@ -1835,6 +1859,147 @@ function SettingsPage() {
|
||||
</div>
|
||||
)
|
||||
|
||||
// HTTP API 服务控制
|
||||
const handleToggleApi = async () => {
|
||||
if (isTogglingApi) return
|
||||
|
||||
// 启动时显示警告弹窗
|
||||
if (!httpApiRunning) {
|
||||
setShowApiWarning(true)
|
||||
return
|
||||
}
|
||||
|
||||
setIsTogglingApi(true)
|
||||
try {
|
||||
await window.electronAPI.http.stop()
|
||||
setHttpApiRunning(false)
|
||||
showMessage('API 服务已停止', true)
|
||||
} catch (e: any) {
|
||||
showMessage(`操作失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsTogglingApi(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 确认启动 API 服务
|
||||
const confirmStartApi = async () => {
|
||||
setShowApiWarning(false)
|
||||
setIsTogglingApi(true)
|
||||
try {
|
||||
const result = await window.electronAPI.http.start(httpApiPort)
|
||||
if (result.success) {
|
||||
setHttpApiRunning(true)
|
||||
if (result.port) setHttpApiPort(result.port)
|
||||
showMessage(`API 服务已启动,端口 ${result.port}`, true)
|
||||
} else {
|
||||
showMessage(`启动失败: ${result.error}`, false)
|
||||
}
|
||||
} catch (e: any) {
|
||||
showMessage(`操作失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsTogglingApi(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyApiUrl = () => {
|
||||
const url = `http://127.0.0.1:${httpApiPort}`
|
||||
navigator.clipboard.writeText(url)
|
||||
showMessage('已复制 API 地址', true)
|
||||
}
|
||||
|
||||
const renderApiTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<label>HTTP API 服务</label>
|
||||
<span className="form-hint">启用后可通过 HTTP 接口查询消息数据(仅限本机访问)</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">
|
||||
{httpApiRunning ? '运行中' : '已停止'}
|
||||
</span>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={httpApiRunning}
|
||||
onChange={handleToggleApi}
|
||||
disabled={isTogglingApi}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>服务端口</label>
|
||||
<span className="form-hint">API 服务监听的端口号(1024-65535)</span>
|
||||
<input
|
||||
type="number"
|
||||
className="field-input"
|
||||
value={httpApiPort}
|
||||
onChange={(e) => setHttpApiPort(parseInt(e.target.value, 10) || 5031)}
|
||||
disabled={httpApiRunning}
|
||||
style={{ width: 120 }}
|
||||
min={1024}
|
||||
max={65535}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{httpApiRunning && (
|
||||
<div className="form-group">
|
||||
<label>API 地址</label>
|
||||
<span className="form-hint">使用以下地址访问 API</span>
|
||||
<div className="api-url-display">
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
value={`http://127.0.0.1:${httpApiPort}`}
|
||||
readOnly
|
||||
/>
|
||||
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复制">
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API 安全警告弹窗 */}
|
||||
{showApiWarning && (
|
||||
<div className="modal-overlay" onClick={() => setShowApiWarning(false)}>
|
||||
<div className="api-warning-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<ShieldCheck size={20} />
|
||||
<h3>安全提示</h3>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p className="warning-text">启用 HTTP API 服务后,本机上的其他程序可通过接口访问您的聊天记录数据。</p>
|
||||
<div className="warning-list">
|
||||
<div className="warning-item">
|
||||
<span className="bullet">•</span>
|
||||
<span>请确保您了解此功能的用途</span>
|
||||
</div>
|
||||
<div className="warning-item">
|
||||
<span className="bullet">•</span>
|
||||
<span>不要在公共或不信任的网络环境下使用</span>
|
||||
</div>
|
||||
<div className="warning-item">
|
||||
<span className="bullet">•</span>
|
||||
<span>此功能仅供高级用户或开发者使用</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowApiWarning(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={confirmStartApi}>
|
||||
确认启动
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const handleSetupHello = async () => {
|
||||
setIsSettingHello(true)
|
||||
try {
|
||||
@@ -2075,6 +2240,7 @@ function SettingsPage() {
|
||||
{activeTab === 'models' && renderModelsTab()}
|
||||
{activeTab === 'export' && renderExportTab()}
|
||||
{activeTab === 'cache' && renderCacheTab()}
|
||||
{activeTab === 'api' && renderApiTab()}
|
||||
{activeTab === 'security' && renderSecurityTab()}
|
||||
{activeTab === 'about' && renderAboutTab()}
|
||||
</div>
|
||||
|
||||
65
src/stores/batchTranscribeStore.ts
Normal file
65
src/stores/batchTranscribeStore.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export interface BatchTranscribeState {
|
||||
/** 是否正在批量转写 */
|
||||
isBatchTranscribing: boolean
|
||||
/** 转写进度 */
|
||||
progress: { current: number; total: number }
|
||||
/** 是否显示进度浮窗 */
|
||||
showToast: boolean
|
||||
/** 是否显示结果弹窗 */
|
||||
showResult: boolean
|
||||
/** 转写结果 */
|
||||
result: { success: number; fail: number }
|
||||
/** 当前转写的会话名 */
|
||||
sessionName: string
|
||||
|
||||
// Actions
|
||||
startTranscribe: (total: number, sessionName: string) => void
|
||||
updateProgress: (current: number, total: number) => void
|
||||
finishTranscribe: (success: number, fail: number) => void
|
||||
setShowToast: (show: boolean) => void
|
||||
setShowResult: (show: boolean) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
||||
isBatchTranscribing: false,
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResult: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
sessionName: '',
|
||||
|
||||
startTranscribe: (total, sessionName) => set({
|
||||
isBatchTranscribing: true,
|
||||
showToast: true,
|
||||
progress: { current: 0, total },
|
||||
showResult: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
sessionName
|
||||
}),
|
||||
|
||||
updateProgress: (current, total) => set({
|
||||
progress: { current, total }
|
||||
}),
|
||||
|
||||
finishTranscribe: (success, fail) => set({
|
||||
isBatchTranscribing: false,
|
||||
showToast: false,
|
||||
showResult: true,
|
||||
result: { success, fail }
|
||||
}),
|
||||
|
||||
setShowToast: (show) => set({ showToast: show }),
|
||||
setShowResult: (show) => set({ showResult: show }),
|
||||
|
||||
reset: () => set({
|
||||
isBatchTranscribing: false,
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResult: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
sessionName: ''
|
||||
})
|
||||
}))
|
||||
238
src/styles/batchTranscribe.scss
Normal file
238
src/styles/batchTranscribe.scss
Normal file
@@ -0,0 +1,238 @@
|
||||
// 批量转写 - 共享基础样式(overlay / modal-content / animations)
|
||||
// 被 ChatPage.scss 和 BatchTranscribeGlobal.tsx 同时使用
|
||||
|
||||
.batch-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: batchFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.batch-modal-content {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: batchSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes batchFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes batchSlideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
// 批量转写进度浮窗(非阻塞 toast)
|
||||
.batch-progress-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
width: 320px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
border: 1px solid var(--border-color);
|
||||
z-index: 10000;
|
||||
animation: batchToastSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
overflow: hidden;
|
||||
|
||||
.batch-progress-toast-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.batch-progress-toast-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
|
||||
svg { color: var(--primary-color); }
|
||||
}
|
||||
}
|
||||
|
||||
.batch-progress-toast-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.batch-progress-toast-body {
|
||||
padding: 12px 14px;
|
||||
|
||||
.progress-text {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.progress-percent {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-color), var(--primary-color));
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes batchToastSlideIn {
|
||||
from { opacity: 0; transform: translateY(16px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
// 批量转写结果对话框
|
||||
.batch-result-modal {
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
|
||||
.batch-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
svg { color: #4caf50; }
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.batch-modal-body {
|
||||
padding: 1.5rem;
|
||||
|
||||
.result-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
|
||||
svg { flex-shrink: 0; }
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.value {
|
||||
margin-left: auto;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.success {
|
||||
svg { color: #4caf50; }
|
||||
.value { color: #4caf50; }
|
||||
}
|
||||
|
||||
&.fail {
|
||||
svg { color: #f44336; }
|
||||
.value { color: #f44336; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.result-tip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 152, 0, 0.3);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.batch-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
|
||||
&.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
&:hover { opacity: 0.9; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/types/electron.d.ts
vendored
16
src/types/electron.d.ts
vendored
@@ -85,6 +85,7 @@ export interface ElectronAPI {
|
||||
}>
|
||||
getContact: (username: string) => Promise<Contact | null>
|
||||
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
|
||||
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
|
||||
getContacts: () => Promise<{
|
||||
success: boolean
|
||||
contacts?: ContactInfo[]
|
||||
@@ -111,6 +112,7 @@ export interface ElectronAPI {
|
||||
}>
|
||||
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; 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
|
||||
@@ -401,6 +403,15 @@ export interface ElectronAPI {
|
||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||
}
|
||||
export: {
|
||||
getExportStats: (sessionIds: string[], options: any) => Promise<{
|
||||
totalMessages: number
|
||||
voiceMessages: number
|
||||
cachedVoiceCount: number
|
||||
needTranscribeCount: number
|
||||
mediaMessages: number
|
||||
estimatedSeconds: number
|
||||
sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }>
|
||||
}>
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
|
||||
success: boolean
|
||||
successCount?: number
|
||||
@@ -492,7 +503,10 @@ export interface ExportProgress {
|
||||
current: number
|
||||
total: number
|
||||
currentSession: string
|
||||
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
|
||||
phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
|
||||
phaseProgress?: number
|
||||
phaseTotal?: number
|
||||
phaseLabel?: string
|
||||
}
|
||||
|
||||
export interface WxidInfo {
|
||||
|
||||
@@ -64,6 +64,9 @@ export interface Message {
|
||||
fileSize?: number // 文件大小
|
||||
fileExt?: string // 文件扩展名
|
||||
xmlType?: string // XML 中的 type 字段
|
||||
// 转账消息
|
||||
transferPayerUsername?: string // 转账付款方 wxid
|
||||
transferReceiverUsername?: string // 转账收款方 wxid
|
||||
// 名片消息
|
||||
cardUsername?: string // 名片的微信ID
|
||||
cardNickname?: string // 名片的昵称
|
||||
|
||||
Reference in New Issue
Block a user