mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
@@ -50,12 +50,20 @@ GET /api/v1/messages
|
|||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
|--------|------|------|------|
|
||||||
| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) |
|
| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) |
|
||||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
| `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` |
|
||||||
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
|
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
|
||||||
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
|
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
|
||||||
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
|
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
|
||||||
|
| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) |
|
||||||
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
|
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
|
||||||
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
|
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
|
||||||
|
| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti`);`0` 时媒体返回占位符 |
|
||||||
|
| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian`) |
|
||||||
|
| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce`) |
|
||||||
|
| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` |
|
||||||
|
| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` |
|
||||||
|
|
||||||
|
默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media`
|
||||||
|
|
||||||
**示例请求**
|
**示例请求**
|
||||||
|
|
||||||
@@ -68,6 +76,12 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1
|
|||||||
|
|
||||||
# 带时间范围查询
|
# 带时间范围查询
|
||||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
|
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
|
||||||
|
|
||||||
|
# 开启媒体导出(只导出图片和语音)
|
||||||
|
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0
|
||||||
|
|
||||||
|
# 关键词过滤
|
||||||
|
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应(原始格式)**
|
**响应(原始格式)**
|
||||||
@@ -77,15 +91,21 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=202
|
|||||||
"talker": "wxid_xxx",
|
"talker": "wxid_xxx",
|
||||||
"count": 50,
|
"count": 50,
|
||||||
"hasMore": true,
|
"hasMore": true,
|
||||||
|
"media": {
|
||||||
|
"enabled": true,
|
||||||
|
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||||
|
"count": 12
|
||||||
|
},
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"localId": 123,
|
"localId": 123,
|
||||||
"talker": "wxid_xxx",
|
"localType": 3,
|
||||||
"type": 1,
|
"content": "[图片]",
|
||||||
"content": "消息内容",
|
|
||||||
"createTime": 1738713600000,
|
"createTime": 1738713600000,
|
||||||
"isSelf": false,
|
"senderUsername": "wxid_sender",
|
||||||
"sender": "wxid_sender"
|
"mediaType": "image",
|
||||||
|
"mediaFileName": "image_123.jpg",
|
||||||
|
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -119,9 +139,15 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=202
|
|||||||
"accountName": "用户名",
|
"accountName": "用户名",
|
||||||
"timestamp": 1738713600000,
|
"timestamp": 1738713600000,
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"content": "消息内容"
|
"content": "消息内容",
|
||||||
|
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"media": {
|
||||||
|
"enabled": true,
|
||||||
|
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||||
|
"count": 12
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1124,6 +1124,13 @@ function registerIpcHandlers() {
|
|||||||
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
|
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'groupAnalytics:exportGroupMemberMessages',
|
||||||
|
async (_, chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) => {
|
||||||
|
return groupAnalyticsService.exportGroupMemberMessages(chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 打开协议窗口
|
// 打开协议窗口
|
||||||
ipcMain.handle('window:openAgreementWindow', async () => {
|
ipcMain.handle('window:openAgreementWindow', async () => {
|
||||||
createAgreementWindow()
|
createAgreementWindow()
|
||||||
@@ -1358,7 +1365,8 @@ function registerIpcHandlers() {
|
|||||||
ipcMain.handle('http:status', async () => {
|
ipcMain.handle('http:status', async () => {
|
||||||
return {
|
return {
|
||||||
running: httpService.isRunning(),
|
running: httpService.isRunning(),
|
||||||
port: httpService.getPort()
|
port: httpService.getPort(),
|
||||||
|
mediaExportPath: httpService.getDefaultMediaExportPath()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -220,7 +220,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||||
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath)
|
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
|
||||||
|
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
||||||
|
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 年度报告
|
// 年度报告
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export interface Message {
|
|||||||
emojiCdnUrl?: string
|
emojiCdnUrl?: string
|
||||||
emojiMd5?: string
|
emojiMd5?: string
|
||||||
emojiLocalPath?: string // 本地缓存 castle 路径
|
emojiLocalPath?: string // 本地缓存 castle 路径
|
||||||
|
emojiThumbUrl?: string
|
||||||
|
emojiEncryptUrl?: string
|
||||||
|
emojiAesKey?: string
|
||||||
// 引用消息相关
|
// 引用消息相关
|
||||||
quotedContent?: string
|
quotedContent?: string
|
||||||
quotedSender?: string
|
quotedSender?: string
|
||||||
@@ -1109,6 +1112,13 @@ class ChatService {
|
|||||||
return Number.isFinite(parsed) ? parsed : NaN
|
return Number.isFinite(parsed) ? parsed : NaN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP API 复用消息解析逻辑,确保和应用内展示一致。
|
||||||
|
*/
|
||||||
|
mapRowsToMessagesForApi(rows: Record<string, any>[]): Message[] {
|
||||||
|
return this.mapRowsToMessages(rows)
|
||||||
|
}
|
||||||
|
|
||||||
private mapRowsToMessages(rows: Record<string, any>[]): Message[] {
|
private mapRowsToMessages(rows: Record<string, any>[]): Message[] {
|
||||||
const myWxid = this.configService.get('myWxid')
|
const myWxid = this.configService.get('myWxid')
|
||||||
const cleanedWxid = myWxid ? this.cleanAccountDirName(myWxid) : null
|
const cleanedWxid = myWxid ? this.cleanAccountDirName(myWxid) : null
|
||||||
@@ -1204,6 +1214,9 @@ class ChatService {
|
|||||||
const emojiInfo = this.parseEmojiInfo(content)
|
const emojiInfo = this.parseEmojiInfo(content)
|
||||||
emojiCdnUrl = emojiInfo.cdnUrl
|
emojiCdnUrl = emojiInfo.cdnUrl
|
||||||
emojiMd5 = emojiInfo.md5
|
emojiMd5 = emojiInfo.md5
|
||||||
|
cdnThumbUrl = emojiInfo.thumbUrl // 复用 cdnThumbUrl 字段或使用 emojiThumbUrl
|
||||||
|
// 注意:Message 接口定义的 emojiThumbUrl,这里我们统一一下
|
||||||
|
// 如果 Message 接口有 emojiThumbUrl,则使用它
|
||||||
} else if (localType === 3 && content) {
|
} else if (localType === 3 && content) {
|
||||||
const imageInfo = this.parseImageInfo(content)
|
const imageInfo = this.parseImageInfo(content)
|
||||||
imageMd5 = imageInfo.md5
|
imageMd5 = imageInfo.md5
|
||||||
@@ -1426,7 +1439,7 @@ class ChatService {
|
|||||||
/**
|
/**
|
||||||
* 解析表情包信息
|
* 解析表情包信息
|
||||||
*/
|
*/
|
||||||
private parseEmojiInfo(content: string): { cdnUrl?: string; md5?: string } {
|
private parseEmojiInfo(content: string): { cdnUrl?: string; md5?: string; thumbUrl?: string; encryptUrl?: string; aesKey?: string } {
|
||||||
try {
|
try {
|
||||||
// 提取 cdnurl
|
// 提取 cdnurl
|
||||||
let cdnUrl: string | undefined
|
let cdnUrl: string | undefined
|
||||||
@@ -1440,26 +1453,39 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有 cdnurl,尝试 thumburl
|
// 提取 thumburl
|
||||||
if (!cdnUrl) {
|
let thumbUrl: string | undefined
|
||||||
const thumbUrlMatch = /thumburl\s*=\s*['"]([^'"]+)['"]/i.exec(content) || /thumburl\s*=\s*([^'"]+?)(?=\s|\/|>)/i.exec(content)
|
const thumbUrlMatch = /thumburl\s*=\s*['"]([^'"]+)['"]/i.exec(content) || /thumburl\s*=\s*([^'"]+?)(?=\s|\/|>)/i.exec(content)
|
||||||
if (thumbUrlMatch) {
|
if (thumbUrlMatch) {
|
||||||
cdnUrl = thumbUrlMatch[1].replace(/&/g, '&')
|
thumbUrl = thumbUrlMatch[1].replace(/&/g, '&')
|
||||||
if (cdnUrl.includes('%')) {
|
if (thumbUrl.includes('%')) {
|
||||||
try {
|
try {
|
||||||
cdnUrl = decodeURIComponent(cdnUrl)
|
thumbUrl = decodeURIComponent(thumbUrl)
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 提取 md5
|
// 提取 md5
|
||||||
const md5Match = /md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || /md5\s*=\s*([a-fA-F0-9]+)/i.exec(content)
|
const md5Match = /md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || /md5\s*=\s*([a-fA-F0-9]+)/i.exec(content)
|
||||||
const md5 = md5Match ? md5Match[1] : undefined
|
const md5 = md5Match ? md5Match[1] : undefined
|
||||||
|
|
||||||
// 不构造假 URL,只返回真正的 cdnurl
|
// 提取 encrypturl
|
||||||
// 没有 cdnUrl 时保持静默,交由后续回退逻辑处理
|
let encryptUrl: string | undefined
|
||||||
return { cdnUrl, md5 }
|
const encryptUrlMatch = /encrypturl\s*=\s*['"]([^'"]+)['"]/i.exec(content) || /encrypturl\s*=\s*([^'"]+?)(?=\s|\/|>)/i.exec(content)
|
||||||
|
if (encryptUrlMatch) {
|
||||||
|
encryptUrl = encryptUrlMatch[1].replace(/&/g, '&')
|
||||||
|
if (encryptUrl.includes('%')) {
|
||||||
|
try {
|
||||||
|
encryptUrl = decodeURIComponent(encryptUrl)
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 aeskey
|
||||||
|
const aesKeyMatch = /aeskey\s*=\s*['"]([a-zA-Z0-9]+)['"]/i.exec(content) || /aeskey\s*=\s*([a-zA-Z0-9]+)/i.exec(content)
|
||||||
|
const aesKey = aesKeyMatch ? aesKeyMatch[1] : undefined
|
||||||
|
|
||||||
|
return { cdnUrl, md5, thumbUrl, encryptUrl, aesKey }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[ChatService] 表情包解析失败:', e, { xml: content })
|
console.error('[ChatService] 表情包解析失败:', e, { xml: content })
|
||||||
return {}
|
return {}
|
||||||
@@ -2675,11 +2701,7 @@ class ChatService {
|
|||||||
// 检查内存缓存
|
// 检查内存缓存
|
||||||
const cached = emojiCache.get(cacheKey)
|
const cached = emojiCache.get(cacheKey)
|
||||||
if (cached && existsSync(cached)) {
|
if (cached && existsSync(cached)) {
|
||||||
// 读取文件并转为 data URL
|
return { success: true, localPath: cached }
|
||||||
const dataUrl = this.fileToDataUrl(cached)
|
|
||||||
if (dataUrl) {
|
|
||||||
return { success: true, localPath: dataUrl }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否正在下载
|
// 检查是否正在下载
|
||||||
@@ -2687,10 +2709,7 @@ class ChatService {
|
|||||||
if (downloading) {
|
if (downloading) {
|
||||||
const result = await downloading
|
const result = await downloading
|
||||||
if (result) {
|
if (result) {
|
||||||
const dataUrl = this.fileToDataUrl(result)
|
return { success: true, localPath: result }
|
||||||
if (dataUrl) {
|
|
||||||
return { success: true, localPath: dataUrl }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { success: false, error: '下载失败' }
|
return { success: false, error: '下载失败' }
|
||||||
}
|
}
|
||||||
@@ -2707,10 +2726,7 @@ class ChatService {
|
|||||||
const filePath = join(cacheDir, `${cacheKey}${ext}`)
|
const filePath = join(cacheDir, `${cacheKey}${ext}`)
|
||||||
if (existsSync(filePath)) {
|
if (existsSync(filePath)) {
|
||||||
emojiCache.set(cacheKey, filePath)
|
emojiCache.set(cacheKey, filePath)
|
||||||
const dataUrl = this.fileToDataUrl(filePath)
|
return { success: true, localPath: filePath }
|
||||||
if (dataUrl) {
|
|
||||||
return { success: true, localPath: dataUrl }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2724,10 +2740,7 @@ class ChatService {
|
|||||||
|
|
||||||
if (localPath) {
|
if (localPath) {
|
||||||
emojiCache.set(cacheKey, localPath)
|
emojiCache.set(cacheKey, localPath)
|
||||||
const dataUrl = this.fileToDataUrl(localPath)
|
return { success: true, localPath }
|
||||||
if (dataUrl) {
|
|
||||||
return { success: true, localPath: dataUrl }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { success: false, error: '下载失败' }
|
return { success: false, error: '下载失败' }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -3970,6 +3983,13 @@ class ChatService {
|
|||||||
const imgInfo = this.parseImageInfo(rawContent)
|
const imgInfo = this.parseImageInfo(rawContent)
|
||||||
Object.assign(msg, imgInfo)
|
Object.assign(msg, imgInfo)
|
||||||
msg.imageDatName = this.parseImageDatNameFromRow(row)
|
msg.imageDatName = this.parseImageDatNameFromRow(row)
|
||||||
|
} else if (msg.localType === 47) { // Emoji
|
||||||
|
const emojiInfo = this.parseEmojiInfo(rawContent)
|
||||||
|
msg.emojiCdnUrl = emojiInfo.cdnUrl
|
||||||
|
msg.emojiMd5 = emojiInfo.md5
|
||||||
|
msg.emojiThumbUrl = emojiInfo.thumbUrl
|
||||||
|
msg.emojiEncryptUrl = emojiInfo.encryptUrl
|
||||||
|
msg.emojiAesKey = emojiInfo.aesKey
|
||||||
}
|
}
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
@@ -4280,6 +4300,34 @@ class ChatService {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载表情包文件(用于导出,返回文件路径)
|
||||||
|
*/
|
||||||
|
async downloadEmojiFile(msg: Message): Promise<string | null> {
|
||||||
|
if (!msg.emojiMd5) return null
|
||||||
|
let url = msg.emojiCdnUrl
|
||||||
|
|
||||||
|
// 尝试获取 URL
|
||||||
|
if (!url && msg.emojiEncryptUrl) {
|
||||||
|
console.warn('[ChatService] Emoji has only encryptUrl:', msg.emojiMd5)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
await this.fallbackEmoticon(msg)
|
||||||
|
url = msg.emojiCdnUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) return null
|
||||||
|
|
||||||
|
// Reuse existing downloadEmoji method
|
||||||
|
const result = await this.downloadEmoji(url, msg.emojiMd5)
|
||||||
|
if (result.success && result.localPath) {
|
||||||
|
return result.localPath
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatService = new ChatService()
|
export const chatService = new ChatService()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ContactExportOptions {
|
|||||||
groups: boolean
|
groups: boolean
|
||||||
officials: boolean
|
officials: boolean
|
||||||
}
|
}
|
||||||
|
selectedUsernames?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +41,11 @@ class ContactExportService {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (Array.isArray(options.selectedUsernames) && options.selectedUsernames.length > 0) {
|
||||||
|
const selectedSet = new Set(options.selectedUsernames)
|
||||||
|
contacts = contacts.filter(c => selectedSet.has(c.username))
|
||||||
|
}
|
||||||
|
|
||||||
if (contacts.length === 0) {
|
if (contacts.length === 0) {
|
||||||
return { success: false, error: '没有符合条件的联系人' }
|
return { success: false, error: '没有符合条件的联系人' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,6 +186,17 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-link-card {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-link-card:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
.inline-emoji {
|
.inline-emoji {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
@@ -186,6 +186,17 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-link-card {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-link-card:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
.inline-emoji {
|
.inline-emoji {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ const MESSAGE_TYPE_MAP: Record<number, number> = {
|
|||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null
|
||||||
|
senderUsername?: string
|
||||||
|
fileNameSuffix?: string
|
||||||
exportMedia?: boolean
|
exportMedia?: boolean
|
||||||
exportAvatars?: boolean
|
exportAvatars?: boolean
|
||||||
exportImages?: boolean
|
exportImages?: boolean
|
||||||
@@ -534,11 +536,14 @@ class ExportService {
|
|||||||
groupNicknamesMap: Map<string, string>,
|
groupNicknamesMap: Map<string, string>,
|
||||||
getContactName: (username: string) => Promise<string>
|
getContactName: (username: string) => Promise<string>
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const xmlType = this.extractXmlValue(content, 'type')
|
const normalizedContent = this.normalizeAppMessageContent(content || '')
|
||||||
if (xmlType !== '2000') return null
|
if (!normalizedContent) return null
|
||||||
|
|
||||||
const payerUsername = this.extractXmlValue(content, 'payer_username')
|
const xmlType = this.extractXmlValue(normalizedContent, 'type')
|
||||||
const receiverUsername = this.extractXmlValue(content, 'receiver_username')
|
if (xmlType && xmlType !== '2000') return null
|
||||||
|
|
||||||
|
const payerUsername = this.extractXmlValue(normalizedContent, 'payer_username')
|
||||||
|
const receiverUsername = this.extractXmlValue(normalizedContent, 'receiver_username')
|
||||||
if (!payerUsername || !receiverUsername) return null
|
if (!payerUsername || !receiverUsername) return null
|
||||||
|
|
||||||
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
|
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
|
||||||
@@ -565,6 +570,52 @@ class ExportService {
|
|||||||
return `${payerName} 转账给 ${receiverName}`
|
return `${payerName} 转账给 ${receiverName}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isSameWxid(lhs?: string, rhs?: string): boolean {
|
||||||
|
const left = new Set(this.buildGroupNicknameIdCandidates([lhs]).map((id) => id.toLowerCase()))
|
||||||
|
if (left.size === 0) return false
|
||||||
|
const right = this.buildGroupNicknameIdCandidates([rhs]).map((id) => id.toLowerCase())
|
||||||
|
return right.some((id) => left.has(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTransferPrefix(content: string, myWxid?: string, senderWxid?: string, isSend?: boolean): '[转账]' | '[转账收款]' {
|
||||||
|
const normalizedContent = this.normalizeAppMessageContent(content || '')
|
||||||
|
if (!normalizedContent) return '[转账]'
|
||||||
|
|
||||||
|
const paySubtype = this.extractXmlValue(normalizedContent, 'paysubtype')
|
||||||
|
// 转账消息在部分账号数据中 `payer_username` 可能为空,优先用 `paysubtype` 判定
|
||||||
|
// 实测:1=发起侧,3=收款侧
|
||||||
|
if (paySubtype === '3') return '[转账收款]'
|
||||||
|
if (paySubtype === '1') return '[转账]'
|
||||||
|
|
||||||
|
const payerUsername = this.extractXmlValue(normalizedContent, 'payer_username')
|
||||||
|
const receiverUsername = this.extractXmlValue(normalizedContent, 'receiver_username')
|
||||||
|
const senderIsPayer = senderWxid ? this.isSameWxid(senderWxid, payerUsername) : false
|
||||||
|
const senderIsReceiver = senderWxid ? this.isSameWxid(senderWxid, receiverUsername) : false
|
||||||
|
|
||||||
|
// 实测字段语义:sender 命中 receiver_username 为转账发起侧,命中 payer_username 为收款侧
|
||||||
|
if (senderWxid) {
|
||||||
|
if (senderIsReceiver && !senderIsPayer) return '[转账]'
|
||||||
|
if (senderIsPayer && !senderIsReceiver) return '[转账收款]'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:按当前账号角色判断
|
||||||
|
if (myWxid) {
|
||||||
|
if (this.isSameWxid(myWxid, receiverUsername)) return '[转账]'
|
||||||
|
if (this.isSameWxid(myWxid, payerUsername)) return '[转账收款]'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '[转账]'
|
||||||
|
}
|
||||||
|
|
||||||
|
private isTransferExportContent(content: string): boolean {
|
||||||
|
return content.startsWith('[转账]') || content.startsWith('[转账收款]')
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendTransferDesc(content: string, transferDesc: string): string {
|
||||||
|
const prefix = content.startsWith('[转账收款]') ? '[转账收款]' : '[转账]'
|
||||||
|
return content.replace(prefix, `${prefix} (${transferDesc})`)
|
||||||
|
}
|
||||||
|
|
||||||
private looksLikeBase64(s: string): boolean {
|
private looksLikeBase64(s: string): boolean {
|
||||||
if (s.length % 4 !== 0) return false
|
if (s.length % 4 !== 0) return false
|
||||||
return /^[A-Za-z0-9+/=]+$/.test(s)
|
return /^[A-Za-z0-9+/=]+$/.test(s)
|
||||||
@@ -574,7 +625,15 @@ class ExportService {
|
|||||||
* 解析消息内容为可读文本
|
* 解析消息内容为可读文本
|
||||||
* 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理
|
* 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理
|
||||||
*/
|
*/
|
||||||
private parseMessageContent(content: string, localType: number, sessionId?: string, createTime?: number): string | null {
|
private parseMessageContent(
|
||||||
|
content: string,
|
||||||
|
localType: number,
|
||||||
|
sessionId?: string,
|
||||||
|
createTime?: number,
|
||||||
|
myWxid?: string,
|
||||||
|
senderWxid?: string,
|
||||||
|
isSend?: boolean
|
||||||
|
): string | null {
|
||||||
if (!content) return null
|
if (!content) return null
|
||||||
|
|
||||||
// 检查 XML 中的 type 标签(支持大 localType 的情况)
|
// 检查 XML 中的 type 标签(支持大 localType 的情况)
|
||||||
@@ -611,10 +670,11 @@ class ExportService {
|
|||||||
if (type === '2000') {
|
if (type === '2000') {
|
||||||
const feedesc = this.extractXmlValue(content, 'feedesc')
|
const feedesc = this.extractXmlValue(content, 'feedesc')
|
||||||
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
||||||
|
const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend)
|
||||||
if (feedesc) {
|
if (feedesc) {
|
||||||
return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}`
|
return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}`
|
||||||
}
|
}
|
||||||
return '[转账]'
|
return transferPrefix
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === '6') return title ? `[文件] ${title}` : '[文件]'
|
if (type === '6') return title ? `[文件] ${title}` : '[文件]'
|
||||||
@@ -650,10 +710,11 @@ class ExportService {
|
|||||||
if (xmlType === '2000') {
|
if (xmlType === '2000') {
|
||||||
const feedesc = this.extractXmlValue(content, 'feedesc')
|
const feedesc = this.extractXmlValue(content, 'feedesc')
|
||||||
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
||||||
|
const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend)
|
||||||
if (feedesc) {
|
if (feedesc) {
|
||||||
return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}`
|
return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}`
|
||||||
}
|
}
|
||||||
return '[转账]'
|
return transferPrefix
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他类型
|
// 其他类型
|
||||||
@@ -676,7 +737,10 @@ class ExportService {
|
|||||||
content: string,
|
content: string,
|
||||||
localType: number,
|
localType: number,
|
||||||
options: { exportVoiceAsText?: boolean },
|
options: { exportVoiceAsText?: boolean },
|
||||||
voiceTranscript?: string
|
voiceTranscript?: string,
|
||||||
|
myWxid?: string,
|
||||||
|
senderWxid?: string,
|
||||||
|
isSend?: boolean
|
||||||
): string {
|
): string {
|
||||||
const safeContent = content || ''
|
const safeContent = content || ''
|
||||||
|
|
||||||
@@ -742,8 +806,9 @@ class ExportService {
|
|||||||
if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) {
|
if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) {
|
||||||
const feedesc = this.extractXmlValue(normalized, 'feedesc')
|
const feedesc = this.extractXmlValue(normalized, 'feedesc')
|
||||||
const payMemo = this.extractXmlValue(normalized, 'pay_memo')
|
const payMemo = this.extractXmlValue(normalized, 'pay_memo')
|
||||||
|
const transferPrefix = this.getTransferPrefix(normalized, myWxid, senderWxid, isSend)
|
||||||
if (feedesc) {
|
if (feedesc) {
|
||||||
return payMemo ? `[转账]${feedesc} ${payMemo}` : `[转账]${feedesc}`
|
return payMemo ? `${transferPrefix}${feedesc} ${payMemo}` : `${transferPrefix}${feedesc}`
|
||||||
}
|
}
|
||||||
const amount = this.extractAmountFromText(
|
const amount = this.extractAmountFromText(
|
||||||
[
|
[
|
||||||
@@ -756,7 +821,7 @@ class ExportService {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
)
|
)
|
||||||
return amount ? `[转账]${amount}` : '[转账]'
|
return amount ? `${transferPrefix}${amount}` : transferPrefix
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) {
|
if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) {
|
||||||
@@ -1256,7 +1321,7 @@ class ExportService {
|
|||||||
return rendered.join('')
|
return rendered.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatHtmlMessageText(content: string, localType: number): string {
|
private formatHtmlMessageText(content: string, localType: number, myWxid?: string, senderWxid?: string, isSend?: boolean): string {
|
||||||
if (!content) return ''
|
if (!content) return ''
|
||||||
|
|
||||||
if (localType === 1) {
|
if (localType === 1) {
|
||||||
@@ -1264,10 +1329,59 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (localType === 34) {
|
if (localType === 34) {
|
||||||
return this.parseMessageContent(content, localType) || ''
|
return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false })
|
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractHtmlLinkCard(content: string, localType: number): { title: string; url: string } | null {
|
||||||
|
if (!content) return null
|
||||||
|
|
||||||
|
const normalized = this.normalizeAppMessageContent(content)
|
||||||
|
const isAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')
|
||||||
|
if (!isAppMessage) return null
|
||||||
|
|
||||||
|
const subType = this.extractXmlValue(normalized, 'type')
|
||||||
|
if (subType && subType !== '5' && subType !== '49') return null
|
||||||
|
|
||||||
|
const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url'))
|
||||||
|
if (!url) return null
|
||||||
|
|
||||||
|
const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url
|
||||||
|
return { title, url }
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeHtmlLinkUrl(rawUrl: string): string {
|
||||||
|
const value = (rawUrl || '').trim()
|
||||||
|
if (!value) return ''
|
||||||
|
|
||||||
|
const parseHttpUrl = (candidate: string): string => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(candidate)
|
||||||
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||||
|
return parsed.toString()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith('//')) {
|
||||||
|
return parseHttpUrl(`https:${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const direct = parseHttpUrl(value)
|
||||||
|
if (direct) return direct
|
||||||
|
|
||||||
|
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)
|
||||||
|
const isDomainLike = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:[/:?#].*)?$/.test(value)
|
||||||
|
if (!hasScheme && isDomainLike) {
|
||||||
|
return parseHttpUrl(`https://${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1479,49 +1593,30 @@ class ExportService {
|
|||||||
fs.mkdirSync(emojisDir, { recursive: true })
|
fs.mkdirSync(emojisDir, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用消息对象中已提取的字段
|
// 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑)
|
||||||
const emojiUrl = msg.emojiCdnUrl
|
const localPath = await chatService.downloadEmojiFile(msg)
|
||||||
const emojiMd5 = msg.emojiMd5
|
|
||||||
|
|
||||||
if (!emojiUrl && !emojiMd5) {
|
|
||||||
|
|
||||||
|
if (!localPath || !fs.existsSync(localPath)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 确定目标文件名
|
||||||
|
const ext = path.extname(localPath) || '.gif'
|
||||||
const key = emojiMd5 || String(msg.localId)
|
const key = msg.emojiMd5 || String(msg.localId)
|
||||||
// 根据 URL 判断扩展名
|
|
||||||
let ext = '.gif'
|
|
||||||
if (emojiUrl) {
|
|
||||||
if (emojiUrl.includes('.png')) ext = '.png'
|
|
||||||
else if (emojiUrl.includes('.jpg') || emojiUrl.includes('.jpeg')) ext = '.jpg'
|
|
||||||
}
|
|
||||||
const fileName = `${key}${ext}`
|
const fileName = `${key}${ext}`
|
||||||
const destPath = path.join(emojisDir, fileName)
|
const destPath = path.join(emojisDir, fileName)
|
||||||
|
|
||||||
// 如果已存在则跳过
|
// 复制文件到导出目录 (如果不存在)
|
||||||
if (fs.existsSync(destPath)) {
|
if (!fs.existsSync(destPath)) {
|
||||||
|
fs.copyFileSync(localPath, destPath)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
||||||
kind: 'emoji'
|
kind: 'emoji'
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 下载表情
|
|
||||||
if (emojiUrl) {
|
|
||||||
const downloaded = await this.downloadFile(emojiUrl, destPath)
|
|
||||||
if (downloaded) {
|
|
||||||
return {
|
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
|
||||||
kind: 'emoji'
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error('ExportService: exportEmoji failed', e)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1704,7 +1799,8 @@ class ExportService {
|
|||||||
private async collectMessages(
|
private async collectMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
cleanedMyWxid: string,
|
cleanedMyWxid: string,
|
||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null,
|
||||||
|
senderUsernameFilter?: string
|
||||||
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
|
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
|
||||||
const rows: any[] = []
|
const rows: any[] = []
|
||||||
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
|
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
|
||||||
@@ -1765,6 +1861,10 @@ class ExportService {
|
|||||||
} else {
|
} else {
|
||||||
actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
|
actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (senderUsernameFilter && !this.isSameWxid(actualSender, senderUsernameFilter)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
senderSet.add(actualSender)
|
senderSet.add(actualSender)
|
||||||
|
|
||||||
// 提取媒体相关字段
|
// 提取媒体相关字段
|
||||||
@@ -2193,7 +2293,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||||
const allMessages = collected.rows
|
const allMessages = collected.rows
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
@@ -2354,11 +2454,19 @@ class ExportService {
|
|||||||
// 使用预先转写的文字
|
// 使用预先转写的文字
|
||||||
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||||
} else {
|
} else {
|
||||||
content = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime)
|
content = this.parseMessageContent(
|
||||||
|
msg.content,
|
||||||
|
msg.localType,
|
||||||
|
sessionId,
|
||||||
|
msg.createTime,
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转账消息:追加 "谁转账给谁" 信息
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
if (content && content.startsWith('[转账]') && msg.content) {
|
if (content && this.isTransferExportContent(content) && msg.content) {
|
||||||
const transferDesc = await this.resolveTransferDesc(
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
msg.content,
|
msg.content,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -2369,7 +2477,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (transferDesc) {
|
if (transferDesc) {
|
||||||
content = content.replace('[转账]', `[转账] (${transferDesc})`)
|
content = this.appendTransferDesc(content, transferDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2580,7 +2688,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
@@ -2724,11 +2832,19 @@ class ExportService {
|
|||||||
} else if (mediaItem) {
|
} else if (mediaItem) {
|
||||||
content = mediaItem.relativePath
|
content = mediaItem.relativePath
|
||||||
} else {
|
} else {
|
||||||
content = this.parseMessageContent(msg.content, msg.localType)
|
content = this.parseMessageContent(
|
||||||
|
msg.content,
|
||||||
|
msg.localType,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转账消息:追加 "谁转账给谁" 信息
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
if (content && content.startsWith('[转账]') && msg.content) {
|
if (content && this.isTransferExportContent(content) && msg.content) {
|
||||||
const transferDesc = await this.resolveTransferDesc(
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
msg.content,
|
msg.content,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -2742,7 +2858,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (transferDesc) {
|
if (transferDesc) {
|
||||||
content = content.replace('[转账]', `[转账] (${transferDesc})`)
|
content = this.appendTransferDesc(content, transferDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2906,7 +3022,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
@@ -3215,19 +3331,25 @@ class ExportService {
|
|||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId),
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
)
|
)
|
||||||
: (mediaItem?.relativePath
|
: (mediaItem?.relativePath
|
||||||
|| this.formatPlainExportContent(
|
|| this.formatPlainExportContent(
|
||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId),
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
))
|
))
|
||||||
|
|
||||||
// 转账消息:追加 "谁转账给谁" 信息
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
let enrichedContentValue = contentValue
|
let enrichedContentValue = contentValue
|
||||||
if (contentValue.startsWith('[转账]') && msg.content) {
|
if (this.isTransferExportContent(contentValue) && msg.content) {
|
||||||
const transferDesc = await this.resolveTransferDesc(
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
msg.content,
|
msg.content,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -3241,7 +3363,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (transferDesc) {
|
if (transferDesc) {
|
||||||
enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`)
|
enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3387,7 +3509,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
@@ -3526,19 +3648,25 @@ class ExportService {
|
|||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId),
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
)
|
)
|
||||||
: (mediaItem?.relativePath
|
: (mediaItem?.relativePath
|
||||||
|| this.formatPlainExportContent(
|
|| this.formatPlainExportContent(
|
||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId),
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
))
|
))
|
||||||
|
|
||||||
// 转账消息:追加 "谁转账给谁" 信息
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
let enrichedContentValue = contentValue
|
let enrichedContentValue = contentValue
|
||||||
if (contentValue.startsWith('[转账]') && msg.content) {
|
if (this.isTransferExportContent(contentValue) && msg.content) {
|
||||||
const transferDesc = await this.resolveTransferDesc(
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
msg.content,
|
msg.content,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -3552,7 +3680,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (transferDesc) {
|
if (transferDesc) {
|
||||||
enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`)
|
enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3661,7 +3789,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||||
}
|
}
|
||||||
@@ -3824,7 +3952,15 @@ class ExportService {
|
|||||||
|
|
||||||
const msgText = msg.localType === 34 && options.exportVoiceAsText
|
const msgText = msg.localType === 34 && options.exportVoiceAsText
|
||||||
? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]')
|
? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]')
|
||||||
: (this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime) || '')
|
: (this.parseMessageContent(
|
||||||
|
msg.content,
|
||||||
|
msg.localType,
|
||||||
|
sessionId,
|
||||||
|
msg.createTime,
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
|
) || '')
|
||||||
const src = this.getWeCloneSource(msg, typeName, mediaItem)
|
const src = this.getWeCloneSource(msg, typeName, mediaItem)
|
||||||
|
|
||||||
const row = [
|
const row = [
|
||||||
@@ -3974,6 +4110,15 @@ class ExportService {
|
|||||||
const isGroup = sessionId.includes('@chatroom')
|
const isGroup = sessionId.includes('@chatroom')
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
const myInfo = await this.getContactInfo(cleanedMyWxid)
|
||||||
|
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
|
||||||
|
const getContactCached = async (username: string) => {
|
||||||
|
if (contactCache.has(username)) {
|
||||||
|
return contactCache.get(username)!
|
||||||
|
}
|
||||||
|
const result = await wcdbService.getContact(username)
|
||||||
|
contactCache.set(username, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
current: 0,
|
current: 0,
|
||||||
@@ -3986,13 +4131,31 @@ class ExportService {
|
|||||||
await this.ensureVoiceModel(onProgress)
|
await this.ensureVoiceModel(onProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const senderUsernames = new Set<string>()
|
||||||
|
for (const msg of collected.rows) {
|
||||||
|
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
|
||||||
|
}
|
||||||
|
senderUsernames.add(sessionId)
|
||||||
|
await this.preloadContacts(senderUsernames, contactCache)
|
||||||
|
|
||||||
|
const groupNicknameCandidates = isGroup
|
||||||
|
? this.buildGroupNicknameIdCandidates([
|
||||||
|
...Array.from(senderUsernames.values()),
|
||||||
|
...collected.rows.map(msg => msg.senderUsername),
|
||||||
|
cleanedMyWxid
|
||||||
|
])
|
||||||
|
: []
|
||||||
|
const groupNicknamesMap = isGroup
|
||||||
|
? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
|
||||||
|
: new Map<string, string>()
|
||||||
|
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
||||||
}
|
}
|
||||||
@@ -4198,13 +4361,38 @@ class ExportService {
|
|||||||
const timeText = this.formatTimestamp(msg.createTime)
|
const timeText = this.formatTimestamp(msg.createTime)
|
||||||
const typeName = this.getMessageTypeName(msg.localType)
|
const typeName = this.getMessageTypeName(msg.localType)
|
||||||
|
|
||||||
let textContent = this.formatHtmlMessageText(msg.content, msg.localType)
|
let textContent = this.formatHtmlMessageText(
|
||||||
|
msg.content,
|
||||||
|
msg.localType,
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
|
)
|
||||||
if (msg.localType === 34 && useVoiceTranscript) {
|
if (msg.localType === 34 && useVoiceTranscript) {
|
||||||
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||||
}
|
}
|
||||||
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) {
|
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) {
|
||||||
textContent = ''
|
textContent = ''
|
||||||
}
|
}
|
||||||
|
if (this.isTransferExportContent(textContent) && msg.content) {
|
||||||
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
|
msg.content,
|
||||||
|
cleanedMyWxid,
|
||||||
|
groupNicknamesMap,
|
||||||
|
async (username) => {
|
||||||
|
const c = await getContactCached(username)
|
||||||
|
if (c.success && c.contact) {
|
||||||
|
return c.contact.remark || c.contact.nickName || c.contact.alias || username
|
||||||
|
}
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (transferDesc) {
|
||||||
|
textContent = this.appendTransferDesc(textContent, transferDesc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkCard = this.extractHtmlLinkCard(msg.content, msg.localType)
|
||||||
|
|
||||||
let mediaHtml = ''
|
let mediaHtml = ''
|
||||||
if (mediaItem?.kind === 'image') {
|
if (mediaItem?.kind === 'image') {
|
||||||
@@ -4220,9 +4408,11 @@ class ExportService {
|
|||||||
mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></video>`
|
mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></video>`
|
||||||
}
|
}
|
||||||
|
|
||||||
const textHtml = textContent
|
const textHtml = linkCard
|
||||||
|
? `<div class="message-text"><a class="message-link-card" href="${this.escapeAttribute(linkCard.url)}" target="_blank" rel="noopener noreferrer">${this.renderTextWithEmoji(linkCard.title).replace(/\r?\n/g, '<br />')}</a></div>`
|
||||||
|
: (textContent
|
||||||
? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
|
? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
|
||||||
: ''
|
: '')
|
||||||
const senderNameHtml = isGroup
|
const senderNameHtml = isGroup
|
||||||
? `<div class="sender-name">${this.escapeHtml(senderName)}</div>`
|
? `<div class="sender-name">${this.escapeHtml(senderName)}</div>`
|
||||||
: ''
|
: ''
|
||||||
@@ -4413,7 +4603,7 @@ class ExportService {
|
|||||||
|
|
||||||
for (const sessionId of sessionIds) {
|
for (const sessionId of sessionIds) {
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
||||||
const msgs = collected.rows
|
const msgs = collected.rows
|
||||||
const voiceMsgs = msgs.filter(m => m.localType === 34)
|
const voiceMsgs = msgs.filter(m => m.localType === 34)
|
||||||
const mediaMsgs = msgs.filter(m => {
|
const mediaMsgs = msgs.filter(m => {
|
||||||
@@ -4512,7 +4702,10 @@ class ExportService {
|
|||||||
phase: 'exporting'
|
phase: 'exporting'
|
||||||
})
|
})
|
||||||
|
|
||||||
const safeName = sessionInfo.displayName.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '')
|
const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim()
|
||||||
|
const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session'
|
||||||
|
const suffix = sanitizeName(options.fileNameSuffix || '')
|
||||||
|
const safeName = suffix ? `${baseName}_${suffix}` : baseName
|
||||||
const useSessionFolder = sessionLayout === 'per-session'
|
const useSessionFolder = sessionLayout === 'per-session'
|
||||||
const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir
|
const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir
|
||||||
|
|
||||||
@@ -4576,3 +4769,4 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const exportService = new ExportService()
|
export const exportService = new ExportService()
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ExcelJS from 'exceljs'
|
|||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { chatService } from './chatService'
|
import { chatService } from './chatService'
|
||||||
|
import type { Message } from './chatService'
|
||||||
|
|
||||||
export interface GroupChatInfo {
|
export interface GroupChatInfo {
|
||||||
username: string
|
username: string
|
||||||
@@ -339,6 +340,92 @@ class GroupAnalyticsService {
|
|||||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private formatUnixTime(createTime: number): string {
|
||||||
|
if (!Number.isFinite(createTime) || createTime <= 0) return ''
|
||||||
|
const milliseconds = createTime > 1e12 ? createTime : createTime * 1000
|
||||||
|
const date = new Date(milliseconds)
|
||||||
|
if (Number.isNaN(date.getTime())) return String(createTime)
|
||||||
|
return this.formatDateTime(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSimpleMessageTypeName(localType: number): string {
|
||||||
|
const typeMap: Record<number, string> = {
|
||||||
|
1: '文本',
|
||||||
|
3: '图片',
|
||||||
|
34: '语音',
|
||||||
|
42: '名片',
|
||||||
|
43: '视频',
|
||||||
|
47: '表情',
|
||||||
|
48: '位置',
|
||||||
|
49: '链接/文件',
|
||||||
|
50: '通话',
|
||||||
|
10000: '系统',
|
||||||
|
266287972401: '拍一拍',
|
||||||
|
8594229559345: '红包',
|
||||||
|
8589934592049: '转账'
|
||||||
|
}
|
||||||
|
return typeMap[localType] || `类型(${localType})`
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeIdCandidates(values: Array<string | null | undefined>): string[] {
|
||||||
|
return this.buildIdCandidates(values).map(value => value.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSameAccountIdentity(left: string | null | undefined, right: string | null | undefined): boolean {
|
||||||
|
const leftCandidates = this.normalizeIdCandidates([left])
|
||||||
|
const rightCandidates = this.normalizeIdCandidates([right])
|
||||||
|
if (leftCandidates.length === 0 || rightCandidates.length === 0) return false
|
||||||
|
|
||||||
|
const rightSet = new Set(rightCandidates)
|
||||||
|
for (const leftCandidate of leftCandidates) {
|
||||||
|
if (rightSet.has(leftCandidate)) return true
|
||||||
|
for (const rightCandidate of rightCandidates) {
|
||||||
|
if (leftCandidate.startsWith(`${rightCandidate}_`) || rightCandidate.startsWith(`${leftCandidate}_`)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveExportMessageContent(message: Message): string {
|
||||||
|
const parsed = String(message.parsedContent || '').trim()
|
||||||
|
if (parsed) return parsed
|
||||||
|
const raw = String(message.rawContent || '').trim()
|
||||||
|
if (raw) return raw
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private async collectMessagesByMember(
|
||||||
|
chatroomId: string,
|
||||||
|
memberUsername: string,
|
||||||
|
startTime: number,
|
||||||
|
endTime: number
|
||||||
|
): Promise<{ success: boolean; data?: Message[]; error?: string }> {
|
||||||
|
const batchSize = 500
|
||||||
|
const matchedMessages: Message[] = []
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true)
|
||||||
|
if (!batch.success || !batch.messages) {
|
||||||
|
return { success: false, error: batch.error || '获取群消息失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of batch.messages) {
|
||||||
|
if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) {
|
||||||
|
matchedMessages.push(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchedCount = batch.messages.length
|
||||||
|
if (fetchedCount <= 0 || !batch.hasMore) break
|
||||||
|
offset += fetchedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: matchedMessages }
|
||||||
|
}
|
||||||
|
|
||||||
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
@@ -611,6 +698,181 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportGroupMemberMessages(
|
||||||
|
chatroomId: string,
|
||||||
|
memberUsername: string,
|
||||||
|
outputPath: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||||
|
try {
|
||||||
|
const conn = await this.ensureConnected()
|
||||||
|
if (!conn.success) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||||
|
const normalizedMemberUsername = String(memberUsername || '').trim()
|
||||||
|
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
|
||||||
|
if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' }
|
||||||
|
|
||||||
|
const beginTimestamp = Number.isFinite(startTime) && typeof startTime === 'number'
|
||||||
|
? Math.max(0, Math.floor(startTime))
|
||||||
|
: 0
|
||||||
|
const endTimestampValue = Number.isFinite(endTime) && typeof endTime === 'number'
|
||||||
|
? Math.max(0, Math.floor(endTime))
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const exportDate = new Date()
|
||||||
|
const exportTime = this.formatDateTime(exportDate)
|
||||||
|
const exportVersion = '0.0.2'
|
||||||
|
const exportGenerator = 'WeFlow'
|
||||||
|
const exportPlatform = 'wechat'
|
||||||
|
|
||||||
|
const groupDisplay = await wcdbService.getDisplayNames([normalizedChatroomId, normalizedMemberUsername])
|
||||||
|
const groupName = groupDisplay.success && groupDisplay.map
|
||||||
|
? (groupDisplay.map[normalizedChatroomId] || normalizedChatroomId)
|
||||||
|
: normalizedChatroomId
|
||||||
|
const defaultMemberDisplayName = groupDisplay.success && groupDisplay.map
|
||||||
|
? (groupDisplay.map[normalizedMemberUsername] || normalizedMemberUsername)
|
||||||
|
: normalizedMemberUsername
|
||||||
|
|
||||||
|
let memberDisplayName = defaultMemberDisplayName
|
||||||
|
let memberAlias = ''
|
||||||
|
let memberRemark = ''
|
||||||
|
let memberGroupNickname = ''
|
||||||
|
const membersResult = await this.getGroupMembers(normalizedChatroomId)
|
||||||
|
if (membersResult.success && membersResult.data) {
|
||||||
|
const matchedMember = membersResult.data.find((item) =>
|
||||||
|
this.isSameAccountIdentity(item.username, normalizedMemberUsername)
|
||||||
|
)
|
||||||
|
if (matchedMember) {
|
||||||
|
memberDisplayName = matchedMember.displayName || defaultMemberDisplayName
|
||||||
|
memberAlias = matchedMember.alias || ''
|
||||||
|
memberRemark = matchedMember.remark || ''
|
||||||
|
memberGroupNickname = matchedMember.groupNickname || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collected = await this.collectMessagesByMember(
|
||||||
|
normalizedChatroomId,
|
||||||
|
normalizedMemberUsername,
|
||||||
|
beginTimestamp,
|
||||||
|
endTimestampValue
|
||||||
|
)
|
||||||
|
if (!collected.success || !collected.data) {
|
||||||
|
return { success: false, error: collected.error || '获取成员消息失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = collected.data.map((message, index) => ({
|
||||||
|
index: index + 1,
|
||||||
|
time: this.formatUnixTime(message.createTime),
|
||||||
|
sender: message.senderUsername || '',
|
||||||
|
messageType: this.getSimpleMessageTypeName(message.localType),
|
||||||
|
content: this.resolveExportMessageContent(message)
|
||||||
|
}))
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
||||||
|
const ext = path.extname(outputPath).toLowerCase()
|
||||||
|
if (ext === '.csv') {
|
||||||
|
const infoTitleRow = ['会话信息']
|
||||||
|
const infoRow = ['群聊ID', normalizedChatroomId, '', '群聊名称', groupName, '成员wxid', normalizedMemberUsername, '']
|
||||||
|
const memberRow = ['成员显示名', memberDisplayName, '成员备注', memberRemark, '群昵称', memberGroupNickname, '微信号', memberAlias]
|
||||||
|
const metaRow = ['导出工具', exportGenerator, '导出版本', exportVersion, '平台', exportPlatform, '导出时间', exportTime]
|
||||||
|
const header = ['序号', '时间', '发送者wxid', '消息类型', '内容']
|
||||||
|
|
||||||
|
const csvRows: string[][] = [infoTitleRow, infoRow, memberRow, metaRow, header]
|
||||||
|
for (const record of records) {
|
||||||
|
csvRows.push([String(record.index), record.time, record.sender, record.messageType, record.content])
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvLines = csvRows.map((row) => row.map((cell) => this.escapeCsvValue(cell)).join(','))
|
||||||
|
const content = '\ufeff' + csvLines.join('\n')
|
||||||
|
fs.writeFileSync(outputPath, content, 'utf8')
|
||||||
|
} else {
|
||||||
|
const workbook = new ExcelJS.Workbook()
|
||||||
|
const worksheet = workbook.addWorksheet(this.sanitizeWorksheetName('成员消息记录'))
|
||||||
|
|
||||||
|
worksheet.getCell(1, 1).value = '会话信息'
|
||||||
|
worksheet.getCell(1, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getRow(1).height = 24
|
||||||
|
|
||||||
|
worksheet.getCell(2, 1).value = '群聊ID'
|
||||||
|
worksheet.getCell(2, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.mergeCells(2, 2, 2, 3)
|
||||||
|
worksheet.getCell(2, 2).value = normalizedChatroomId
|
||||||
|
|
||||||
|
worksheet.getCell(2, 4).value = '群聊名称'
|
||||||
|
worksheet.getCell(2, 4).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(2, 5).value = groupName
|
||||||
|
worksheet.getCell(2, 6).value = '成员wxid'
|
||||||
|
worksheet.getCell(2, 6).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.mergeCells(2, 7, 2, 8)
|
||||||
|
worksheet.getCell(2, 7).value = normalizedMemberUsername
|
||||||
|
|
||||||
|
worksheet.getCell(3, 1).value = '成员显示名'
|
||||||
|
worksheet.getCell(3, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(3, 2).value = memberDisplayName
|
||||||
|
worksheet.getCell(3, 3).value = '成员备注'
|
||||||
|
worksheet.getCell(3, 3).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(3, 4).value = memberRemark
|
||||||
|
worksheet.getCell(3, 5).value = '群昵称'
|
||||||
|
worksheet.getCell(3, 5).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(3, 6).value = memberGroupNickname
|
||||||
|
worksheet.getCell(3, 7).value = '微信号'
|
||||||
|
worksheet.getCell(3, 7).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(3, 8).value = memberAlias
|
||||||
|
|
||||||
|
worksheet.getCell(4, 1).value = '导出工具'
|
||||||
|
worksheet.getCell(4, 1).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(4, 2).value = exportGenerator
|
||||||
|
worksheet.getCell(4, 3).value = '导出版本'
|
||||||
|
worksheet.getCell(4, 3).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(4, 4).value = exportVersion
|
||||||
|
worksheet.getCell(4, 5).value = '平台'
|
||||||
|
worksheet.getCell(4, 5).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(4, 6).value = exportPlatform
|
||||||
|
worksheet.getCell(4, 7).value = '导出时间'
|
||||||
|
worksheet.getCell(4, 7).font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
worksheet.getCell(4, 8).value = exportTime
|
||||||
|
|
||||||
|
const headerRow = worksheet.getRow(5)
|
||||||
|
const header = ['序号', '时间', '发送者wxid', '消息类型', '内容']
|
||||||
|
header.forEach((title, index) => {
|
||||||
|
const cell = headerRow.getCell(index + 1)
|
||||||
|
cell.value = title
|
||||||
|
cell.font = { name: 'Calibri', bold: true, size: 11 }
|
||||||
|
})
|
||||||
|
headerRow.height = 22
|
||||||
|
|
||||||
|
worksheet.getColumn(1).width = 10
|
||||||
|
worksheet.getColumn(2).width = 22
|
||||||
|
worksheet.getColumn(3).width = 30
|
||||||
|
worksheet.getColumn(4).width = 16
|
||||||
|
worksheet.getColumn(5).width = 90
|
||||||
|
worksheet.getColumn(6).width = 16
|
||||||
|
worksheet.getColumn(7).width = 20
|
||||||
|
worksheet.getColumn(8).width = 24
|
||||||
|
|
||||||
|
let currentRow = 6
|
||||||
|
for (const record of records) {
|
||||||
|
const row = worksheet.getRow(currentRow)
|
||||||
|
row.getCell(1).value = record.index
|
||||||
|
row.getCell(2).value = record.time
|
||||||
|
row.getCell(3).value = record.sender
|
||||||
|
row.getCell(4).value = record.messageType
|
||||||
|
row.getCell(5).value = record.content
|
||||||
|
row.alignment = { vertical: 'top', wrapText: true }
|
||||||
|
currentRow += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
await workbook.xlsx.writeFile(outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, count: records.length }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async exportGroupMembers(chatroomId: string, outputPath: string): Promise<{ success: boolean; count?: number; error?: string }> {
|
async exportGroupMembers(chatroomId: string, outputPath: string): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* HTTP API 服务
|
* HTTP API 服务
|
||||||
* 提供 ChatLab 标准化格式的消息查询 API
|
* 提供 ChatLab 标准化格式的消息查询 API
|
||||||
*/
|
*/
|
||||||
import * as http from 'http'
|
import * as http from 'http'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
import { URL } from 'url'
|
import { URL } from 'url'
|
||||||
import { chatService, Message } from './chatService'
|
import { chatService, Message } from './chatService'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
|
import { videoService } from './videoService'
|
||||||
|
|
||||||
// ChatLab 格式定义
|
// ChatLab 格式定义
|
||||||
interface ChatLabHeader {
|
interface ChatLabHeader {
|
||||||
@@ -42,6 +45,7 @@ interface ChatLabMessage {
|
|||||||
content: string | null
|
content: string | null
|
||||||
platformMessageId?: string
|
platformMessageId?: string
|
||||||
replyToMessageId?: string
|
replyToMessageId?: string
|
||||||
|
mediaPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatLabData {
|
interface ChatLabData {
|
||||||
@@ -51,6 +55,22 @@ interface ChatLabData {
|
|||||||
messages: ChatLabMessage[]
|
messages: ChatLabMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApiMediaOptions {
|
||||||
|
enabled: boolean
|
||||||
|
exportImages: boolean
|
||||||
|
exportVoices: boolean
|
||||||
|
exportVideos: boolean
|
||||||
|
exportEmojis: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaKind = 'image' | 'voice' | 'video' | 'emoji'
|
||||||
|
|
||||||
|
interface ApiExportedMedia {
|
||||||
|
kind: MediaKind
|
||||||
|
fileName: string
|
||||||
|
fullPath: string
|
||||||
|
}
|
||||||
|
|
||||||
// ChatLab 消息类型映射
|
// ChatLab 消息类型映射
|
||||||
const ChatLabType = {
|
const ChatLabType = {
|
||||||
TEXT: 0,
|
TEXT: 0,
|
||||||
@@ -163,6 +183,10 @@ class HttpService {
|
|||||||
return this.port
|
return this.port
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDefaultMediaExportPath(): string {
|
||||||
|
return this.getApiMediaExportPath()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 HTTP 请求
|
* 处理 HTTP 请求
|
||||||
*/
|
*/
|
||||||
@@ -213,7 +237,7 @@ class HttpService {
|
|||||||
ascending: boolean
|
ascending: boolean
|
||||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
// 使用固定 batch 大小(与 limit 相同或最大 500)来减少循环次数
|
// 使用固定 batch 大小(与 limit 相同或最多 500)来减少循环次数
|
||||||
const batchSize = Math.min(limit, 500)
|
const batchSize = Math.min(limit, 500)
|
||||||
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
||||||
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
||||||
@@ -240,7 +264,7 @@ class HttpService {
|
|||||||
let rows = batch.rows
|
let rows = batch.rows
|
||||||
hasMore = batch.hasMore === true
|
hasMore = batch.hasMore === true
|
||||||
|
|
||||||
// 处理 offset: 跳过前 N 条
|
// 处理 offset:跳过前 N 条
|
||||||
if (skipped < offset) {
|
if (skipped < offset) {
|
||||||
const remaining = offset - skipped
|
const remaining = offset - skipped
|
||||||
if (remaining >= rows.length) {
|
if (remaining >= rows.length) {
|
||||||
@@ -256,7 +280,7 @@ class HttpService {
|
|||||||
|
|
||||||
const trimmedRows = allRows.slice(0, limit)
|
const trimmedRows = allRows.slice(0, limit)
|
||||||
const finalHasMore = hasMore || allRows.length > limit
|
const finalHasMore = hasMore || allRows.length > limit
|
||||||
const messages = this.mapRowsToMessagesSimple(trimmedRows)
|
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
|
||||||
return { success: true, messages, hasMore: finalHasMore }
|
return { success: true, messages, hasMore: finalHasMore }
|
||||||
} finally {
|
} finally {
|
||||||
await wcdbService.closeMessageCursor(cursor)
|
await wcdbService.closeMessageCursor(cursor)
|
||||||
@@ -268,154 +292,134 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 简单的行数据到 Message 映射(用于 API 输出)
|
* Query param helpers.
|
||||||
*/
|
*/
|
||||||
private mapRowsToMessagesSimple(rows: Record<string, any>[]): Message[] {
|
private parseIntParam(value: string | null, defaultValue: number, min: number, max: number): number {
|
||||||
const myWxid = this.configService.get('myWxid') || ''
|
const parsed = parseInt(value || '', 10)
|
||||||
const messages: Message[] = []
|
if (!Number.isFinite(parsed)) return defaultValue
|
||||||
|
return Math.min(Math.max(parsed, min), max)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析消息内容中的特殊字段
|
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
|
||||||
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) {
|
for (const key of keys) {
|
||||||
if (row[key] !== undefined && row[key] !== null) {
|
const raw = url.searchParams.get(key)
|
||||||
return String(row[key])
|
if (raw === null) continue
|
||||||
|
const normalized = raw.trim().toLowerCase()
|
||||||
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
|
||||||
|
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseMediaOptions(url: URL): ApiMediaOptions {
|
||||||
|
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
|
||||||
|
if (!mediaEnabled) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
exportImages: false,
|
||||||
|
exportVoices: false,
|
||||||
|
exportVideos: false,
|
||||||
|
exportEmojis: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
exportImages: this.parseBooleanParam(url, ['image', 'tupian'], true),
|
||||||
|
exportVoices: this.parseBooleanParam(url, ['voice', 'vioce'], true),
|
||||||
|
exportVideos: this.parseBooleanParam(url, ['video'], true),
|
||||||
|
exportEmojis: this.parseBooleanParam(url, ['emoji'], true)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理消息查询
|
|
||||||
* GET /api/v1/messages?talker=xxx&limit=100&start=20260101&chatlab=1
|
|
||||||
*/
|
|
||||||
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
|
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
const talker = url.searchParams.get('talker')
|
const talker = (url.searchParams.get('talker') || '').trim()
|
||||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '100', 10), 10000)
|
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
||||||
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
|
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
||||||
|
const keyword = (url.searchParams.get('keyword') || '').trim().toLowerCase()
|
||||||
const startParam = url.searchParams.get('start')
|
const startParam = url.searchParams.get('start')
|
||||||
const endParam = url.searchParams.get('end')
|
const endParam = url.searchParams.get('end')
|
||||||
const chatlab = url.searchParams.get('chatlab') === '1'
|
const chatlab = this.parseBooleanParam(url, ['chatlab'], false)
|
||||||
const formatParam = url.searchParams.get('format')
|
const formatParam = (url.searchParams.get('format') || '').trim().toLowerCase()
|
||||||
const format = formatParam || (chatlab ? 'chatlab' : 'json')
|
const format = formatParam || (chatlab ? 'chatlab' : 'json')
|
||||||
|
const mediaOptions = this.parseMediaOptions(url)
|
||||||
|
|
||||||
if (!talker) {
|
if (!talker) {
|
||||||
this.sendError(res, 400, 'Missing required parameter: talker')
|
this.sendError(res, 400, 'Missing required parameter: talker')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析时间参数 (支持 YYYYMMDD 格式)
|
if (format !== 'json' && format !== 'chatlab') {
|
||||||
|
this.sendError(res, 400, 'Invalid format, supported: json/chatlab')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const startTime = this.parseTimeParam(startParam)
|
const startTime = this.parseTimeParam(startParam)
|
||||||
const endTime = this.parseTimeParam(endParam, true)
|
const endTime = this.parseTimeParam(endParam, true)
|
||||||
|
const queryOffset = keyword ? 0 : offset
|
||||||
|
const queryLimit = keyword ? 10000 : limit
|
||||||
|
|
||||||
// 使用批量获取方法,绕过 chatService 的单 batch 限制
|
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true)
|
||||||
const result = await this.fetchMessagesBatch(talker, offset, limit, startTime, endTime, true)
|
|
||||||
if (!result.success || !result.messages) {
|
if (!result.success || !result.messages) {
|
||||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === 'chatlab') {
|
let messages = result.messages
|
||||||
// 获取会话显示名
|
let hasMore = result.hasMore === true
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
const filtered = messages.filter((msg) => {
|
||||||
|
const content = (msg.parsedContent || msg.rawContent || '').toLowerCase()
|
||||||
|
return content.includes(keyword)
|
||||||
|
})
|
||||||
|
const endIndex = offset + limit
|
||||||
|
hasMore = filtered.length > endIndex
|
||||||
|
messages = filtered.slice(offset, endIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaMap = mediaOptions.enabled
|
||||||
|
? await this.exportMediaForMessages(messages, talker, mediaOptions)
|
||||||
|
: new Map<number, ApiExportedMedia>()
|
||||||
|
|
||||||
const displayNames = await this.getDisplayNames([talker])
|
const displayNames = await this.getDisplayNames([talker])
|
||||||
const talkerName = displayNames[talker] || talker
|
const talkerName = displayNames[talker] || talker
|
||||||
|
|
||||||
const chatLabData = await this.convertToChatLab(result.messages, talker, talkerName)
|
if (format === 'chatlab') {
|
||||||
this.sendJson(res, chatLabData)
|
const chatLabData = await this.convertToChatLab(messages, talker, talkerName, mediaMap)
|
||||||
} else {
|
this.sendJson(res, {
|
||||||
// 返回原始消息格式
|
...chatLabData,
|
||||||
|
media: {
|
||||||
|
enabled: mediaOptions.enabled,
|
||||||
|
exportPath: this.getApiMediaExportPath(),
|
||||||
|
count: mediaMap.size
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiMessages = messages.map((msg) => this.toApiMessage(msg, mediaMap.get(msg.localId)))
|
||||||
this.sendJson(res, {
|
this.sendJson(res, {
|
||||||
success: true,
|
success: true,
|
||||||
talker,
|
talker,
|
||||||
count: result.messages.length,
|
count: apiMessages.length,
|
||||||
hasMore: result.hasMore,
|
hasMore,
|
||||||
messages: result.messages
|
media: {
|
||||||
|
enabled: mediaOptions.enabled,
|
||||||
|
exportPath: this.getApiMediaExportPath(),
|
||||||
|
count: mediaMap.size
|
||||||
|
},
|
||||||
|
messages: apiMessages
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理会话列表查询
|
* 处理会话列表查询
|
||||||
* GET /api/v1/sessions?keyword=xxx&limit=100
|
* GET /api/v1/sessions?keyword=xxx&limit=100
|
||||||
*/
|
*/
|
||||||
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
|
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
const keyword = url.searchParams.get('keyword') || ''
|
const keyword = (url.searchParams.get('keyword') || '').trim()
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '100', 10)
|
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessions = await chatService.getSessions()
|
const sessions = await chatService.getSessions()
|
||||||
@@ -457,8 +461,8 @@ class HttpService {
|
|||||||
* GET /api/v1/contacts?keyword=xxx&limit=100
|
* GET /api/v1/contacts?keyword=xxx&limit=100
|
||||||
*/
|
*/
|
||||||
private async handleContacts(url: URL, res: http.ServerResponse): Promise<void> {
|
private async handleContacts(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
const keyword = url.searchParams.get('keyword') || ''
|
const keyword = (url.searchParams.get('keyword') || '').trim()
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '100', 10)
|
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contacts = await chatService.getContacts()
|
const contacts = await chatService.getContacts()
|
||||||
@@ -490,6 +494,156 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getApiMediaExportPath(): string {
|
||||||
|
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeFileName(value: string, fallback: string): string {
|
||||||
|
const safe = (value || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||||||
|
.replace(/\.+$/g, '')
|
||||||
|
return safe || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureDir(dirPath: string): void {
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectImageExt(buffer: Buffer): string {
|
||||||
|
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg'
|
||||||
|
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return '.png'
|
||||||
|
if (buffer.length >= 6) {
|
||||||
|
const sig6 = buffer.subarray(0, 6).toString('ascii')
|
||||||
|
if (sig6 === 'GIF87a' || sig6 === 'GIF89a') return '.gif'
|
||||||
|
}
|
||||||
|
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return '.webp'
|
||||||
|
if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4d) return '.bmp'
|
||||||
|
return '.jpg'
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exportMediaForMessages(
|
||||||
|
messages: Message[],
|
||||||
|
talker: string,
|
||||||
|
options: ApiMediaOptions
|
||||||
|
): Promise<Map<number, ApiExportedMedia>> {
|
||||||
|
const mediaMap = new Map<number, ApiExportedMedia>()
|
||||||
|
if (!options.enabled || messages.length === 0) {
|
||||||
|
return mediaMap
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
|
||||||
|
this.ensureDir(sessionDir)
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
|
||||||
|
if (exported) {
|
||||||
|
mediaMap.set(msg.localId, exported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaMap
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exportMediaForMessage(
|
||||||
|
msg: Message,
|
||||||
|
talker: string,
|
||||||
|
sessionDir: string,
|
||||||
|
options: ApiMediaOptions
|
||||||
|
): Promise<ApiExportedMedia | null> {
|
||||||
|
try {
|
||||||
|
if (msg.localType === 3 && options.exportImages) {
|
||||||
|
const result = await chatService.getImageData(talker, String(msg.localId))
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const imageBuffer = Buffer.from(result.data, 'base64')
|
||||||
|
const ext = this.detectImageExt(imageBuffer)
|
||||||
|
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||||
|
const fileName = `${fileBase}${ext}`
|
||||||
|
const targetDir = path.join(sessionDir, 'images')
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
this.ensureDir(targetDir)
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.writeFileSync(fullPath, imageBuffer)
|
||||||
|
}
|
||||||
|
return { kind: 'image', fileName, fullPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.localType === 34 && options.exportVoices) {
|
||||||
|
const result = await chatService.getVoiceData(
|
||||||
|
talker,
|
||||||
|
String(msg.localId),
|
||||||
|
msg.createTime || undefined,
|
||||||
|
msg.serverId || undefined
|
||||||
|
)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const fileName = `voice_${msg.localId}.wav`
|
||||||
|
const targetDir = path.join(sessionDir, 'voices')
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
this.ensureDir(targetDir)
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
|
||||||
|
}
|
||||||
|
return { kind: 'voice', fileName, fullPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.localType === 43 && options.exportVideos && msg.videoMd5) {
|
||||||
|
const info = await videoService.getVideoInfo(msg.videoMd5)
|
||||||
|
if (info.exists && info.videoUrl && fs.existsSync(info.videoUrl)) {
|
||||||
|
const ext = path.extname(info.videoUrl) || '.mp4'
|
||||||
|
const fileName = `${this.sanitizeFileName(msg.videoMd5, `video_${msg.localId}`)}${ext}`
|
||||||
|
const targetDir = path.join(sessionDir, 'videos')
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
this.ensureDir(targetDir)
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.copyFileSync(info.videoUrl, fullPath)
|
||||||
|
}
|
||||||
|
return { kind: 'video', fileName, fullPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.localType === 47 && options.exportEmojis && msg.emojiCdnUrl) {
|
||||||
|
const result = await chatService.downloadEmoji(msg.emojiCdnUrl, msg.emojiMd5)
|
||||||
|
if (result.success && result.localPath && fs.existsSync(result.localPath)) {
|
||||||
|
const sourceExt = path.extname(result.localPath) || '.gif'
|
||||||
|
const fileName = `${this.sanitizeFileName(msg.emojiMd5 || `emoji_${msg.localId}`, `emoji_${msg.localId}`)}${sourceExt}`
|
||||||
|
const targetDir = path.join(sessionDir, 'emojis')
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
this.ensureDir(targetDir)
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
fs.copyFileSync(result.localPath, fullPath)
|
||||||
|
}
|
||||||
|
return { kind: 'emoji', fileName, fullPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[HttpService] exportMediaForMessage failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private toApiMessage(msg: Message, media?: ApiExportedMedia): Record<string, any> {
|
||||||
|
return {
|
||||||
|
localId: msg.localId,
|
||||||
|
serverId: msg.serverId,
|
||||||
|
localType: msg.localType,
|
||||||
|
createTime: msg.createTime,
|
||||||
|
sortSeq: msg.sortSeq,
|
||||||
|
isSend: msg.isSend,
|
||||||
|
senderUsername: msg.senderUsername,
|
||||||
|
content: this.getMessageContent(msg),
|
||||||
|
rawContent: msg.rawContent,
|
||||||
|
parsedContent: msg.parsedContent,
|
||||||
|
mediaType: media?.kind,
|
||||||
|
mediaFileName: media?.fileName,
|
||||||
|
mediaPath: media?.fullPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析时间参数
|
* 解析时间参数
|
||||||
* 支持 YYYYMMDD 格式,返回秒级时间戳
|
* 支持 YYYYMMDD 格式,返回秒级时间戳
|
||||||
@@ -497,7 +651,7 @@ class HttpService {
|
|||||||
private parseTimeParam(param: string | null, isEnd: boolean = false): number {
|
private parseTimeParam(param: string | null, isEnd: boolean = false): number {
|
||||||
if (!param) return 0
|
if (!param) return 0
|
||||||
|
|
||||||
// 纯数字且长度为8,视为 YYYYMMDD
|
// 纯数字且长度为 8,视为 YYYYMMDD
|
||||||
if (/^\d{8}$/.test(param)) {
|
if (/^\d{8}$/.test(param)) {
|
||||||
const year = parseInt(param.slice(0, 4), 10)
|
const year = parseInt(param.slice(0, 4), 10)
|
||||||
const month = parseInt(param.slice(4, 6), 10) - 1
|
const month = parseInt(param.slice(4, 6), 10) - 1
|
||||||
@@ -539,7 +693,12 @@ class HttpService {
|
|||||||
/**
|
/**
|
||||||
* 转换为 ChatLab 格式
|
* 转换为 ChatLab 格式
|
||||||
*/
|
*/
|
||||||
private async convertToChatLab(messages: Message[], talkerId: string, talkerName: string): Promise<ChatLabData> {
|
private async convertToChatLab(
|
||||||
|
messages: Message[],
|
||||||
|
talkerId: string,
|
||||||
|
talkerName: string,
|
||||||
|
mediaMap: Map<number, ApiExportedMedia> = new Map()
|
||||||
|
): Promise<ChatLabData> {
|
||||||
const isGroup = talkerId.endsWith('@chatroom')
|
const isGroup = talkerId.endsWith('@chatroom')
|
||||||
const myWxid = this.configService.get('myWxid') || ''
|
const myWxid = this.configService.get('myWxid') || ''
|
||||||
|
|
||||||
@@ -603,7 +762,8 @@ class HttpService {
|
|||||||
timestamp: msg.createTime,
|
timestamp: msg.createTime,
|
||||||
type: this.mapMessageType(msg.localType, msg),
|
type: this.mapMessageType(msg.localType, msg),
|
||||||
content: this.getMessageContent(msg),
|
content: this.getMessageContent(msg),
|
||||||
platformMessageId: msg.serverId ? String(msg.serverId) : undefined
|
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
||||||
|
mediaPath: mediaMap.get(msg.localId)?.fullPath
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -705,13 +865,13 @@ class HttpService {
|
|||||||
case 1:
|
case 1:
|
||||||
return msg.rawContent || null
|
return msg.rawContent || null
|
||||||
case 3:
|
case 3:
|
||||||
return msg.imageMd5 || '[图片]'
|
return '[图片]'
|
||||||
case 34:
|
case 34:
|
||||||
return '[语音]'
|
return '[语音]'
|
||||||
case 43:
|
case 43:
|
||||||
return msg.videoMd5 || '[视频]'
|
return '[视频]'
|
||||||
case 47:
|
case 47:
|
||||||
return msg.emojiCdnUrl || msg.emojiMd5 || '[表情]'
|
return '[表情]'
|
||||||
case 42:
|
case 42:
|
||||||
return msg.cardNickname || '[名片]'
|
return msg.cardNickname || '[名片]'
|
||||||
case 48:
|
case 48:
|
||||||
@@ -743,3 +903,4 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const httpService = new HttpService()
|
export const httpService = new HttpService()
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "2.0.1",
|
"version": "2.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "2.0.1",
|
"version": "2.1.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "2.0.1",
|
"version": "2.1.0",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": "cc",
|
"author": "cc",
|
||||||
|
|||||||
@@ -45,6 +45,12 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ function AnalyticsPage() {
|
|||||||
}, [loadExcludedUsernames])
|
}, [loadExcludedUsernames])
|
||||||
|
|
||||||
const handleRefresh = () => loadData(true)
|
const handleRefresh = () => loadData(true)
|
||||||
|
const isNoSessionError = error?.includes('未找到消息会话') ?? false
|
||||||
|
|
||||||
const loadExcludeCandidates = useCallback(async () => {
|
const loadExcludeCandidates = useCallback(async () => {
|
||||||
setExcludeLoading(true)
|
setExcludeLoading(true)
|
||||||
@@ -175,6 +176,23 @@ function AnalyticsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleResetExcluded = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.analytics.setExcludedUsernames([])
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error || '重置排除好友失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExcludedUsernames(new Set())
|
||||||
|
setDraftExcluded(new Set())
|
||||||
|
clearCache()
|
||||||
|
await window.electronAPI.cache.clearAnalytics()
|
||||||
|
await loadData(true)
|
||||||
|
} catch (e) {
|
||||||
|
setError(`重置排除好友失败: ${String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const visibleExcludeCandidates = excludeCandidates
|
const visibleExcludeCandidates = excludeCandidates
|
||||||
.filter((candidate) => {
|
.filter((candidate) => {
|
||||||
const query = excludeQuery.trim().toLowerCase()
|
const query = excludeQuery.trim().toLowerCase()
|
||||||
@@ -355,6 +373,22 @@ function AnalyticsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) {
|
||||||
|
return (
|
||||||
|
<div className="error-container">
|
||||||
|
<p>{error}</p>
|
||||||
|
<div className="error-actions">
|
||||||
|
<button className="btn btn-secondary" onClick={handleResetExcluded}>
|
||||||
|
重置排除好友
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={() => loadData(true)}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (error && !isLoaded) {
|
if (error && !isLoaded) {
|
||||||
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}>重试</button></div>)
|
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}>重试</button></div>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,24 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selection-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 20px 12px;
|
||||||
|
|
||||||
|
.checkbox-item {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.loading-state,
|
.loading-state,
|
||||||
.empty-state {
|
.empty-state {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -214,11 +232,30 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.contact-avatar {
|
.contact-avatar {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface ContactInfo {
|
|||||||
function ContactsPage() {
|
function ContactsPage() {
|
||||||
const [contacts, setContacts] = useState<ContactInfo[]>([])
|
const [contacts, setContacts] = useState<ContactInfo[]>([])
|
||||||
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
|
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
|
||||||
|
const [selectedUsernames, setSelectedUsernames] = useState<Set<string>>(new Set())
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
const [contactTypes, setContactTypes] = useState({
|
const [contactTypes, setContactTypes] = useState({
|
||||||
@@ -62,6 +63,7 @@ function ContactsPage() {
|
|||||||
|
|
||||||
setContacts(contactsResult.contacts)
|
setContacts(contactsResult.contacts)
|
||||||
setFilteredContacts(contactsResult.contacts)
|
setFilteredContacts(contactsResult.contacts)
|
||||||
|
setSelectedUsernames(new Set())
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载通讯录失败:', e)
|
console.error('加载通讯录失败:', e)
|
||||||
@@ -111,6 +113,37 @@ function ContactsPage() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [showFormatSelect])
|
}, [showFormatSelect])
|
||||||
|
|
||||||
|
const selectedInFilteredCount = filteredContacts.reduce((count, contact) => {
|
||||||
|
return selectedUsernames.has(contact.username) ? count + 1 : count
|
||||||
|
}, 0)
|
||||||
|
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length
|
||||||
|
|
||||||
|
const toggleContactSelected = (username: string, checked: boolean) => {
|
||||||
|
setSelectedUsernames(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (checked) {
|
||||||
|
next.add(username)
|
||||||
|
} else {
|
||||||
|
next.delete(username)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAllFilteredSelected = (checked: boolean) => {
|
||||||
|
setSelectedUsernames(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
filteredContacts.forEach(contact => {
|
||||||
|
if (checked) {
|
||||||
|
next.add(contact.username)
|
||||||
|
} else {
|
||||||
|
next.delete(contact.username)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const getAvatarLetter = (name: string) => {
|
const getAvatarLetter = (name: string) => {
|
||||||
if (!name) return '?'
|
if (!name) return '?'
|
||||||
return [...name][0] || '?'
|
return [...name][0] || '?'
|
||||||
@@ -154,6 +187,10 @@ function ContactsPage() {
|
|||||||
alert('请先选择导出位置')
|
alert('请先选择导出位置')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (selectedUsernames.size === 0) {
|
||||||
|
alert('请至少选择一个联系人')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsExporting(true)
|
setIsExporting(true)
|
||||||
try {
|
try {
|
||||||
@@ -164,7 +201,8 @@ function ContactsPage() {
|
|||||||
friends: contactTypes.friends,
|
friends: contactTypes.friends,
|
||||||
groups: contactTypes.groups,
|
groups: contactTypes.groups,
|
||||||
officials: contactTypes.officials
|
officials: contactTypes.officials
|
||||||
}
|
},
|
||||||
|
selectedUsernames: Array.from(selectedUsernames)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await window.electronAPI.export.exportContacts(exportFolder, exportOptions)
|
const result = await window.electronAPI.export.exportContacts(exportFolder, exportOptions)
|
||||||
@@ -251,6 +289,18 @@ function ContactsPage() {
|
|||||||
<div className="contacts-count">
|
<div className="contacts-count">
|
||||||
共 {filteredContacts.length} 个联系人
|
共 {filteredContacts.length} 个联系人
|
||||||
</div>
|
</div>
|
||||||
|
<div className="selection-toolbar">
|
||||||
|
<label className="checkbox-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allFilteredSelected}
|
||||||
|
onChange={e => toggleAllFilteredSelected(e.target.checked)}
|
||||||
|
disabled={filteredContacts.length === 0}
|
||||||
|
/>
|
||||||
|
<span>全选当前筛选结果</span>
|
||||||
|
</label>
|
||||||
|
<span className="selection-count">已选 {selectedUsernames.size}(当前筛选 {selectedInFilteredCount} / {filteredContacts.length})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="loading-state">
|
<div className="loading-state">
|
||||||
@@ -263,8 +313,21 @@ function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="contacts-list">
|
<div className="contacts-list">
|
||||||
{filteredContacts.map(contact => (
|
{filteredContacts.map(contact => {
|
||||||
<div key={contact.username} className="contact-item">
|
const isSelected = selectedUsernames.has(contact.username)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={contact.username}
|
||||||
|
className={`contact-item ${isSelected ? 'selected' : ''}`}
|
||||||
|
onClick={() => toggleContactSelected(contact.username, !isSelected)}
|
||||||
|
>
|
||||||
|
<label className="contact-select" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<div className="contact-avatar">
|
<div className="contact-avatar">
|
||||||
{contact.avatarUrl ? (
|
{contact.avatarUrl ? (
|
||||||
<img src={contact.avatarUrl} alt="" />
|
<img src={contact.avatarUrl} alt="" />
|
||||||
@@ -283,7 +346,8 @@ function ContactsPage() {
|
|||||||
<span>{getContactTypeName(contact.type)}</span>
|
<span>{getContactTypeName(contact.type)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -356,7 +420,7 @@ function ContactsPage() {
|
|||||||
<button
|
<button
|
||||||
className="export-btn"
|
className="export-btn"
|
||||||
onClick={startExport}
|
onClick={startExport}
|
||||||
disabled={!exportFolder || isExporting}
|
disabled={!exportFolder || isExporting || selectedUsernames.size === 0}
|
||||||
>
|
>
|
||||||
{isExporting ? (
|
{isExporting ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -777,6 +777,344 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-export-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
.member-export-empty {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 30;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-select-trigger-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-select-dropdown {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-select-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-select-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-select-empty {
|
||||||
|
padding: 10px 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-select-option {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
|
||||||
|
.member-option-main {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-option-meta {
|
||||||
|
grid-column: 2 / 3;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
.member-option-main,
|
||||||
|
.member-option-meta {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-folder {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-folder-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-options {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-chip-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-group-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-chip-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-filter-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled,
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-export-start-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.rankings-list {
|
.rankings-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react'
|
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown } from 'lucide-react'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
import DateRangePicker from '../components/DateRangePicker'
|
import DateRangePicker from '../components/DateRangePicker'
|
||||||
|
import * as configService from '../services/config'
|
||||||
import './GroupAnalyticsPage.scss'
|
import './GroupAnalyticsPage.scss'
|
||||||
|
|
||||||
interface GroupChatInfo {
|
interface GroupChatInfo {
|
||||||
@@ -28,7 +29,26 @@ interface GroupMessageRank {
|
|||||||
messageCount: number
|
messageCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalysisFunction = 'members' | 'ranking' | 'activeHours' | 'mediaStats'
|
type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats'
|
||||||
|
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone'
|
||||||
|
|
||||||
|
interface MemberMessageExportOptions {
|
||||||
|
format: MemberExportFormat
|
||||||
|
exportAvatars: boolean
|
||||||
|
exportMedia: boolean
|
||||||
|
exportImages: boolean
|
||||||
|
exportVoices: boolean
|
||||||
|
exportVideos: boolean
|
||||||
|
exportEmojis: boolean
|
||||||
|
exportVoiceAsText: boolean
|
||||||
|
displayNamePreference: 'group-nickname' | 'remark' | 'nickname'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberExportFormatOption {
|
||||||
|
value: MemberExportFormat
|
||||||
|
label: string
|
||||||
|
desc: string
|
||||||
|
}
|
||||||
|
|
||||||
function GroupAnalyticsPage() {
|
function GroupAnalyticsPage() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -46,10 +66,31 @@ function GroupAnalyticsPage() {
|
|||||||
const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null)
|
const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null)
|
||||||
const [functionLoading, setFunctionLoading] = useState(false)
|
const [functionLoading, setFunctionLoading] = useState(false)
|
||||||
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
||||||
|
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
|
||||||
|
const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('')
|
||||||
|
const [exportFolder, setExportFolder] = useState('')
|
||||||
|
const [memberExportOptions, setMemberExportOptions] = useState<MemberMessageExportOptions>({
|
||||||
|
format: 'excel',
|
||||||
|
exportAvatars: true,
|
||||||
|
exportMedia: false,
|
||||||
|
exportImages: true,
|
||||||
|
exportVoices: true,
|
||||||
|
exportVideos: true,
|
||||||
|
exportEmojis: true,
|
||||||
|
exportVoiceAsText: false,
|
||||||
|
displayNamePreference: 'remark'
|
||||||
|
})
|
||||||
|
|
||||||
// 成员详情弹框
|
// 成员详情弹框
|
||||||
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
|
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
|
||||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||||
|
const [showMemberSelect, setShowMemberSelect] = useState(false)
|
||||||
|
const [showFormatSelect, setShowFormatSelect] = useState(false)
|
||||||
|
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
|
||||||
|
const [memberSearchKeyword, setMemberSearchKeyword] = useState('')
|
||||||
|
const memberSelectDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const formatDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// 时间范围
|
// 时间范围
|
||||||
const [startDate, setStartDate] = useState<string>('')
|
const [startDate, setStartDate] = useState<string>('')
|
||||||
@@ -74,9 +115,84 @@ function GroupAnalyticsPage() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}, [location.state])
|
}, [location.state])
|
||||||
|
|
||||||
|
const memberExportFormatOptions = useMemo<MemberExportFormatOption[]>(() => ([
|
||||||
|
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
||||||
|
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
||||||
|
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
||||||
|
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
||||||
|
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
||||||
|
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
||||||
|
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }
|
||||||
|
]), [])
|
||||||
|
const displayNameOptions = useMemo<Array<{
|
||||||
|
value: MemberMessageExportOptions['displayNamePreference']
|
||||||
|
label: string
|
||||||
|
desc: string
|
||||||
|
}>>(() => ([
|
||||||
|
{ value: 'group-nickname', label: '群昵称优先', desc: '仅群聊有效,私聊显示备注/昵称' },
|
||||||
|
{ value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' },
|
||||||
|
{ value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' }
|
||||||
|
]), [])
|
||||||
|
const selectedExportMember = useMemo(
|
||||||
|
() => members.find(member => member.username === selectedExportMemberUsername) || null,
|
||||||
|
[members, selectedExportMemberUsername]
|
||||||
|
)
|
||||||
|
const selectedFormatOption = useMemo(
|
||||||
|
() => memberExportFormatOptions.find(option => option.value === memberExportOptions.format) || memberExportFormatOptions[0],
|
||||||
|
[memberExportFormatOptions, memberExportOptions.format]
|
||||||
|
)
|
||||||
|
const selectedDisplayNameOption = useMemo(
|
||||||
|
() => displayNameOptions.find(option => option.value === memberExportOptions.displayNamePreference) || displayNameOptions[0],
|
||||||
|
[displayNameOptions, memberExportOptions.displayNamePreference]
|
||||||
|
)
|
||||||
|
const filteredMemberOptions = useMemo(() => {
|
||||||
|
const keyword = memberSearchKeyword.trim().toLowerCase()
|
||||||
|
if (!keyword) return members
|
||||||
|
return members.filter(member => {
|
||||||
|
const fields = [
|
||||||
|
member.username,
|
||||||
|
member.displayName,
|
||||||
|
member.nickname,
|
||||||
|
member.remark,
|
||||||
|
member.alias
|
||||||
|
]
|
||||||
|
return fields.some(field => String(field || '').toLowerCase().includes(keyword))
|
||||||
|
})
|
||||||
|
}, [memberSearchKeyword, members])
|
||||||
|
|
||||||
|
const loadExportPath = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const savedPath = await configService.getExportPath()
|
||||||
|
if (savedPath) {
|
||||||
|
setExportFolder(savedPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
|
||||||
|
setExportFolder(downloadsPath)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载导出路径失败:', e)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadGroups = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.groupAnalytics.getGroupChats()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setGroups(result.data)
|
||||||
|
setFilteredGroups(result.data)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadGroups()
|
loadGroups()
|
||||||
}, [])
|
loadExportPath()
|
||||||
|
}, [loadGroups, loadExportPath])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
preselectAppliedRef.current = false
|
preselectAppliedRef.current = false
|
||||||
@@ -90,6 +206,34 @@ function GroupAnalyticsPage() {
|
|||||||
}
|
}
|
||||||
}, [searchQuery, groups])
|
}, [searchQuery, groups])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (members.length === 0) {
|
||||||
|
setSelectedExportMemberUsername('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const exists = members.some(member => member.username === selectedExportMemberUsername)
|
||||||
|
if (!exists) {
|
||||||
|
setSelectedExportMemberUsername(members[0].username)
|
||||||
|
}
|
||||||
|
}, [members, selectedExportMemberUsername])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) {
|
||||||
|
setShowMemberSelect(false)
|
||||||
|
}
|
||||||
|
if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) {
|
||||||
|
setShowFormatSelect(false)
|
||||||
|
}
|
||||||
|
if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) {
|
||||||
|
setShowDisplayNameSelect(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [showDisplayNameSelect, showFormatSelect, showMemberSelect])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preselectAppliedRef.current) return
|
if (preselectAppliedRef.current) return
|
||||||
if (groups.length === 0 || preselectGroupIds.length === 0) return
|
if (groups.length === 0 || preselectGroupIds.length === 0) return
|
||||||
@@ -125,27 +269,12 @@ function GroupAnalyticsPage() {
|
|||||||
|
|
||||||
// 日期范围变化时自动刷新
|
// 日期范围变化时自动刷新
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') {
|
if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members' && selectedFunction !== 'memberExport') {
|
||||||
setDateRangeReady(false)
|
setDateRangeReady(false)
|
||||||
loadFunctionData(selectedFunction)
|
loadFunctionData(selectedFunction)
|
||||||
}
|
}
|
||||||
}, [dateRangeReady])
|
}, [dateRangeReady])
|
||||||
|
|
||||||
const loadGroups = useCallback(async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupChats()
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setGroups(result.data)
|
|
||||||
setFilteredGroups(result.data)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
setGroups([])
|
setGroups([])
|
||||||
@@ -157,15 +286,21 @@ function GroupAnalyticsPage() {
|
|||||||
setActiveHours({})
|
setActiveHours({})
|
||||||
setMediaStats(null)
|
setMediaStats(null)
|
||||||
void loadGroups()
|
void loadGroups()
|
||||||
|
void loadExportPath()
|
||||||
}
|
}
|
||||||
window.addEventListener('wxid-changed', handleChange as EventListener)
|
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||||
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||||
}, [loadGroups])
|
}, [loadExportPath, loadGroups])
|
||||||
|
|
||||||
const handleGroupSelect = (group: GroupChatInfo) => {
|
const handleGroupSelect = (group: GroupChatInfo) => {
|
||||||
if (selectedGroup?.username !== group.username) {
|
if (selectedGroup?.username !== group.username) {
|
||||||
setSelectedGroup(group)
|
setSelectedGroup(group)
|
||||||
setSelectedFunction(null)
|
setSelectedFunction(null)
|
||||||
|
setSelectedExportMemberUsername('')
|
||||||
|
setMemberSearchKeyword('')
|
||||||
|
setShowMemberSelect(false)
|
||||||
|
setShowFormatSelect(false)
|
||||||
|
setShowDisplayNameSelect(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +326,11 @@ function GroupAnalyticsPage() {
|
|||||||
if (result.success && result.data) setMembers(result.data)
|
if (result.success && result.data) setMembers(result.data)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'memberExport': {
|
||||||
|
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
|
||||||
|
if (result.success && result.data) setMembers(result.data)
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'ranking': {
|
case 'ranking': {
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
|
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
|
||||||
if (result.success && result.data) setRankings(result.data)
|
if (result.success && result.data) setRankings(result.data)
|
||||||
@@ -286,6 +426,7 @@ function GroupAnalyticsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDateRangeComplete = () => {
|
const handleDateRangeComplete = () => {
|
||||||
|
if (selectedFunction === 'memberExport') return
|
||||||
setDateRangeReady(true)
|
setDateRangeReady(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +464,86 @@ function GroupAnalyticsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMemberExportFormatChange = (format: MemberExportFormat) => {
|
||||||
|
setMemberExportOptions(prev => {
|
||||||
|
const next = { ...prev, format }
|
||||||
|
if (format === 'html') {
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
exportMedia: true,
|
||||||
|
exportImages: true,
|
||||||
|
exportVoices: true,
|
||||||
|
exportVideos: true,
|
||||||
|
exportEmojis: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChooseExportFolder = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.dialog.openDirectory({
|
||||||
|
title: '选择导出目录'
|
||||||
|
})
|
||||||
|
if (!result.canceled && result.filePaths.length > 0) {
|
||||||
|
setExportFolder(result.filePaths[0])
|
||||||
|
await configService.setExportPath(result.filePaths[0])
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('选择导出目录失败:', e)
|
||||||
|
alert(`选择导出目录失败:${String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportMemberMessages = async () => {
|
||||||
|
if (!selectedGroup || !selectedExportMemberUsername || !exportFolder || isExportingMemberMessages) return
|
||||||
|
const member = members.find(item => item.username === selectedExportMemberUsername)
|
||||||
|
if (!member) {
|
||||||
|
alert('请先选择成员')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsExportingMemberMessages(true)
|
||||||
|
try {
|
||||||
|
const hasDateRange = Boolean(startDate && endDate)
|
||||||
|
const result = await window.electronAPI.export.exportSessions(
|
||||||
|
[selectedGroup.username],
|
||||||
|
exportFolder,
|
||||||
|
{
|
||||||
|
format: memberExportOptions.format,
|
||||||
|
dateRange: hasDateRange
|
||||||
|
? {
|
||||||
|
start: Math.floor(new Date(startDate).getTime() / 1000),
|
||||||
|
end: Math.floor(new Date(`${endDate}T23:59:59`).getTime() / 1000)
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
exportAvatars: memberExportOptions.exportAvatars,
|
||||||
|
exportMedia: memberExportOptions.exportMedia,
|
||||||
|
exportImages: memberExportOptions.exportMedia && memberExportOptions.exportImages,
|
||||||
|
exportVoices: memberExportOptions.exportMedia && memberExportOptions.exportVoices,
|
||||||
|
exportVideos: memberExportOptions.exportMedia && memberExportOptions.exportVideos,
|
||||||
|
exportEmojis: memberExportOptions.exportMedia && memberExportOptions.exportEmojis,
|
||||||
|
exportVoiceAsText: memberExportOptions.exportVoiceAsText,
|
||||||
|
sessionLayout: memberExportOptions.exportMedia ? 'per-session' : 'shared',
|
||||||
|
displayNamePreference: memberExportOptions.displayNamePreference,
|
||||||
|
senderUsername: member.username,
|
||||||
|
fileNameSuffix: sanitizeFileName(member.displayName || member.username)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (result.success && (result.successCount ?? 0) > 0) {
|
||||||
|
alert(`导出成功:${member.displayName || member.username}`)
|
||||||
|
} else {
|
||||||
|
alert(`导出失败:${result.error || '未知错误'}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('导出成员消息失败:', e)
|
||||||
|
alert(`导出失败:${String(e)}`)
|
||||||
|
} finally {
|
||||||
|
setIsExportingMemberMessages(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCopy = async (text: string, field: string) => {
|
const handleCopy = async (text: string, field: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
@@ -479,6 +700,10 @@ function GroupAnalyticsPage() {
|
|||||||
<Users size={32} />
|
<Users size={32} />
|
||||||
<span>群成员查看</span>
|
<span>群成员查看</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="function-card" onClick={() => handleFunctionSelect('memberExport')}>
|
||||||
|
<Download size={32} />
|
||||||
|
<span>成员消息导出</span>
|
||||||
|
</div>
|
||||||
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
|
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
|
||||||
<BarChart3 size={32} />
|
<BarChart3 size={32} />
|
||||||
<span>群聊发言排行</span>
|
<span>群聊发言排行</span>
|
||||||
@@ -499,6 +724,7 @@ function GroupAnalyticsPage() {
|
|||||||
const getFunctionTitle = () => {
|
const getFunctionTitle = () => {
|
||||||
switch (selectedFunction) {
|
switch (selectedFunction) {
|
||||||
case 'members': return '群成员查看'
|
case 'members': return '群成员查看'
|
||||||
|
case 'memberExport': return '成员消息导出'
|
||||||
case 'ranking': return '群聊发言排行'
|
case 'ranking': return '群聊发言排行'
|
||||||
case 'activeHours': return '群聊活跃时段'
|
case 'activeHours': return '群聊活跃时段'
|
||||||
case 'mediaStats': return '媒体内容统计'
|
case 'mediaStats': return '媒体内容统计'
|
||||||
@@ -554,6 +780,234 @@ function GroupAnalyticsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{selectedFunction === 'memberExport' && (
|
||||||
|
<div className="member-export-panel">
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<div className="member-export-empty">暂无群成员数据,请先刷新。</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="member-export-grid">
|
||||||
|
<div className="member-export-field" ref={memberSelectDropdownRef}>
|
||||||
|
<span>导出成员</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showMemberSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowMemberSelect(prev => !prev)
|
||||||
|
setShowFormatSelect(false)
|
||||||
|
setShowDisplayNameSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="member-select-trigger-value">
|
||||||
|
<Avatar
|
||||||
|
src={selectedExportMember?.avatarUrl}
|
||||||
|
name={selectedExportMember?.displayName || selectedExportMember?.username || '?'}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
<span className="select-value">{selectedExportMember?.displayName || selectedExportMember?.username || '请选择成员'}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showMemberSelect && (
|
||||||
|
<div className="select-dropdown member-select-dropdown">
|
||||||
|
<div className="member-select-search">
|
||||||
|
<Search size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={memberSearchKeyword}
|
||||||
|
onChange={e => setMemberSearchKeyword(e.target.value)}
|
||||||
|
placeholder="搜索 wxid / 昵称 / 备注 / 微信号"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="member-select-options">
|
||||||
|
{filteredMemberOptions.length === 0 ? (
|
||||||
|
<div className="member-select-empty">无匹配成员</div>
|
||||||
|
) : (
|
||||||
|
filteredMemberOptions.map(member => (
|
||||||
|
<button
|
||||||
|
key={member.username}
|
||||||
|
type="button"
|
||||||
|
className={`select-option member-select-option ${selectedExportMemberUsername === member.username ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedExportMemberUsername(member.username)
|
||||||
|
setShowMemberSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar src={member.avatarUrl} name={member.displayName} size={28} />
|
||||||
|
<span className="member-option-main">{member.displayName || member.username}</span>
|
||||||
|
<span className="member-option-meta">
|
||||||
|
wxid: {member.username}
|
||||||
|
{member.alias ? ` · 微信号: ${member.alias}` : ''}
|
||||||
|
{member.remark ? ` · 备注: ${member.remark}` : ''}
|
||||||
|
{member.nickname ? ` · 昵称: ${member.nickname}` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="member-export-field" ref={formatDropdownRef}>
|
||||||
|
<span>导出格式</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowFormatSelect(prev => !prev)
|
||||||
|
setShowMemberSelect(false)
|
||||||
|
setShowDisplayNameSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="select-value">{selectedFormatOption.label}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showFormatSelect && (
|
||||||
|
<div className="select-dropdown">
|
||||||
|
{memberExportFormatOptions.map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`select-option ${memberExportOptions.format === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
handleMemberExportFormatChange(option.value)
|
||||||
|
setShowFormatSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{option.label}</span>
|
||||||
|
<span className="option-desc">{option.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="member-export-field member-export-folder">
|
||||||
|
<span>导出目录</span>
|
||||||
|
<div className="member-export-folder-row">
|
||||||
|
<input value={exportFolder} readOnly placeholder="请选择导出目录" />
|
||||||
|
<button type="button" onClick={handleChooseExportFolder}>
|
||||||
|
选择目录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="member-export-options">
|
||||||
|
<div className="member-export-chip-group">
|
||||||
|
<span className="chip-group-label">媒体导出</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`export-filter-chip ${memberExportOptions.exportMedia ? 'active' : ''}`}
|
||||||
|
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportMedia: !prev.exportMedia }))}
|
||||||
|
>
|
||||||
|
导出媒体文件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="member-export-chip-group">
|
||||||
|
<span className="chip-group-label">媒体类型</span>
|
||||||
|
<div className="member-export-chip-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`export-filter-chip ${memberExportOptions.exportImages ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||||
|
disabled={!memberExportOptions.exportMedia}
|
||||||
|
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportImages: !prev.exportImages }))}
|
||||||
|
>
|
||||||
|
图片
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`export-filter-chip ${memberExportOptions.exportVoices ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||||
|
disabled={!memberExportOptions.exportMedia}
|
||||||
|
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVoices: !prev.exportVoices }))}
|
||||||
|
>
|
||||||
|
语音
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`export-filter-chip ${memberExportOptions.exportVideos ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||||
|
disabled={!memberExportOptions.exportMedia}
|
||||||
|
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVideos: !prev.exportVideos }))}
|
||||||
|
>
|
||||||
|
视频
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`export-filter-chip ${memberExportOptions.exportEmojis ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
|
||||||
|
disabled={!memberExportOptions.exportMedia}
|
||||||
|
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportEmojis: !prev.exportEmojis }))}
|
||||||
|
>
|
||||||
|
表情
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="member-export-chip-group">
|
||||||
|
<span className="chip-group-label">附加选项</span>
|
||||||
|
<div className="member-export-chip-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`export-filter-chip ${memberExportOptions.exportVoiceAsText ? 'active' : ''}`}
|
||||||
|
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVoiceAsText: !prev.exportVoiceAsText }))}
|
||||||
|
>
|
||||||
|
语音转文字
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`export-filter-chip ${memberExportOptions.exportAvatars ? 'active' : ''}`}
|
||||||
|
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportAvatars: !prev.exportAvatars }))}
|
||||||
|
>
|
||||||
|
导出头像
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="member-export-field" ref={displayNameDropdownRef}>
|
||||||
|
<span>显示名称规则</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showDisplayNameSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowDisplayNameSelect(prev => !prev)
|
||||||
|
setShowMemberSelect(false)
|
||||||
|
setShowFormatSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="select-value">{selectedDisplayNameOption.label}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showDisplayNameSelect && (
|
||||||
|
<div className="select-dropdown">
|
||||||
|
{displayNameOptions.map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`select-option ${memberExportOptions.displayNamePreference === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setMemberExportOptions(prev => ({ ...prev, displayNamePreference: option.value }))
|
||||||
|
setShowDisplayNameSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{option.label}</span>
|
||||||
|
<span className="option-desc">{option.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="member-export-actions">
|
||||||
|
<button
|
||||||
|
className="member-export-start-btn"
|
||||||
|
onClick={handleExportMemberMessages}
|
||||||
|
disabled={isExportingMemberMessages || !selectedExportMemberUsername || !exportFolder}
|
||||||
|
>
|
||||||
|
{isExportingMemberMessages ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
|
||||||
|
<span>{isExportingMemberMessages ? '导出中...' : '开始导出'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{selectedFunction === 'ranking' && (
|
{selectedFunction === 'ranking' && (
|
||||||
<div className="rankings-list">
|
<div className="rankings-list">
|
||||||
{rankings.map((item, index) => (
|
{rankings.map((item, index) => (
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ function SettingsPage() {
|
|||||||
const [httpApiEnabled, setHttpApiEnabled] = useState(false)
|
const [httpApiEnabled, setHttpApiEnabled] = useState(false)
|
||||||
const [httpApiPort, setHttpApiPort] = useState(5031)
|
const [httpApiPort, setHttpApiPort] = useState(5031)
|
||||||
const [httpApiRunning, setHttpApiRunning] = useState(false)
|
const [httpApiRunning, setHttpApiRunning] = useState(false)
|
||||||
|
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
|
||||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||||
const [showApiWarning, setShowApiWarning] = useState(false)
|
const [showApiWarning, setShowApiWarning] = useState(false)
|
||||||
|
|
||||||
@@ -169,6 +170,9 @@ function SettingsPage() {
|
|||||||
if (status.port) {
|
if (status.port) {
|
||||||
setHttpApiPort(status.port)
|
setHttpApiPort(status.port)
|
||||||
}
|
}
|
||||||
|
if (status.mediaExportPath) {
|
||||||
|
setHttpApiMediaExportPath(status.mediaExportPath)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('检查 API 状态失败:', e)
|
console.error('检查 API 状态失败:', e)
|
||||||
}
|
}
|
||||||
@@ -1978,6 +1982,17 @@ function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* API 安全警告弹窗 */}
|
{/* API 安全警告弹窗 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>默认媒体导出目录</label>
|
||||||
|
<span className="form-hint">`/api/v1/messages` 在开启 `media=1` 时会把媒体保存到这里</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input"
|
||||||
|
value={httpApiMediaExportPath || '未获取到目录'}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showApiWarning && (
|
{showApiWarning && (
|
||||||
<div className="modal-overlay" onClick={() => setShowApiWarning(false)}>
|
<div className="modal-overlay" onClick={() => setShowApiWarning(false)}>
|
||||||
<div className="api-warning-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="api-warning-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -704,6 +704,84 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-link-card {
|
||||||
|
width: min(460px, 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(var(--accent-color-rgb), 0.35);
|
||||||
|
background: rgba(var(--accent-color-rgb), 0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-thumb {
|
||||||
|
width: 88px;
|
||||||
|
min-width: 88px;
|
||||||
|
height: 66px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-thumb-fallback {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.link-title {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-url {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-arrow {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.post-media-grid {
|
.post-media-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
@@ -32,6 +32,171 @@ interface SnsPost {
|
|||||||
likes: string[]
|
likes: string[]
|
||||||
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
|
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
|
||||||
rawXml?: string // 原始 XML 数据
|
rawXml?: string // 原始 XML 数据
|
||||||
|
linkTitle?: string
|
||||||
|
linkUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnsLinkCardData {
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
thumb?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
|
||||||
|
const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle']
|
||||||
|
const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload']
|
||||||
|
|
||||||
|
const isSnsVideoUrl = (url?: string): boolean => {
|
||||||
|
if (!url) return false
|
||||||
|
const lower = url.toLowerCase()
|
||||||
|
return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb')
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeHtmlEntities = (text: string): string => {
|
||||||
|
if (!text) return ''
|
||||||
|
return text
|
||||||
|
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'/gi, "'")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeUrlCandidate = (raw: string): string | null => {
|
||||||
|
const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim()
|
||||||
|
if (!value) return null
|
||||||
|
if (!/^https?:\/\//i.test(value)) return null
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const simplifyUrlForCompare = (value: string): string => {
|
||||||
|
const normalized = value.trim().toLowerCase().replace(/^https?:\/\//, '')
|
||||||
|
const [withoutQuery] = normalized.split('?')
|
||||||
|
return withoutQuery.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getXmlTagValues = (xml: string, tags: string[]): string[] => {
|
||||||
|
if (!xml) return []
|
||||||
|
const results: string[] = []
|
||||||
|
for (const tag of tags) {
|
||||||
|
const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig')
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
while ((match = reg.exec(xml)) !== null) {
|
||||||
|
if (match[1]) results.push(match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUrlLikeStrings = (text: string): string[] => {
|
||||||
|
if (!text) return []
|
||||||
|
return text.match(/https?:\/\/[^\s<>"']+/gi) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLikelyMediaAssetUrl = (url: string): boolean => {
|
||||||
|
const lower = url.toLowerCase()
|
||||||
|
return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint))
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
|
||||||
|
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||||
|
if (hasVideoMedia) return null
|
||||||
|
|
||||||
|
const mediaValues = post.media
|
||||||
|
.flatMap((item) => [item.url, item.thumb])
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
const mediaSet = new Set(mediaValues.map((value) => simplifyUrlForCompare(value)))
|
||||||
|
|
||||||
|
const urlCandidates: string[] = [
|
||||||
|
post.linkUrl || '',
|
||||||
|
...getXmlTagValues(post.rawXml || '', LINK_XML_URL_TAGS),
|
||||||
|
...getUrlLikeStrings(post.rawXml || ''),
|
||||||
|
...getUrlLikeStrings(post.contentDesc || '')
|
||||||
|
]
|
||||||
|
|
||||||
|
const normalizedCandidates = urlCandidates
|
||||||
|
.map(normalizeUrlCandidate)
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
|
||||||
|
const dedupedCandidates: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const candidate of normalizedCandidates) {
|
||||||
|
if (seen.has(candidate)) continue
|
||||||
|
seen.add(candidate)
|
||||||
|
dedupedCandidates.push(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkUrl = dedupedCandidates.find((candidate) => {
|
||||||
|
const simplified = simplifyUrlForCompare(candidate)
|
||||||
|
if (mediaSet.has(simplified)) return false
|
||||||
|
if (isLikelyMediaAssetUrl(candidate)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!linkUrl) return null
|
||||||
|
|
||||||
|
const titleCandidates = [
|
||||||
|
post.linkTitle || '',
|
||||||
|
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
||||||
|
post.contentDesc || ''
|
||||||
|
]
|
||||||
|
|
||||||
|
const title = titleCandidates
|
||||||
|
.map((value) => decodeHtmlEntities(value))
|
||||||
|
.find((value) => Boolean(value) && !/^https?:\/\//i.test(value))
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: linkUrl,
|
||||||
|
title: title || '网页链接',
|
||||||
|
thumb: post.media[0]?.thumb || post.media[0]?.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
|
||||||
|
const [thumbFailed, setThumbFailed] = useState(false)
|
||||||
|
const hostname = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return new URL(card.url).hostname.replace(/^www\./i, '')
|
||||||
|
} catch {
|
||||||
|
return card.url
|
||||||
|
}
|
||||||
|
}, [card.url])
|
||||||
|
|
||||||
|
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
try {
|
||||||
|
await window.electronAPI.shell.openExternal(card.url)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SnsPage] openExternal failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className="post-link-card" onClick={handleClick}>
|
||||||
|
<div className="link-thumb">
|
||||||
|
{card.thumb && !thumbFailed ? (
|
||||||
|
<img
|
||||||
|
src={card.thumb}
|
||||||
|
alt=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setThumbFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="link-thumb-fallback">
|
||||||
|
<ImageIcon size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="link-meta">
|
||||||
|
<div className="link-title">{card.title}</div>
|
||||||
|
<div className="link-url">{hostname}</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} className="link-arrow" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MediaItem = ({ media, onPreview }: { media: any; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => {
|
const MediaItem = ({ media, onPreview }: { media: any; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => {
|
||||||
@@ -45,7 +210,7 @@ const MediaItem = ({ media, onPreview }: { media: any; onPreview: (src: string,
|
|||||||
const targetUrl = thumb || url // 默认显示缩略图
|
const targetUrl = thumb || url // 默认显示缩略图
|
||||||
|
|
||||||
// 判断是否为视频
|
// 判断是否为视频
|
||||||
const isVideo = url && (url.includes('snsvideodownload') || url.includes('.mp4') || url.includes('video')) && !url.includes('vweixinthumb')
|
const isVideo = isSnsVideoUrl(url)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -606,7 +771,11 @@ export default function SnsPage() {
|
|||||||
查看更新的动态
|
查看更新的动态
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{posts.map((post, index) => {
|
{posts.map((post) => {
|
||||||
|
const linkCard = buildLinkCardData(post)
|
||||||
|
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||||
|
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
|
||||||
|
const showMediaGrid = post.media.length > 0 && !showLinkCard
|
||||||
return (
|
return (
|
||||||
<div key={post.id} className="sns-post-row">
|
<div key={post.id} className="sns-post-row">
|
||||||
<div className="sns-post-wrapper">
|
<div className="sns-post-wrapper">
|
||||||
@@ -640,7 +809,11 @@ export default function SnsPage() {
|
|||||||
<div className="post-body">
|
<div className="post-body">
|
||||||
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
|
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
|
||||||
|
|
||||||
{post.media.length > 0 && (
|
{showLinkCard && linkCard && (
|
||||||
|
<SnsLinkCard card={linkCard} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMediaGrid && (
|
||||||
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
|
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
|
||||||
{post.media.map((m, idx) => (
|
{post.media.map((m, idx) => (
|
||||||
<MediaItem key={idx} media={m} onPreview={(src, isVideo, liveVideoPath) => setPreviewImage({ src, isVideo, liveVideoPath })} />
|
<MediaItem key={idx} media={m} onPreview={(src, isVideo, liveVideoPath) => setPreviewImage({ src, isVideo, liveVideoPath })} />
|
||||||
|
|||||||
23
src/types/electron.d.ts
vendored
23
src/types/electron.d.ts
vendored
@@ -275,6 +275,17 @@ export interface ElectronAPI {
|
|||||||
count?: number
|
count?: number
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
exportGroupMemberMessages: (
|
||||||
|
chatroomId: string,
|
||||||
|
memberUsername: string,
|
||||||
|
outputPath: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
count?: number
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
annualReport: {
|
annualReport: {
|
||||||
getAvailableYears: () => Promise<{
|
getAvailableYears: () => Promise<{
|
||||||
@@ -433,7 +444,7 @@ export interface ElectronAPI {
|
|||||||
success: boolean
|
success: boolean
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
exportContacts: (outputDir: string, options: { format: 'json' | 'csv' | 'vcf'; exportAvatars: boolean; contactTypes: { friends: boolean; groups: boolean; officials: boolean } }) => Promise<{
|
exportContacts: (outputDir: string, options: { format: 'json' | 'csv' | 'vcf'; exportAvatars: boolean; contactTypes: { friends: boolean; groups: boolean; officials: boolean }; selectedUsernames?: string[] }) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
successCount?: number
|
successCount?: number
|
||||||
error?: string
|
error?: string
|
||||||
@@ -492,15 +503,23 @@ export interface ElectronAPI {
|
|||||||
onToken: (callback: (token: string) => void) => () => void
|
onToken: (callback: (token: string) => void) => () => void
|
||||||
onDownloadProgress: (callback: (payload: { downloaded: number; total: number; speed: number }) => void) => () => void
|
onDownloadProgress: (callback: (payload: { downloaded: number; total: number; speed: number }) => void) => () => void
|
||||||
}
|
}
|
||||||
|
http: {
|
||||||
|
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
|
||||||
|
stop: () => Promise<{ success: boolean }>
|
||||||
|
status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql'
|
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null
|
||||||
|
senderUsername?: string
|
||||||
|
fileNameSuffix?: string
|
||||||
exportMedia?: boolean
|
exportMedia?: boolean
|
||||||
exportAvatars?: boolean
|
exportAvatars?: boolean
|
||||||
exportImages?: boolean
|
exportImages?: boolean
|
||||||
exportVoices?: boolean
|
exportVoices?: boolean
|
||||||
|
exportVideos?: boolean
|
||||||
exportEmojis?: boolean
|
exportEmojis?: boolean
|
||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
excelCompactColumns?: boolean
|
excelCompactColumns?: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user