From 5f868d193c5cabf16bd564df1da5f294269d48fb Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Wed, 18 Feb 2026 13:49:56 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=87=BA=E6=9D=A5?= =?UTF-8?q?=E7=9A=84=E8=A1=A8=E6=83=85=E5=8C=85=E4=B9=9F=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/chatService.ts | 103 ++++++++++++++++++++--------- electron/services/exportService.ts | 45 ++++--------- 2 files changed, 85 insertions(+), 63 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index afa4a4b..dc9c50b 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -50,6 +50,9 @@ export interface Message { emojiCdnUrl?: string emojiMd5?: string emojiLocalPath?: string // 本地缓存 castle 路径 + emojiThumbUrl?: string + emojiEncryptUrl?: string + emojiAesKey?: string // 引用消息相关 quotedContent?: string quotedSender?: string @@ -1151,6 +1154,9 @@ class ChatService { const emojiInfo = this.parseEmojiInfo(content) emojiCdnUrl = emojiInfo.cdnUrl emojiMd5 = emojiInfo.md5 + cdnThumbUrl = emojiInfo.thumbUrl // 复用 cdnThumbUrl 字段或使用 emojiThumbUrl + // 注意:Message 接口定义的 emojiThumbUrl,这里我们统一一下 + // 如果 Message 接口有 emojiThumbUrl,则使用它 } else if (localType === 3 && content) { const imageInfo = this.parseImageInfo(content) imageMd5 = imageInfo.md5 @@ -1373,7 +1379,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 { // 提取 cdnurl let cdnUrl: string | undefined @@ -1387,16 +1393,15 @@ class ChatService { } } - // 如果没有 cdnurl,尝试 thumburl - if (!cdnUrl) { - const thumbUrlMatch = /thumburl\s*=\s*['"]([^'"]+)['"]/i.exec(content) || /thumburl\s*=\s*([^'"]+?)(?=\s|\/|>)/i.exec(content) - if (thumbUrlMatch) { - cdnUrl = thumbUrlMatch[1].replace(/&/g, '&') - if (cdnUrl.includes('%')) { - try { - cdnUrl = decodeURIComponent(cdnUrl) - } catch { } - } + // 提取 thumburl + let thumbUrl: string | undefined + const thumbUrlMatch = /thumburl\s*=\s*['"]([^'"]+)['"]/i.exec(content) || /thumburl\s*=\s*([^'"]+?)(?=\s|\/|>)/i.exec(content) + if (thumbUrlMatch) { + thumbUrl = thumbUrlMatch[1].replace(/&/g, '&') + if (thumbUrl.includes('%')) { + try { + thumbUrl = decodeURIComponent(thumbUrl) + } catch { } } } @@ -1404,9 +1409,23 @@ class ChatService { 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 - // 不构造假 URL,只返回真正的 cdnurl - // 没有 cdnUrl 时保持静默,交由后续回退逻辑处理 - return { cdnUrl, md5 } + // 提取 encrypturl + let encryptUrl: string | undefined + 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) { console.error('[ChatService] 表情包解析失败:', e, { xml: content }) return {} @@ -2622,11 +2641,7 @@ class ChatService { // 检查内存缓存 const cached = emojiCache.get(cacheKey) if (cached && existsSync(cached)) { - // 读取文件并转为 data URL - const dataUrl = this.fileToDataUrl(cached) - if (dataUrl) { - return { success: true, localPath: dataUrl } - } + return { success: true, localPath: cached } } // 检查是否正在下载 @@ -2634,10 +2649,7 @@ class ChatService { if (downloading) { const result = await downloading if (result) { - const dataUrl = this.fileToDataUrl(result) - if (dataUrl) { - return { success: true, localPath: dataUrl } - } + return { success: true, localPath: result } } return { success: false, error: '下载失败' } } @@ -2654,10 +2666,7 @@ class ChatService { const filePath = join(cacheDir, `${cacheKey}${ext}`) if (existsSync(filePath)) { emojiCache.set(cacheKey, filePath) - const dataUrl = this.fileToDataUrl(filePath) - if (dataUrl) { - return { success: true, localPath: dataUrl } - } + return { success: true, localPath: filePath } } } @@ -2671,10 +2680,7 @@ class ChatService { if (localPath) { emojiCache.set(cacheKey, localPath) - const dataUrl = this.fileToDataUrl(localPath) - if (dataUrl) { - return { success: true, localPath: dataUrl } - } + return { success: true, localPath } } return { success: false, error: '下载失败' } } catch (e) { @@ -3917,6 +3923,13 @@ class ChatService { const imgInfo = this.parseImageInfo(rawContent) Object.assign(msg, imgInfo) 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 @@ -4227,6 +4240,34 @@ class ChatService { return { success: false, error: String(e) } } } + + + /** + * 下载表情包文件(用于导出,返回文件路径) + */ + async downloadEmojiFile(msg: Message): Promise { + 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() diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 5814efd..ab0eb67 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1479,49 +1479,30 @@ class ExportService { fs.mkdirSync(emojisDir, { recursive: true }) } - // 使用消息对象中已提取的字段 - const emojiUrl = msg.emojiCdnUrl - const emojiMd5 = msg.emojiMd5 - - if (!emojiUrl && !emojiMd5) { + // 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑) + const localPath = await chatService.downloadEmojiFile(msg) + if (!localPath || !fs.existsSync(localPath)) { return null } - - - const key = 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 ext = path.extname(localPath) || '.gif' + const key = msg.emojiMd5 || String(msg.localId) const fileName = `${key}${ext}` const destPath = path.join(emojisDir, fileName) - // 如果已存在则跳过 - if (fs.existsSync(destPath)) { - return { - relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), - kind: 'emoji' - } + // 复制文件到导出目录 (如果不存在) + if (!fs.existsSync(destPath)) { + fs.copyFileSync(localPath, destPath) } - // 下载表情 - if (emojiUrl) { - const downloaded = await this.downloadFile(emojiUrl, destPath) - if (downloaded) { - return { - relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), - kind: 'emoji' - } - } else { - } + return { + relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), + kind: 'emoji' } - - return null } catch (e) { + console.error('ExportService: exportEmoji failed', e) return null } } From d5f0094025bebd5bcba89e3d7eae4d6e7a31ed18 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Thu, 19 Feb 2026 16:47:50 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=BD=AC=E8=B4=A6?= =?UTF-8?q?=E7=B1=BB=E6=B6=88=E6=81=AF=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 55 +++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index ab0eb67..3db210d 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -534,11 +534,14 @@ class ExportService { groupNicknamesMap: Map, getContactName: (username: string) => Promise ): Promise { - const xmlType = this.extractXmlValue(content, 'type') - if (xmlType !== '2000') return null + const normalizedContent = this.normalizeAppMessageContent(content || '') + if (!normalizedContent) return null - const payerUsername = this.extractXmlValue(content, 'payer_username') - const receiverUsername = this.extractXmlValue(content, 'receiver_username') + const xmlType = this.extractXmlValue(normalizedContent, 'type') + 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 const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : '' @@ -3955,6 +3958,15 @@ class ExportService { const isGroup = sessionId.includes('@chatroom') const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) + const contactCache = new Map() + const getContactCached = async (username: string) => { + if (contactCache.has(username)) { + return contactCache.get(username)! + } + const result = await wcdbService.getContact(username) + contactCache.set(username, result) + return result + } onProgress?.({ current: 0, @@ -3974,6 +3986,24 @@ class ExportService { return { success: false, error: '该会话在指定时间范围内没有消息' } } + const senderUsernames = new Set() + 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() + if (isGroup) { await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) } @@ -4186,6 +4216,23 @@ class ExportService { if (mediaItem && (msg.localType === 3 || msg.localType === 47)) { textContent = '' } + if (textContent.startsWith('[转账]') && 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 = textContent.replace('[转账]', `[转账] (${transferDesc})`) + } + } let mediaHtml = '' if (mediaItem?.kind === 'image') { From 89783b4d45c5a7dd45b18d8f1458113213710e1b Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Thu, 19 Feb 2026 16:49:00 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=E7=BE=A4=E8=81=8A=E5=8D=95=E4=B8=AA?= =?UTF-8?q?=E6=88=90=E5=91=98=E6=B6=88=E6=81=AF=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 10 +- electron/preload.ts | 4 +- electron/services/groupAnalyticsService.ts | 262 +++++++++++++++++++++ src/pages/GroupAnalyticsPage.scss | 32 +++ src/pages/GroupAnalyticsPage.tsx | 48 ++++ src/types/electron.d.ts | 13 +- 6 files changed, 366 insertions(+), 3 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 6f35fb6..4457126 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1116,6 +1116,13 @@ function registerIpcHandlers() { 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 () => { createAgreementWindow() @@ -1350,7 +1357,8 @@ function registerIpcHandlers() { ipcMain.handle('http:status', async () => { return { running: httpService.isRunning(), - port: httpService.getPort() + port: httpService.getPort(), + mediaExportPath: httpService.getDefaultMediaExportPath() } }) diff --git a/electron/preload.ts b/electron/preload.ts index d44cf5b..4ec16e8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -216,7 +216,9 @@ contextBridge.exposeInMainWorld('electronAPI', { getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime), getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime), getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime), - 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) }, // 年度报告 diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 4886f0e..22abcb9 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -4,6 +4,7 @@ import ExcelJS from 'exceljs' import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { chatService } from './chatService' +import type { Message } from './chatService' export interface GroupChatInfo { username: string @@ -339,6 +340,92 @@ class GroupAnalyticsService { 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 = { + 1: '文本', + 3: '图片', + 34: '语音', + 42: '名片', + 43: '视频', + 47: '表情', + 48: '位置', + 49: '链接/文件', + 50: '通话', + 10000: '系统', + 266287972401: '拍一拍', + 8594229559345: '红包', + 8589934592049: '转账' + } + return typeMap[localType] || `类型(${localType})` + } + + private normalizeIdCandidates(values: Array): 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 }> { try { 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 }> { try { const conn = await this.ensureConnected() diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index 14cd529..8bb980c 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -1143,6 +1143,38 @@ text-align: center; } + .member-action-row { + width: 100%; + margin-bottom: 16px; + } + + .export-member-btn { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: none; + border-radius: 10px; + padding: 10px 14px; + background: var(--bg-tertiary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.15s; + font-size: 13px; + font-weight: 500; + + &:hover { + background: var(--bg-hover); + color: var(--primary); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + .member-details { width: 100%; display: flex; diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index cac6edd..4671b85 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -46,6 +46,7 @@ function GroupAnalyticsPage() { const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null) const [functionLoading, setFunctionLoading] = useState(false) const [isExportingMembers, setIsExportingMembers] = useState(false) + const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false) // 成员详情弹框 const [selectedMember, setSelectedMember] = useState(null) @@ -323,6 +324,43 @@ function GroupAnalyticsPage() { } } + const handleExportMemberMessages = async (member: GroupMember) => { + if (!selectedGroup || !member || isExportingMemberMessages) return + setIsExportingMemberMessages(true) + try { + const downloadsPath = await window.electronAPI.app.getDownloadsPath() + const memberName = member.displayName || member.username + const baseName = sanitizeFileName(`${selectedGroup.displayName || selectedGroup.username}_${memberName}_消息记录`) + const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/' + const defaultPath = downloadsPath ? `${downloadsPath}${separator}${baseName}.xlsx` : `${baseName}.xlsx` + const saveResult = await window.electronAPI.dialog.saveFile({ + title: `导出 ${memberName} 的群聊消息`, + defaultPath, + filters: [ + { name: 'Excel', extensions: ['xlsx'] }, + { name: 'CSV', extensions: ['csv'] } + ] + }) + if (!saveResult || saveResult.canceled || !saveResult.filePath) return + + const result = await window.electronAPI.groupAnalytics.exportGroupMemberMessages( + selectedGroup.username, + member.username, + saveResult.filePath + ) + if (result.success) { + alert(`导出成功,共 ${result.count ?? 0} 条消息`) + } else { + alert(`导出失败:${result.error || '未知错误'}`) + } + } catch (e) { + console.error('导出成员消息失败:', e) + alert(`导出失败:${String(e)}`) + } finally { + setIsExportingMemberMessages(false) + } + } + const handleCopy = async (text: string, field: string) => { try { await navigator.clipboard.writeText(text) @@ -351,6 +389,16 @@ function GroupAnalyticsPage() {

{selectedMember.displayName}

+
+ +
微信ID diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 5bc67cc..40caef7 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -273,6 +273,17 @@ export interface ElectronAPI { count?: number error?: string }> + exportGroupMemberMessages: ( + chatroomId: string, + memberUsername: string, + outputPath: string, + startTime?: number, + endTime?: number + ) => Promise<{ + success: boolean + count?: number + error?: string + }> } annualReport: { getAvailableYears: () => Promise<{ @@ -431,7 +442,7 @@ export interface ElectronAPI { success: boolean 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 successCount?: number error?: string From 25325e80ee8bcf3761ad40bca354f13fe4cfdf0b Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Thu, 19 Feb 2026 16:49:46 +0800 Subject: [PATCH 04/12] =?UTF-8?q?=E9=80=9A=E8=AE=AF=E5=BD=95=E5=8F=AF?= =?UTF-8?q?=E5=8B=BE=E9=80=89=E9=83=A8=E5=88=86=E5=A5=BD=E5=8F=8B=E5=AF=BC?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/contactExportService.ts | 6 ++ src/pages/ContactsPage.scss | 39 +++++++- src/pages/ContactsPage.tsx | 108 +++++++++++++++++----- 3 files changed, 130 insertions(+), 23 deletions(-) diff --git a/electron/services/contactExportService.ts b/electron/services/contactExportService.ts index 6a33432..11efee5 100644 --- a/electron/services/contactExportService.ts +++ b/electron/services/contactExportService.ts @@ -10,6 +10,7 @@ interface ContactExportOptions { groups: boolean officials: boolean } + selectedUsernames?: string[] } /** @@ -40,6 +41,11 @@ class ContactExportService { 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) { return { success: false, error: '没有符合条件的联系人' } } diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index d64dc46..2609639 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -174,6 +174,24 @@ 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, .empty-state { flex: 1; @@ -214,11 +232,30 @@ border-radius: 10px; transition: all 0.2s; margin-bottom: 4px; + cursor: pointer; &: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 { width: 44px; height: 44px; @@ -548,4 +585,4 @@ to { transform: rotate(360deg); } -} \ No newline at end of file +} diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 27868e7..43968ae 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -14,6 +14,7 @@ interface ContactInfo { function ContactsPage() { const [contacts, setContacts] = useState([]) const [filteredContacts, setFilteredContacts] = useState([]) + const [selectedUsernames, setSelectedUsernames] = useState>(new Set()) const [isLoading, setIsLoading] = useState(true) const [searchKeyword, setSearchKeyword] = useState('') const [contactTypes, setContactTypes] = useState({ @@ -62,6 +63,7 @@ function ContactsPage() { setContacts(contactsResult.contacts) setFilteredContacts(contactsResult.contacts) + setSelectedUsernames(new Set()) } } catch (e) { console.error('加载通讯录失败:', e) @@ -111,6 +113,37 @@ function ContactsPage() { return () => document.removeEventListener('mousedown', handleClickOutside) }, [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) => { if (!name) return '?' return [...name][0] || '?' @@ -154,6 +187,10 @@ function ContactsPage() { alert('请先选择导出位置') return } + if (selectedUsernames.size === 0) { + alert('请至少选择一个联系人') + return + } setIsExporting(true) try { @@ -164,7 +201,8 @@ function ContactsPage() { friends: contactTypes.friends, groups: contactTypes.groups, officials: contactTypes.officials - } + }, + selectedUsernames: Array.from(selectedUsernames) } const result = await window.electronAPI.export.exportContacts(exportFolder, exportOptions) @@ -251,6 +289,18 @@ function ContactsPage() {
共 {filteredContacts.length} 个联系人
+
+ + 已选 {selectedUsernames.size}(当前筛选 {selectedInFilteredCount} / {filteredContacts.length}) +
{isLoading ? (
@@ -263,27 +313,41 @@ function ContactsPage() {
) : (
- {filteredContacts.map(contact => ( -
-
- {contact.avatarUrl ? ( - - ) : ( - {getAvatarLetter(contact.displayName)} - )} + {filteredContacts.map(contact => { + const isSelected = selectedUsernames.has(contact.username) + return ( +
toggleContactSelected(contact.username, !isSelected)} + > + +
+ {contact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(contact.displayName)} + )} +
+
+
{contact.displayName}
+ {contact.remark && contact.remark !== contact.displayName && ( +
备注: {contact.remark}
+ )} +
+
+ {getContactTypeIcon(contact.type)} + {getContactTypeName(contact.type)} +
-
-
{contact.displayName}
- {contact.remark && contact.remark !== contact.displayName && ( -
备注: {contact.remark}
- )} -
-
- {getContactTypeIcon(contact.type)} - {getContactTypeName(contact.type)} -
-
- ))} + ) + })}
)}
@@ -356,7 +420,7 @@ function ContactsPage() { + ) } const MediaItem = ({ media, onPreview }: { media: any; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => { @@ -606,7 +762,10 @@ export default function SnsPage() { 查看更新的动态
)} - {posts.map((post, index) => { + {posts.map((post) => { + const linkCard = buildLinkCardData(post) + const showLinkCard = Boolean(linkCard) && post.media.length <= 1 + const showMediaGrid = post.media.length > 0 && !showLinkCard return (
@@ -640,7 +799,11 @@ export default function SnsPage() {
{post.contentDesc &&
{post.contentDesc}
} - {post.media.length > 0 && ( + {showLinkCard && linkCard && ( + + )} + + {showMediaGrid && (
{post.media.map((m, idx) => ( setPreviewImage({ src, isVideo, liveVideoPath })} /> From 4e64c6ad6ed929aff429aeaff03cee601e917a6b Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Thu, 19 Feb 2026 17:05:43 +0800 Subject: [PATCH 06/12] =?UTF-8?q?api=E7=9B=B8=E5=85=B3=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/HTTP-API.md | 42 ++- electron/services/chatService.ts | 7 + electron/services/httpService.ts | 541 ++++++++++++++++++++----------- src/pages/SettingsPage.tsx | 15 + src/types/electron.d.ts | 5 + 5 files changed, 412 insertions(+), 198 deletions(-) diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 0a099fe..21e5265 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -50,12 +50,20 @@ GET /api/v1/messages | 参数名 | 类型 | 必填 | 说明 | |--------|------|------|------| | `talker` | string | ✅ | 会话 ID(wxid 或群 ID) | -| `limit` | number | ❌ | 返回数量限制,默认 100 | +| `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` | | `offset` | number | ❌ | 偏移量,用于分页,默认 0 | | `start` | string | ❌ | 开始时间,格式 YYYYMMDD | | `end` | string | ❌ | 结束时间,格式 YYYYMMDD | +| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) | | `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 | | `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` | +| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti`);`0` 时媒体返回占位符 | +| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian`) | +| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce`) | +| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` | +| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` | + +默认媒体导出目录:`%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&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", "count": 50, "hasMore": true, + "media": { + "enabled": true, + "exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media", + "count": 12 + }, "messages": [ { "localId": 123, - "talker": "wxid_xxx", - "type": 1, - "content": "消息内容", + "localType": 3, + "content": "[图片]", "createTime": 1738713600000, - "isSelf": false, - "sender": "wxid_sender" + "senderUsername": "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": "用户名", "timestamp": 1738713600000, "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 + } } ``` diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index dc9c50b..286b7c0 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -1059,6 +1059,13 @@ class ChatService { return Number.isFinite(parsed) ? parsed : NaN } + /** + * HTTP API 复用消息解析逻辑,确保和应用内展示一致。 + */ + mapRowsToMessagesForApi(rows: Record[]): Message[] { + return this.mapRowsToMessages(rows) + } + private mapRowsToMessages(rows: Record[]): Message[] { const myWxid = this.configService.get('myWxid') const cleanedWxid = myWxid ? this.cleanAccountDirName(myWxid) : null diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 73b253f..c36a498 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -1,14 +1,17 @@ -/** - * HTTP API 服务 - * 提供 ChatLab 标准化格式的消息查询 API +/** + * HTTP API 鏈嶅姟 + * 鎻愪緵 ChatLab 鏍囧噯鍖栨牸寮忕殑娑堟伅鏌ヨ API */ import * as http from 'http' +import * as fs from 'fs' +import * as path from 'path' import { URL } from 'url' import { chatService, Message } from './chatService' import { wcdbService } from './wcdbService' import { ConfigService } from './config' +import { videoService } from './videoService' -// ChatLab 格式定义 +// ChatLab 鏍煎紡瀹氫箟 interface ChatLabHeader { version: string exportedAt: number @@ -42,6 +45,7 @@ interface ChatLabMessage { content: string | null platformMessageId?: string replyToMessageId?: string + mediaPath?: string } interface ChatLabData { @@ -51,7 +55,23 @@ interface ChatLabData { messages: ChatLabMessage[] } -// ChatLab 消息类型映射 +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 娑堟伅绫诲瀷鏄犲皠 const ChatLabType = { TEXT: 0, IMAGE: 1, @@ -86,7 +106,7 @@ class HttpService { } /** - * 启动 HTTP 服务 + * 鍚姩 HTTP 鏈嶅姟 */ async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> { if (this.running && this.server) { @@ -98,7 +118,7 @@ class HttpService { return new Promise((resolve) => { this.server = http.createServer((req, res) => this.handleRequest(req, res)) - // 跟踪所有连接,以便关闭时能强制断开 + // 璺熻釜鎵€鏈夎繛鎺ワ紝浠ヤ究鍏抽棴鏃惰兘寮哄埗鏂紑 this.server.on('connection', (socket) => { this.connections.add(socket) socket.on('close', () => { @@ -125,12 +145,12 @@ class HttpService { } /** - * 停止 HTTP 服务 + * 鍋滄 HTTP 鏈嶅姟 */ async stop(): Promise { return new Promise((resolve) => { if (this.server) { - // 强制关闭所有活动连接 + // 寮哄埗鍏抽棴鎵€鏈夋椿鍔ㄨ繛鎺? for (const socket of this.connections) { socket.destroy() } @@ -150,24 +170,28 @@ class HttpService { } /** - * 检查服务是否运行 + * 妫€鏌ユ湇鍔℃槸鍚﹁繍琛? */ isRunning(): boolean { return this.running } /** - * 获取当前端口 + * 鑾峰彇褰撳墠绔彛 */ getPort(): number { return this.port } + getDefaultMediaExportPath(): string { + return this.getApiMediaExportPath() + } + /** - * 处理 HTTP 请求 + * 澶勭悊 HTTP 璇锋眰 */ private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { - // 设置 CORS 头 + // 璁剧疆 CORS 澶? res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type') @@ -182,7 +206,7 @@ class HttpService { const pathname = url.pathname try { - // 路由处理 + // 璺敱澶勭悊 if (pathname === '/health' || pathname === '/api/v1/health') { this.sendJson(res, { status: 'ok' }) } else if (pathname === '/api/v1/messages') { @@ -201,8 +225,8 @@ class HttpService { } /** - * 批量获取消息(循环游标直到满足 limit) - * 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标 + * 鎵归噺鑾峰彇娑堟伅锛堝惊鐜父鏍囩洿鍒版弧瓒?limit锛? + * 缁曡繃 chatService 鐨勫崟 batch 闄愬埗锛岀洿鎺ユ搷浣?wcdbService 娓告爣 */ private async fetchMessagesBatch( talker: string, @@ -213,14 +237,14 @@ class HttpService { ascending: boolean ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> { try { - // 使用固定 batch 大小(与 limit 相同或最大 500)来减少循环次数 + // 浣跨敤鍥哄畾 batch 澶у皬锛堜笌 limit 鐩稿悓鎴栨渶澶?500锛夋潵鍑忓皯寰幆娆℃暟 const batchSize = Math.min(limit, 500) const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime const cursorResult = await wcdbService.openMessageCursor(talker, batchSize, ascending, beginTimestamp, endTimestamp) if (!cursorResult.success || !cursorResult.cursor) { - return { success: false, error: cursorResult.error || '打开消息游标失败' } + return { success: false, error: cursorResult.error || '鎵撳紑娑堟伅娓告爣澶辫触' } } const cursor = cursorResult.cursor @@ -229,7 +253,7 @@ class HttpService { let hasMore = true let skipped = 0 - // 循环获取消息,处理 offset 跳过 + limit 累积 + // 寰幆鑾峰彇娑堟伅锛屽鐞?offset 璺宠繃 + limit 绱Н while (allRows.length < limit && hasMore) { const batch = await wcdbService.fetchMessageBatch(cursor) if (!batch.success || !batch.rows || batch.rows.length === 0) { @@ -240,7 +264,7 @@ class HttpService { let rows = batch.rows hasMore = batch.hasMore === true - // 处理 offset: 跳过前 N 条 + // 澶勭悊 offset: 璺宠繃鍓?N 鏉? if (skipped < offset) { const remaining = offset - skipped if (remaining >= rows.length) { @@ -256,7 +280,7 @@ class HttpService { const trimmedRows = allRows.slice(0, limit) const finalHasMore = hasMore || allRows.length > limit - const messages = this.mapRowsToMessagesSimple(trimmedRows) + const messages = chatService.mapRowsToMessagesForApi(trimmedRows) return { success: true, messages, hasMore: finalHasMore } } finally { await wcdbService.closeMessageCursor(cursor) @@ -268,154 +292,134 @@ class HttpService { } /** - * 简单的行数据到 Message 映射(用于 API 输出) + * Query param helpers. */ - private mapRowsToMessagesSimple(rows: Record[]): Message[] { - const myWxid = this.configService.get('myWxid') || '' - const messages: Message[] = [] - - for (const row of rows) { - const content = this.getField(row, ['message_content', 'messageContent', 'content', 'msg_content', 'WCDB_CT_message_content']) || '' - const localType = parseInt(this.getField(row, ['local_type', 'localType', 'type', 'msg_type', 'WCDB_CT_local_type']) || '1', 10) - const isSendRaw = this.getField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']) - const senderUsername = this.getField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || '' - const createTime = parseInt(this.getField(row, ['create_time', 'createTime', 'msg_create_time', 'WCDB_CT_create_time']) || '0', 10) - const localId = parseInt(this.getField(row, ['local_id', 'localId', 'WCDB_CT_local_id', 'rowid']) || '0', 10) - const serverId = this.getField(row, ['server_id', 'serverId', 'WCDB_CT_server_id']) || '' - - let isSend: number - if (isSendRaw !== null && isSendRaw !== undefined) { - isSend = parseInt(isSendRaw, 10) - } else if (senderUsername && myWxid) { - isSend = senderUsername.toLowerCase() === myWxid.toLowerCase() ? 1 : 0 - } else { - isSend = 0 - } - - // 解析消息内容中的特殊字段 - let parsedContent = content - let xmlType: string | undefined - let linkTitle: string | undefined - let fileName: string | undefined - let emojiCdnUrl: string | undefined - let emojiMd5: string | undefined - let imageMd5: string | undefined - let videoMd5: string | undefined - let cardNickname: string | undefined - - if (localType === 49 && content) { - // 提取 type 子标签 - const typeMatch = /(\d+)<\/type>/i.exec(content) - if (typeMatch) xmlType = typeMatch[1] - // 提取 title - const titleMatch = /([^<]*)<\/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 parseIntParam(value: string | null, defaultValue: number, min: number, max: number): number { + const parsed = parseInt(value || '', 10) + if (!Number.isFinite(parsed)) return defaultValue + return Math.min(Math.max(parsed, min), max) } - /** - * 从行数据中获取字段值(兼容多种字段名) - */ - private getField(row: Record<string, any>, keys: string[]): string | null { + private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean { for (const key of keys) { - if (row[key] !== undefined && row[key] !== null) { - return String(row[key]) - } + const raw = url.searchParams.get(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> { - const talker = url.searchParams.get('talker') - const limit = Math.min(parseInt(url.searchParams.get('limit') || '100', 10), 10000) - const offset = parseInt(url.searchParams.get('offset') || '0', 10) + const talker = (url.searchParams.get('talker') || '').trim() + const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000) + 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 endParam = url.searchParams.get('end') - const chatlab = url.searchParams.get('chatlab') === '1' - const formatParam = url.searchParams.get('format') + const chatlab = this.parseBooleanParam(url, ['chatlab'], false) + const formatParam = (url.searchParams.get('format') || '').trim().toLowerCase() const format = formatParam || (chatlab ? 'chatlab' : 'json') + const mediaOptions = this.parseMediaOptions(url) if (!talker) { this.sendError(res, 400, 'Missing required parameter: talker') return } - // 解析时间参数 (支持 YYYYMMDD 格式) + if (format !== 'json' && format !== 'chatlab') { + this.sendError(res, 400, 'Invalid format, supported: json/chatlab') + return + } + const startTime = this.parseTimeParam(startParam) const endTime = this.parseTimeParam(endParam, true) + const queryOffset = keyword ? 0 : offset + const queryLimit = keyword ? 10000 : limit - // 使用批量获取方法,绕过 chatService 的单 batch 限制 - const result = await this.fetchMessagesBatch(talker, offset, limit, startTime, endTime, true) + const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true) if (!result.success || !result.messages) { this.sendError(res, 500, result.error || 'Failed to get messages') return } - if (format === 'chatlab') { - // 获取会话显示名 - const displayNames = await this.getDisplayNames([talker]) - const talkerName = displayNames[talker] || talker + let messages = result.messages + let hasMore = result.hasMore === true - const chatLabData = await this.convertToChatLab(result.messages, talker, talkerName) - this.sendJson(res, chatLabData) - } else { - // 返回原始消息格式 - this.sendJson(res, { - success: true, - talker, - count: result.messages.length, - hasMore: result.hasMore, - messages: result.messages + 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 talkerName = displayNames[talker] || talker + + if (format === 'chatlab') { + const chatLabData = await this.convertToChatLab(messages, talker, talkerName, mediaMap) + 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, { + success: true, + talker, + count: apiMessages.length, + hasMore, + media: { + enabled: mediaOptions.enabled, + exportPath: this.getApiMediaExportPath(), + count: mediaMap.size + }, + messages: apiMessages + }) } /** - * 处理会话列表查询 + * 澶勭悊浼氳瘽鍒楄〃鏌ヨ * GET /api/v1/sessions?keyword=xxx&limit=100 */ private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> { - const keyword = url.searchParams.get('keyword') || '' - const limit = parseInt(url.searchParams.get('limit') || '100', 10) + const keyword = (url.searchParams.get('keyword') || '').trim() + const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000) try { const sessions = await chatService.getSessions() @@ -433,7 +437,7 @@ class HttpService { ) } - // 应用 limit + // 搴旂敤 limit const limitedSessions = filteredSessions.slice(0, limit) this.sendJson(res, { @@ -453,12 +457,12 @@ class HttpService { } /** - * 处理联系人查询 + * 澶勭悊鑱旂郴浜烘煡璇? * GET /api/v1/contacts?keyword=xxx&limit=100 */ private async handleContacts(url: URL, res: http.ServerResponse): Promise<void> { - const keyword = url.searchParams.get('keyword') || '' - const limit = parseInt(url.searchParams.get('limit') || '100', 10) + const keyword = (url.searchParams.get('keyword') || '').trim() + const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000) try { const contacts = await chatService.getContacts() @@ -490,30 +494,180 @@ 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 格式,返回秒级时间戳 + * 瑙f瀽鏃堕棿鍙傛暟 + * 鏀寔 YYYYMMDD 鏍煎紡锛岃繑鍥炵绾ф椂闂存埑 */ private parseTimeParam(param: string | null, isEnd: boolean = false): number { if (!param) return 0 - // 纯数字且长度为8,视为 YYYYMMDD + // 绾暟瀛椾笖闀垮害涓?锛岃涓?YYYYMMDD if (/^\d{8}$/.test(param)) { const year = parseInt(param.slice(0, 4), 10) const month = parseInt(param.slice(4, 6), 10) - 1 const day = parseInt(param.slice(6, 8), 10) const date = new Date(year, month, day) if (isEnd) { - // 结束时间设为当天 23:59:59 + // 缁撴潫鏃堕棿璁句负褰撳ぉ 23:59:59 date.setHours(23, 59, 59, 999) } return Math.floor(date.getTime() / 1000) } - // 纯数字,视为时间戳 + // 绾暟瀛楋紝瑙嗕负鏃堕棿鎴? if (/^\d+$/.test(param)) { const ts = parseInt(param, 10) - // 如果是毫秒级时间戳,转为秒级 + // 濡傛灉鏄绉掔骇鏃堕棿鎴筹紝杞负绉掔骇 return ts > 10000000000 ? Math.floor(ts / 1000) : ts } @@ -521,7 +675,7 @@ class HttpService { } /** - * 获取显示名称 + * 鑾峰彇鏄剧ず鍚嶇О */ private async getDisplayNames(usernames: string[]): Promise<Record<string, string>> { try { @@ -532,18 +686,23 @@ class HttpService { } catch (e) { console.error('[HttpService] Failed to get display names:', e) } - // 返回空对象,调用方会使用 username 作为备用 + // 杩斿洖绌哄璞★紝璋冪敤鏂逛細浣跨敤 username 浣滀负澶囩敤 return {} } /** - * 转换为 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 myWxid = this.configService.get('myWxid') || '' - // 收集所有发送者 + // 鏀堕泦鎵€鏈夊彂閫佽€? const senderSet = new Set<string>() for (const msg of messages) { if (msg.senderUsername) { @@ -551,10 +710,10 @@ class HttpService { } } - // 获取发送者显示名 + // 鑾峰彇鍙戦€佽€呮樉绀哄悕 const senderNames = await this.getDisplayNames(Array.from(senderSet)) - // 获取群昵称(如果是群聊) + // 鑾峰彇缇ゆ樀绉帮紙濡傛灉鏄兢鑱婏級 let groupNicknamesMap = new Map<string, string>() if (isGroup) { try { @@ -567,31 +726,31 @@ class HttpService { } } - // 构建成员列表 + // 鏋勫缓鎴愬憳鍒楄〃 const memberMap = new Map<string, ChatLabMember>() for (const msg of messages) { const sender = msg.senderUsername || '' if (sender && !memberMap.has(sender)) { const displayName = senderNames[sender] || sender const isSelf = sender === myWxid || sender.toLowerCase() === myWxid.toLowerCase() - // 获取群昵称(尝试多种方式) + // 鑾峰彇缇ゆ樀绉帮紙灏濊瘯澶氱鏂瑰紡锛? const groupNickname = isGroup ? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '') : '' memberMap.set(sender, { platformId: sender, - accountName: isSelf ? '我' : displayName, + accountName: isSelf ? '鎴? : displayName, groupNickname: groupNickname || undefined }) } } - // 转换消息 + // 杞崲娑堟伅 const chatLabMessages: ChatLabMessage[] = messages.map(msg => { const sender = msg.senderUsername || '' const isSelf = msg.isSend === 1 || sender === myWxid - const accountName = isSelf ? '我' : (senderNames[sender] || sender) - // 获取该发送者的群昵称 + const accountName = isSelf ? '鎴? : (senderNames[sender] || sender) + // 鑾峰彇璇ュ彂閫佽€呯殑缇ゆ樀绉? const groupNickname = isGroup ? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '') : '' @@ -603,7 +762,8 @@ class HttpService { timestamp: msg.createTime, type: this.mapMessageType(msg.localType, 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 } }) @@ -626,37 +786,37 @@ class HttpService { } /** - * 映射 WeChat 消息类型到 ChatLab 类型 + * 鏄犲皠 WeChat 娑堟伅绫诲瀷鍒?ChatLab 绫诲瀷 */ private mapMessageType(localType: number, msg: Message): number { switch (localType) { - case 1: // 文本 + case 1: // 鏂囨湰 return ChatLabType.TEXT - case 3: // 图片 + case 3: // 鍥剧墖 return ChatLabType.IMAGE - case 34: // 语音 + case 34: // 璇煶 return ChatLabType.VOICE - case 43: // 视频 + case 43: // 瑙嗛 return ChatLabType.VIDEO - case 47: // 动画表情 + case 47: // 鍔ㄧ敾琛ㄦ儏 return ChatLabType.EMOJI - case 48: // 位置 + case 48: // 浣嶇疆 return ChatLabType.LOCATION - case 42: // 名片 + case 42: // 鍚嶇墖 return ChatLabType.CONTACT - case 50: // 语音/视频通话 + case 50: // 璇煶/瑙嗛閫氳瘽 return ChatLabType.CALL - case 10000: // 系统消息 + case 10000: // 绯荤粺娑堟伅 return ChatLabType.SYSTEM - case 49: // 复合消息 + case 49: // 澶嶅悎娑堟伅 return this.mapType49(msg) - case 244813135921: // 引用消息 + case 244813135921: // 寮曠敤娑堟伅 return ChatLabType.REPLY - case 266287972401: // 拍一拍 + case 266287972401: // 鎷嶄竴鎷? return ChatLabType.POKE - case 8594229559345: // 红包 + case 8594229559345: // 绾㈠寘 return ChatLabType.RED_PACKET - case 8589934592049: // 转账 + case 8589934592049: // 杞处 return ChatLabType.TRANSFER default: return ChatLabType.OTHER @@ -664,27 +824,27 @@ class HttpService { } /** - * 映射 Type 49 子类型 + * 鏄犲皠 Type 49 瀛愮被鍨? */ private mapType49(msg: Message): number { const xmlType = msg.xmlType switch (xmlType) { - case '5': // 链接 + case '5': // 閾炬帴 case '49': return ChatLabType.LINK - case '6': // 文件 + case '6': // 鏂囦欢 return ChatLabType.FILE - case '19': // 聊天记录 + case '19': // 鑱婂ぉ璁板綍 return ChatLabType.FORWARD - case '33': // 小程序 + case '33': // 灏忕▼搴? case '36': return ChatLabType.SHARE - case '57': // 引用消息 + case '57': // 寮曠敤娑堟伅 return ChatLabType.REPLY - case '2000': // 转账 + case '2000': // 杞处 return ChatLabType.TRANSFER - case '2001': // 红包 + case '2001': // 绾㈠寘 return ChatLabType.RED_PACKET default: return ChatLabType.OTHER @@ -692,26 +852,26 @@ class HttpService { } /** - * 获取消息内容 + * 鑾峰彇娑堟伅鍐呭 */ private getMessageContent(msg: Message): string | null { - // 优先使用已解析的内容 + // 浼樺厛浣跨敤宸茶В鏋愮殑鍐呭 if (msg.parsedContent) { return msg.parsedContent } - // 根据类型返回占位符 + // 鏍规嵁绫诲瀷杩斿洖鍗犱綅绗? switch (msg.localType) { case 1: return msg.rawContent || null case 3: - return msg.imageMd5 || '[图片]' + return '[图片]' case 34: return '[语音]' case 43: - return msg.videoMd5 || '[视频]' + return '[视频]' case 47: - return msg.emojiCdnUrl || msg.emojiMd5 || '[表情]' + return '[表情]' case 42: return msg.cardNickname || '[名片]' case 48: @@ -724,7 +884,7 @@ class HttpService { } /** - * 发送 JSON 响应 + * 鍙戦€?JSON 鍝嶅簲 */ private sendJson(res: http.ServerResponse, data: any): void { res.setHeader('Content-Type', 'application/json; charset=utf-8') @@ -733,7 +893,7 @@ class HttpService { } /** - * 发送错误响应 + * 鍙戦€侀敊璇搷搴? */ private sendError(res: http.ServerResponse, code: number, message: string): void { res.setHeader('Content-Type', 'application/json; charset=utf-8') @@ -743,3 +903,4 @@ class HttpService { } export const httpService = new HttpService() + diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 1eefbda..1911acd 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -148,6 +148,7 @@ function SettingsPage() { const [httpApiEnabled, setHttpApiEnabled] = useState(false) const [httpApiPort, setHttpApiPort] = useState(5031) const [httpApiRunning, setHttpApiRunning] = useState(false) + const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('') const [isTogglingApi, setIsTogglingApi] = useState(false) const [showApiWarning, setShowApiWarning] = useState(false) @@ -169,6 +170,9 @@ function SettingsPage() { if (status.port) { setHttpApiPort(status.port) } + if (status.mediaExportPath) { + setHttpApiMediaExportPath(status.mediaExportPath) + } } catch (e) { console.error('检查 API 状态失败:', e) } @@ -1978,6 +1982,17 @@ function SettingsPage() { )} {/* 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 && ( <div className="modal-overlay" onClick={() => setShowApiWarning(false)}> <div className="api-warning-modal" onClick={(e) => e.stopPropagation()}> diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 40caef7..a1f1784 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -501,6 +501,11 @@ export interface ElectronAPI { onToken: (callback: (token: string) => 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 { From 15e08dc529cd563d43e3a38f83cd1d6a1c40057d Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Thu, 19 Feb 2026 17:12:28 +0800 Subject: [PATCH 07/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=8B=E5=8F=8B?= =?UTF-8?q?=E5=9C=88=E8=A7=86=E9=A2=91=E4=B9=9F=E8=B5=B0=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/SnsPage.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 218d8d0..d10031a 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -46,6 +46,12 @@ 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 @@ -95,6 +101,9 @@ const isLikelyMediaAssetUrl = (url: string): boolean => { } 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)) @@ -201,7 +210,7 @@ const MediaItem = ({ media, onPreview }: { media: any; onPreview: (src: string, const targetUrl = thumb || url // 默认显示缩略图 // 判断是否为视频 - const isVideo = url && (url.includes('snsvideodownload') || url.includes('.mp4') || url.includes('video')) && !url.includes('vweixinthumb') + const isVideo = isSnsVideoUrl(url) useEffect(() => { let cancelled = false @@ -764,7 +773,8 @@ export default function SnsPage() { )} {posts.map((post) => { const linkCard = buildLinkCardData(post) - const showLinkCard = Boolean(linkCard) && post.media.length <= 1 + 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 ( <div key={post.id} className="sns-post-row"> From c1a393eaf67e6e712bc54e8e21731ad702cb7f9b Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Thu, 19 Feb 2026 17:28:12 +0800 Subject: [PATCH 08/12] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/httpService.ts | 136 +++++++++++++++---------------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index c36a498..bab754d 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -1,6 +1,6 @@ /** - * HTTP API 鏈嶅姟 - * 鎻愪緵 ChatLab 鏍囧噯鍖栨牸寮忕殑娑堟伅鏌ヨ API + * HTTP API 服务 + * 提供 ChatLab 标准化格式的消息查询 API */ import * as http from 'http' import * as fs from 'fs' @@ -11,7 +11,7 @@ import { wcdbService } from './wcdbService' import { ConfigService } from './config' import { videoService } from './videoService' -// ChatLab 鏍煎紡瀹氫箟 +// ChatLab 格式定义 interface ChatLabHeader { version: string exportedAt: number @@ -71,7 +71,7 @@ interface ApiExportedMedia { fullPath: string } -// ChatLab 娑堟伅绫诲瀷鏄犲皠 +// ChatLab 消息类型映射 const ChatLabType = { TEXT: 0, IMAGE: 1, @@ -106,7 +106,7 @@ class HttpService { } /** - * 鍚姩 HTTP 鏈嶅姟 + * 启动 HTTP 服务 */ async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> { if (this.running && this.server) { @@ -118,7 +118,7 @@ class HttpService { return new Promise((resolve) => { this.server = http.createServer((req, res) => this.handleRequest(req, res)) - // 璺熻釜鎵€鏈夎繛鎺ワ紝浠ヤ究鍏抽棴鏃惰兘寮哄埗鏂紑 + // 跟踪所有连接,以便关闭时能强制断开 this.server.on('connection', (socket) => { this.connections.add(socket) socket.on('close', () => { @@ -145,12 +145,12 @@ class HttpService { } /** - * 鍋滄 HTTP 鏈嶅姟 + * 停止 HTTP 服务 */ async stop(): Promise<void> { return new Promise((resolve) => { if (this.server) { - // 寮哄埗鍏抽棴鎵€鏈夋椿鍔ㄨ繛鎺? + // 强制关闭所有活动连接 for (const socket of this.connections) { socket.destroy() } @@ -170,14 +170,14 @@ class HttpService { } /** - * 妫€鏌ユ湇鍔℃槸鍚﹁繍琛? + * 检查服务是否运行 */ isRunning(): boolean { return this.running } /** - * 鑾峰彇褰撳墠绔彛 + * 获取当前端口 */ getPort(): number { return this.port @@ -188,10 +188,10 @@ class HttpService { } /** - * 澶勭悊 HTTP 璇锋眰 + * 处理 HTTP 请求 */ private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> { - // 璁剧疆 CORS 澶? + // 设置 CORS 头 res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type') @@ -206,7 +206,7 @@ class HttpService { const pathname = url.pathname try { - // 璺敱澶勭悊 + // 路由处理 if (pathname === '/health' || pathname === '/api/v1/health') { this.sendJson(res, { status: 'ok' }) } else if (pathname === '/api/v1/messages') { @@ -225,8 +225,8 @@ class HttpService { } /** - * 鎵归噺鑾峰彇娑堟伅锛堝惊鐜父鏍囩洿鍒版弧瓒?limit锛? - * 缁曡繃 chatService 鐨勫崟 batch 闄愬埗锛岀洿鎺ユ搷浣?wcdbService 娓告爣 + * 批量获取消息(循环游标直到满足 limit) + * 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标 */ private async fetchMessagesBatch( talker: string, @@ -237,14 +237,14 @@ class HttpService { ascending: boolean ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> { try { - // 浣跨敤鍥哄畾 batch 澶у皬锛堜笌 limit 鐩稿悓鎴栨渶澶?500锛夋潵鍑忓皯寰幆娆℃暟 + // 使用固定 batch 大小(与 limit 相同或最多 500)来减少循环次数 const batchSize = Math.min(limit, 500) const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime const cursorResult = await wcdbService.openMessageCursor(talker, batchSize, ascending, beginTimestamp, endTimestamp) if (!cursorResult.success || !cursorResult.cursor) { - return { success: false, error: cursorResult.error || '鎵撳紑娑堟伅娓告爣澶辫触' } + return { success: false, error: cursorResult.error || '打开消息游标失败' } } const cursor = cursorResult.cursor @@ -253,7 +253,7 @@ class HttpService { let hasMore = true let skipped = 0 - // 寰幆鑾峰彇娑堟伅锛屽鐞?offset 璺宠繃 + limit 绱Н + // 循环获取消息,处理 offset 跳过 + limit 累积 while (allRows.length < limit && hasMore) { const batch = await wcdbService.fetchMessageBatch(cursor) if (!batch.success || !batch.rows || batch.rows.length === 0) { @@ -264,7 +264,7 @@ class HttpService { let rows = batch.rows hasMore = batch.hasMore === true - // 澶勭悊 offset: 璺宠繃鍓?N 鏉? + // 处理 offset:跳过前 N 条 if (skipped < offset) { const remaining = offset - skipped if (remaining >= rows.length) { @@ -414,7 +414,7 @@ class HttpService { } /** - * 澶勭悊浼氳瘽鍒楄〃鏌ヨ + * 处理会话列表查询 * GET /api/v1/sessions?keyword=xxx&limit=100 */ private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> { @@ -437,7 +437,7 @@ class HttpService { ) } - // 搴旂敤 limit + // 应用 limit const limitedSessions = filteredSessions.slice(0, limit) this.sendJson(res, { @@ -457,7 +457,7 @@ class HttpService { } /** - * 澶勭悊鑱旂郴浜烘煡璇? + * 处理联系人查询 * GET /api/v1/contacts?keyword=xxx&limit=100 */ private async handleContacts(url: URL, res: http.ServerResponse): Promise<void> { @@ -645,29 +645,29 @@ class HttpService { } /** - * 瑙f瀽鏃堕棿鍙傛暟 - * 鏀寔 YYYYMMDD 鏍煎紡锛岃繑鍥炵绾ф椂闂存埑 + * 解析时间参数 + * 支持 YYYYMMDD 格式,返回秒级时间戳 */ private parseTimeParam(param: string | null, isEnd: boolean = false): number { if (!param) return 0 - // 绾暟瀛椾笖闀垮害涓?锛岃涓?YYYYMMDD + // 纯数字且长度为 8,视为 YYYYMMDD if (/^\d{8}$/.test(param)) { const year = parseInt(param.slice(0, 4), 10) const month = parseInt(param.slice(4, 6), 10) - 1 const day = parseInt(param.slice(6, 8), 10) const date = new Date(year, month, day) if (isEnd) { - // 缁撴潫鏃堕棿璁句负褰撳ぉ 23:59:59 + // 结束时间设为当天 23:59:59 date.setHours(23, 59, 59, 999) } return Math.floor(date.getTime() / 1000) } - // 绾暟瀛楋紝瑙嗕负鏃堕棿鎴? + // 纯数字,视为时间戳 if (/^\d+$/.test(param)) { const ts = parseInt(param, 10) - // 濡傛灉鏄绉掔骇鏃堕棿鎴筹紝杞负绉掔骇 + // 如果是毫秒级时间戳,转为秒级 return ts > 10000000000 ? Math.floor(ts / 1000) : ts } @@ -675,7 +675,7 @@ class HttpService { } /** - * 鑾峰彇鏄剧ず鍚嶇О + * 获取显示名称 */ private async getDisplayNames(usernames: string[]): Promise<Record<string, string>> { try { @@ -686,12 +686,12 @@ class HttpService { } catch (e) { console.error('[HttpService] Failed to get display names:', e) } - // 杩斿洖绌哄璞★紝璋冪敤鏂逛細浣跨敤 username 浣滀负澶囩敤 + // 返回空对象,调用方会使用 username 作为备用 return {} } /** - * 杞崲涓?ChatLab 鏍煎紡 + * 转换为 ChatLab 格式 */ private async convertToChatLab( messages: Message[], @@ -702,7 +702,7 @@ class HttpService { const isGroup = talkerId.endsWith('@chatroom') const myWxid = this.configService.get('myWxid') || '' - // 鏀堕泦鎵€鏈夊彂閫佽€? + // 收集所有发送者 const senderSet = new Set<string>() for (const msg of messages) { if (msg.senderUsername) { @@ -710,10 +710,10 @@ class HttpService { } } - // 鑾峰彇鍙戦€佽€呮樉绀哄悕 + // 获取发送者显示名 const senderNames = await this.getDisplayNames(Array.from(senderSet)) - // 鑾峰彇缇ゆ樀绉帮紙濡傛灉鏄兢鑱婏級 + // 获取群昵称(如果是群聊) let groupNicknamesMap = new Map<string, string>() if (isGroup) { try { @@ -726,31 +726,31 @@ class HttpService { } } - // 鏋勫缓鎴愬憳鍒楄〃 + // 构建成员列表 const memberMap = new Map<string, ChatLabMember>() for (const msg of messages) { const sender = msg.senderUsername || '' if (sender && !memberMap.has(sender)) { const displayName = senderNames[sender] || sender const isSelf = sender === myWxid || sender.toLowerCase() === myWxid.toLowerCase() - // 鑾峰彇缇ゆ樀绉帮紙灏濊瘯澶氱鏂瑰紡锛? + // 获取群昵称(尝试多种方式) const groupNickname = isGroup ? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '') : '' memberMap.set(sender, { platformId: sender, - accountName: isSelf ? '鎴? : displayName, + accountName: isSelf ? '我' : displayName, groupNickname: groupNickname || undefined }) } } - // 杞崲娑堟伅 + // 转换消息 const chatLabMessages: ChatLabMessage[] = messages.map(msg => { const sender = msg.senderUsername || '' const isSelf = msg.isSend === 1 || sender === myWxid - const accountName = isSelf ? '鎴? : (senderNames[sender] || sender) - // 鑾峰彇璇ュ彂閫佽€呯殑缇ゆ樀绉? + const accountName = isSelf ? '我' : (senderNames[sender] || sender) + // 获取该发送者的群昵称 const groupNickname = isGroup ? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '') : '' @@ -786,37 +786,37 @@ class HttpService { } /** - * 鏄犲皠 WeChat 娑堟伅绫诲瀷鍒?ChatLab 绫诲瀷 + * 映射 WeChat 消息类型到 ChatLab 类型 */ private mapMessageType(localType: number, msg: Message): number { switch (localType) { - case 1: // 鏂囨湰 + case 1: // 文本 return ChatLabType.TEXT - case 3: // 鍥剧墖 + case 3: // 图片 return ChatLabType.IMAGE - case 34: // 璇煶 + case 34: // 语音 return ChatLabType.VOICE - case 43: // 瑙嗛 + case 43: // 视频 return ChatLabType.VIDEO - case 47: // 鍔ㄧ敾琛ㄦ儏 + case 47: // 动画表情 return ChatLabType.EMOJI - case 48: // 浣嶇疆 + case 48: // 位置 return ChatLabType.LOCATION - case 42: // 鍚嶇墖 + case 42: // 名片 return ChatLabType.CONTACT - case 50: // 璇煶/瑙嗛閫氳瘽 + case 50: // 语音/视频通话 return ChatLabType.CALL - case 10000: // 绯荤粺娑堟伅 + case 10000: // 系统消息 return ChatLabType.SYSTEM - case 49: // 澶嶅悎娑堟伅 + case 49: // 复合消息 return this.mapType49(msg) - case 244813135921: // 寮曠敤娑堟伅 + case 244813135921: // 引用消息 return ChatLabType.REPLY - case 266287972401: // 鎷嶄竴鎷? + case 266287972401: // 拍一拍 return ChatLabType.POKE - case 8594229559345: // 绾㈠寘 + case 8594229559345: // 红包 return ChatLabType.RED_PACKET - case 8589934592049: // 杞处 + case 8589934592049: // 转账 return ChatLabType.TRANSFER default: return ChatLabType.OTHER @@ -824,27 +824,27 @@ class HttpService { } /** - * 鏄犲皠 Type 49 瀛愮被鍨? + * 映射 Type 49 子类型 */ private mapType49(msg: Message): number { const xmlType = msg.xmlType switch (xmlType) { - case '5': // 閾炬帴 + case '5': // 链接 case '49': return ChatLabType.LINK - case '6': // 鏂囦欢 + case '6': // 文件 return ChatLabType.FILE - case '19': // 鑱婂ぉ璁板綍 + case '19': // 聊天记录 return ChatLabType.FORWARD - case '33': // 灏忕▼搴? + case '33': // 小程序 case '36': return ChatLabType.SHARE - case '57': // 寮曠敤娑堟伅 + case '57': // 引用消息 return ChatLabType.REPLY - case '2000': // 杞处 + case '2000': // 转账 return ChatLabType.TRANSFER - case '2001': // 绾㈠寘 + case '2001': // 红包 return ChatLabType.RED_PACKET default: return ChatLabType.OTHER @@ -852,15 +852,15 @@ class HttpService { } /** - * 鑾峰彇娑堟伅鍐呭 + * 获取消息内容 */ private getMessageContent(msg: Message): string | null { - // 浼樺厛浣跨敤宸茶В鏋愮殑鍐呭 + // 优先使用已解析的内容 if (msg.parsedContent) { return msg.parsedContent } - // 鏍规嵁绫诲瀷杩斿洖鍗犱綅绗? + // 根据类型返回占位符 switch (msg.localType) { case 1: return msg.rawContent || null @@ -884,7 +884,7 @@ class HttpService { } /** - * 鍙戦€?JSON 鍝嶅簲 + * 发送 JSON 响应 */ private sendJson(res: http.ServerResponse, data: any): void { res.setHeader('Content-Type', 'application/json; charset=utf-8') @@ -893,7 +893,7 @@ class HttpService { } /** - * 鍙戦€侀敊璇搷搴? + * 发送错误响应 */ private sendError(res: http.ServerResponse, code: number, message: string): void { res.setHeader('Content-Type', 'application/json; charset=utf-8') From e88c859f4fc23915aa338a378c24451b4de9ad6e Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Thu, 19 Feb 2026 17:40:41 +0800 Subject: [PATCH 09/12] =?UTF-8?q?=E6=88=90=E5=91=98=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=8D=95=E6=8B=8E=E5=87=BA=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 189 ++++++++++++---- src/pages/GroupAnalyticsPage.scss | 185 ++++++++++++--- src/pages/GroupAnalyticsPage.tsx | 349 ++++++++++++++++++++++++----- src/types/electron.d.ts | 5 +- 4 files changed, 604 insertions(+), 124 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 3db210d..6a8a177 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -70,6 +70,8 @@ const MESSAGE_TYPE_MAP: Record<number, number> = { export interface ExportOptions { format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' dateRange?: { start: number; end: number } | null + senderUsername?: string + fileNameSuffix?: string exportMedia?: boolean exportAvatars?: boolean exportImages?: boolean @@ -568,6 +570,52 @@ class ExportService { 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 { if (s.length % 4 !== 0) return false return /^[A-Za-z0-9+/=]+$/.test(s) @@ -577,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 // 检查 XML 中的 type 标签(支持大 localType 的情况) @@ -614,10 +670,11 @@ class ExportService { if (type === '2000') { const feedesc = this.extractXmlValue(content, 'feedesc') const payMemo = this.extractXmlValue(content, 'pay_memo') + const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend) if (feedesc) { - return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}` + return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}` } - return '[转账]' + return transferPrefix } if (type === '6') return title ? `[文件] ${title}` : '[文件]' @@ -653,10 +710,11 @@ class ExportService { if (xmlType === '2000') { const feedesc = this.extractXmlValue(content, 'feedesc') const payMemo = this.extractXmlValue(content, 'pay_memo') + const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend) if (feedesc) { - return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}` + return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}` } - return '[转账]' + return transferPrefix } // 其他类型 @@ -679,7 +737,10 @@ class ExportService { content: string, localType: number, options: { exportVoiceAsText?: boolean }, - voiceTranscript?: string + voiceTranscript?: string, + myWxid?: string, + senderWxid?: string, + isSend?: boolean ): string { const safeContent = content || '' @@ -745,8 +806,9 @@ class ExportService { if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) { const feedesc = this.extractXmlValue(normalized, 'feedesc') const payMemo = this.extractXmlValue(normalized, 'pay_memo') + const transferPrefix = this.getTransferPrefix(normalized, myWxid, senderWxid, isSend) if (feedesc) { - return payMemo ? `[转账]${feedesc} ${payMemo}` : `[转账]${feedesc}` + return payMemo ? `${transferPrefix}${feedesc} ${payMemo}` : `${transferPrefix}${feedesc}` } const amount = this.extractAmountFromText( [ @@ -759,7 +821,7 @@ class ExportService { .filter(Boolean) .join(' ') ) - return amount ? `[转账]${amount}` : '[转账]' + return amount ? `${transferPrefix}${amount}` : transferPrefix } if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) { @@ -1259,7 +1321,7 @@ class ExportService { 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 (localType === 1) { @@ -1267,10 +1329,10 @@ class ExportService { } 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) } /** @@ -1688,7 +1750,8 @@ class ExportService { private async collectMessages( sessionId: 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 }> { const rows: any[] = [] const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>() @@ -1749,6 +1812,10 @@ class ExportService { } else { actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId) } + + if (senderUsernameFilter && !this.isSameWxid(actualSender, senderUsernameFilter)) { + continue + } senderSet.add(actualSender) // 提取媒体相关字段 @@ -2177,7 +2244,7 @@ class ExportService { 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 // 如果没有消息,不创建文件 @@ -2338,11 +2405,19 @@ class ExportService { // 使用预先转写的文字 content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' } 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( msg.content, cleanedMyWxid, @@ -2353,7 +2428,7 @@ class ExportService { } ) if (transferDesc) { - content = content.replace('[转账]', `[转账] (${transferDesc})`) + content = this.appendTransferDesc(content, transferDesc) } } @@ -2564,7 +2639,7 @@ class ExportService { 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) { @@ -2708,11 +2783,19 @@ class ExportService { } else if (mediaItem) { content = mediaItem.relativePath } 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( msg.content, cleanedMyWxid, @@ -2726,7 +2809,7 @@ class ExportService { } ) if (transferDesc) { - content = content.replace('[转账]', `[转账] (${transferDesc})`) + content = this.appendTransferDesc(content, transferDesc) } } @@ -2890,7 +2973,7 @@ class ExportService { 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) { @@ -3199,19 +3282,25 @@ class ExportService { msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId) + voiceTranscriptMap.get(msg.localId), + cleanedMyWxid, + msg.senderUsername, + msg.isSend ) : (mediaItem?.relativePath || this.formatPlainExportContent( msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId) + voiceTranscriptMap.get(msg.localId), + cleanedMyWxid, + msg.senderUsername, + msg.isSend )) // 转账消息:追加 "谁转账给谁" 信息 let enrichedContentValue = contentValue - if (contentValue.startsWith('[转账]') && msg.content) { + if (this.isTransferExportContent(contentValue) && msg.content) { const transferDesc = await this.resolveTransferDesc( msg.content, cleanedMyWxid, @@ -3225,7 +3314,7 @@ class ExportService { } ) if (transferDesc) { - enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`) + enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc) } } @@ -3371,7 +3460,7 @@ class ExportService { 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) { @@ -3510,19 +3599,25 @@ class ExportService { msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId) + voiceTranscriptMap.get(msg.localId), + cleanedMyWxid, + msg.senderUsername, + msg.isSend ) : (mediaItem?.relativePath || this.formatPlainExportContent( msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId) + voiceTranscriptMap.get(msg.localId), + cleanedMyWxid, + msg.senderUsername, + msg.isSend )) // 转账消息:追加 "谁转账给谁" 信息 let enrichedContentValue = contentValue - if (contentValue.startsWith('[转账]') && msg.content) { + if (this.isTransferExportContent(contentValue) && msg.content) { const transferDesc = await this.resolveTransferDesc( msg.content, cleanedMyWxid, @@ -3536,7 +3631,7 @@ class ExportService { } ) if (transferDesc) { - enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`) + enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc) } } @@ -3645,7 +3740,7 @@ class ExportService { 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) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -3808,7 +3903,15 @@ class ExportService { const msgText = msg.localType === 34 && options.exportVoiceAsText ? (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 row = [ @@ -3979,7 +4082,7 @@ class ExportService { 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) { @@ -4209,14 +4312,20 @@ class ExportService { const timeText = this.formatTimestamp(msg.createTime) 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) { textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' } if (mediaItem && (msg.localType === 3 || msg.localType === 47)) { textContent = '' } - if (textContent.startsWith('[转账]') && msg.content) { + if (this.isTransferExportContent(textContent) && msg.content) { const transferDesc = await this.resolveTransferDesc( msg.content, cleanedMyWxid, @@ -4230,7 +4339,7 @@ class ExportService { } ) if (transferDesc) { - textContent = textContent.replace('[转账]', `[转账] (${transferDesc})`) + textContent = this.appendTransferDesc(textContent, transferDesc) } } @@ -4441,7 +4550,7 @@ class ExportService { for (const sessionId of sessionIds) { 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 voiceMsgs = msgs.filter(m => m.localType === 34) const mediaMsgs = msgs.filter(m => { @@ -4540,7 +4649,10 @@ class ExportService { 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 sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir @@ -4604,3 +4716,4 @@ class ExportService { } export const exportService = new ExportService() + diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index 8bb980c..7dfc40f 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -777,6 +777,159 @@ } } +.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; + + span { + font-size: 12px; + color: var(--text-secondary); + } + + select { + width: 100%; + 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; + } + } + + .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-switch { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; + color: var(--text-primary); + + input { + width: 16px; + height: 16px; + cursor: pointer; + } + } + + .member-export-checkboxes { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + + label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-primary); + + input { + width: 16px; + height: 16px; + cursor: pointer; + } + } + } + + .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 { display: flex; flex-direction: column; @@ -1143,38 +1296,6 @@ text-align: center; } - .member-action-row { - width: 100%; - margin-bottom: 16px; - } - - .export-member-btn { - width: 100%; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - border: none; - border-radius: 10px; - padding: 10px 14px; - background: var(--bg-tertiary); - color: var(--text-primary); - cursor: pointer; - transition: all 0.15s; - font-size: 13px; - font-weight: 500; - - &:hover { - background: var(--bg-hover); - color: var(--primary); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - } - .member-details { width: 100%; display: flex; diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 4671b85..6f889ca 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useLocation } from 'react-router-dom' -import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react' +import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react' import { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' +import * as configService from '../services/config' import './GroupAnalyticsPage.scss' interface GroupChatInfo { @@ -28,7 +29,20 @@ interface GroupMessageRank { 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' +} function GroupAnalyticsPage() { const location = useLocation() @@ -47,6 +61,19 @@ function GroupAnalyticsPage() { const [functionLoading, setFunctionLoading] = 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) @@ -75,9 +102,49 @@ function GroupAnalyticsPage() { .filter(Boolean) }, [location.state]) + const memberExportFormatOptions = useMemo<Array<{ value: MemberExportFormat; label: string }>>(() => ([ + { value: 'excel', label: 'Excel' }, + { value: 'txt', label: 'TXT' }, + { value: 'json', label: 'JSON' }, + { value: 'chatlab', label: 'ChatLab' }, + { value: 'chatlab-jsonl', label: 'ChatLab JSONL' }, + { value: 'html', label: 'HTML' }, + { value: 'weclone', label: 'WeClone CSV' } + ]), []) + + 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(() => { loadGroups() - }, []) + loadExportPath() + }, [loadGroups, loadExportPath]) useEffect(() => { preselectAppliedRef.current = false @@ -91,6 +158,17 @@ function GroupAnalyticsPage() { } }, [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(() => { if (preselectAppliedRef.current) return if (groups.length === 0 || preselectGroupIds.length === 0) return @@ -126,27 +204,12 @@ function GroupAnalyticsPage() { // 日期范围变化时自动刷新 useEffect(() => { - if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') { + if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members' && selectedFunction !== 'memberExport') { setDateRangeReady(false) loadFunctionData(selectedFunction) } }, [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(() => { const handleChange = () => { setGroups([]) @@ -158,15 +221,17 @@ function GroupAnalyticsPage() { setActiveHours({}) setMediaStats(null) void loadGroups() + void loadExportPath() } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, [loadGroups]) + }, [loadExportPath, loadGroups]) const handleGroupSelect = (group: GroupChatInfo) => { if (selectedGroup?.username !== group.username) { setSelectedGroup(group) setSelectedFunction(null) + setSelectedExportMemberUsername('') } } @@ -192,6 +257,11 @@ function GroupAnalyticsPage() { if (result.success && result.data) setMembers(result.data) break } + case 'memberExport': { + const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) + if (result.success && result.data) setMembers(result.data) + break + } case 'ranking': { const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime) if (result.success && result.data) setRankings(result.data) @@ -287,6 +357,7 @@ function GroupAnalyticsPage() { } const handleDateRangeComplete = () => { + if (selectedFunction === 'memberExport') return setDateRangeReady(true) } @@ -324,32 +395,75 @@ function GroupAnalyticsPage() { } } - const handleExportMemberMessages = async (member: GroupMember) => { - if (!selectedGroup || !member || isExportingMemberMessages) return + 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 downloadsPath = await window.electronAPI.app.getDownloadsPath() - const memberName = member.displayName || member.username - const baseName = sanitizeFileName(`${selectedGroup.displayName || selectedGroup.username}_${memberName}_消息记录`) - const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/' - const defaultPath = downloadsPath ? `${downloadsPath}${separator}${baseName}.xlsx` : `${baseName}.xlsx` - const saveResult = await window.electronAPI.dialog.saveFile({ - title: `导出 ${memberName} 的群聊消息`, - defaultPath, - filters: [ - { name: 'Excel', extensions: ['xlsx'] }, - { name: 'CSV', extensions: ['csv'] } - ] - }) - if (!saveResult || saveResult.canceled || !saveResult.filePath) return - - const result = await window.electronAPI.groupAnalytics.exportGroupMemberMessages( - selectedGroup.username, - member.username, - saveResult.filePath + 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) { - alert(`导出成功,共 ${result.count ?? 0} 条消息`) + if (result.success && (result.successCount ?? 0) > 0) { + alert(`导出成功:${member.displayName || member.username}`) } else { alert(`导出失败:${result.error || '未知错误'}`) } @@ -389,16 +503,6 @@ function GroupAnalyticsPage() { <Avatar src={selectedMember.avatarUrl} name={selectedMember.displayName} size={96} /> </div> <h3 className="member-display-name">{selectedMember.displayName}</h3> - <div className="member-action-row"> - <button - className="export-member-btn" - onClick={() => handleExportMemberMessages(selectedMember)} - disabled={isExportingMemberMessages} - > - {isExportingMemberMessages ? <Loader2 size={16} className="spin" /> : <Download size={16} />} - <span>{isExportingMemberMessages ? '导出中...' : '导出该成员全部消息'}</span> - </button> - </div> <div className="member-details"> <div className="detail-row"> <span className="detail-label">微信ID</span> @@ -527,6 +631,10 @@ function GroupAnalyticsPage() { <Users size={32} /> <span>群成员查看</span> </div> + <div className="function-card" onClick={() => handleFunctionSelect('memberExport')}> + <Download size={32} /> + <span>成员消息导出</span> + </div> <div className="function-card" onClick={() => handleFunctionSelect('ranking')}> <BarChart3 size={32} /> <span>群聊发言排行</span> @@ -547,6 +655,7 @@ function GroupAnalyticsPage() { const getFunctionTitle = () => { switch (selectedFunction) { case 'members': return '群成员查看' + case 'memberExport': return '成员消息导出' case 'ranking': return '群聊发言排行' case 'activeHours': return '群聊活跃时段' case 'mediaStats': return '媒体内容统计' @@ -602,6 +711,140 @@ function GroupAnalyticsPage() { ))} </div> )} + {selectedFunction === 'memberExport' && ( + <div className="member-export-panel"> + {members.length === 0 ? ( + <div className="member-export-empty">暂无群成员数据,请先刷新。</div> + ) : ( + <> + <div className="member-export-grid"> + <label className="member-export-field"> + <span>导出成员</span> + <select + value={selectedExportMemberUsername} + onChange={e => setSelectedExportMemberUsername(e.target.value)} + > + {members.map(member => ( + <option key={member.username} value={member.username}> + {member.displayName || member.username} + </option> + ))} + </select> + </label> + <label className="member-export-field"> + <span>导出格式</span> + <select + value={memberExportOptions.format} + onChange={e => handleMemberExportFormatChange(e.target.value as MemberExportFormat)} + > + {memberExportFormatOptions.map(option => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + </label> + <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"> + <label className="member-export-switch"> + <span>导出媒体文件</span> + <input + type="checkbox" + checked={memberExportOptions.exportMedia} + onChange={e => setMemberExportOptions(prev => ({ ...prev, exportMedia: e.target.checked }))} + /> + </label> + <div className="member-export-checkboxes"> + <label> + <input + type="checkbox" + checked={memberExportOptions.exportImages} + disabled={!memberExportOptions.exportMedia} + onChange={e => setMemberExportOptions(prev => ({ ...prev, exportImages: e.target.checked }))} + /> + <span>图片</span> + </label> + <label> + <input + type="checkbox" + checked={memberExportOptions.exportVoices} + disabled={!memberExportOptions.exportMedia} + onChange={e => setMemberExportOptions(prev => ({ ...prev, exportVoices: e.target.checked }))} + /> + <span>语音</span> + </label> + <label> + <input + type="checkbox" + checked={memberExportOptions.exportVideos} + disabled={!memberExportOptions.exportMedia} + onChange={e => setMemberExportOptions(prev => ({ ...prev, exportVideos: e.target.checked }))} + /> + <span>视频</span> + </label> + <label> + <input + type="checkbox" + checked={memberExportOptions.exportEmojis} + disabled={!memberExportOptions.exportMedia} + onChange={e => setMemberExportOptions(prev => ({ ...prev, exportEmojis: e.target.checked }))} + /> + <span>表情</span> + </label> + <label> + <input + type="checkbox" + checked={memberExportOptions.exportVoiceAsText} + onChange={e => setMemberExportOptions(prev => ({ ...prev, exportVoiceAsText: e.target.checked }))} + /> + <span>语音转文字</span> + </label> + <label> + <input + type="checkbox" + checked={memberExportOptions.exportAvatars} + onChange={e => setMemberExportOptions(prev => ({ ...prev, exportAvatars: e.target.checked }))} + /> + <span>导出头像</span> + </label> + </div> + <label className="member-export-field"> + <span>显示名称规则</span> + <select + value={memberExportOptions.displayNamePreference} + onChange={e => setMemberExportOptions(prev => ({ ...prev, displayNamePreference: e.target.value as MemberMessageExportOptions['displayNamePreference'] }))} + > + <option value="group-nickname">群昵称优先</option> + <option value="remark">备注优先</option> + <option value="nickname">微信昵称</option> + </select> + </label> + </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' && ( <div className="rankings-list"> {rankings.map((item, index) => ( diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 30cbd6c..6c4136c 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -511,12 +511,15 @@ export interface ElectronAPI { } 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 + senderUsername?: string + fileNameSuffix?: string exportMedia?: boolean exportAvatars?: boolean exportImages?: boolean exportVoices?: boolean + exportVideos?: boolean exportEmojis?: boolean exportVoiceAsText?: boolean excelCompactColumns?: boolean From 804a65f52b2d15482b69fe058ef28667b49c974c Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Thu, 19 Feb 2026 17:54:55 +0800 Subject: [PATCH 10/12] =?UTF-8?q?=E5=8D=95=E4=B8=AA=E5=A5=BD=E5=8F=8B?= =?UTF-8?q?=E5=AF=BC=E5=87=BAui=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 59 ++++- src/pages/GroupAnalyticsPage.scss | 249 +++++++++++++++++--- src/pages/GroupAnalyticsPage.tsx | 365 +++++++++++++++++++++-------- 3 files changed, 537 insertions(+), 136 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 6a8a177..be1a653 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1335,6 +1335,55 @@ class ExportService { 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 '' + } + /** * 导出媒体文件到指定目录 */ @@ -4343,6 +4392,8 @@ class ExportService { } } + const linkCard = this.extractHtmlLinkCard(msg.content, msg.localType) + let mediaHtml = '' if (mediaItem?.kind === 'image') { const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath)) @@ -4357,9 +4408,11 @@ class ExportService { mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></video>` } - const textHtml = textContent - ? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>` - : '' + 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>` + : '') const senderNameHtml = isGroup ? `<div class="sender-name">${this.escapeHtml(senderName)}</div>` : '' diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index 7dfc40f..b9cd651 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -802,21 +802,180 @@ display: flex; flex-direction: column; gap: 6px; + position: relative; - span { + > span { font-size: 12px; color: var(--text-secondary); } + } - select { - width: 100%; - border: 1px solid var(--border-color); - border-radius: 10px; + .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); - color: var(--text-primary); - font-size: 13px; - padding: 8px 10px; + } + + &.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); + } } } @@ -866,36 +1025,62 @@ gap: 12px; } - .member-export-switch { + .member-export-chip-group { display: flex; - align-items: center; - justify-content: space-between; - font-size: 13px; - color: var(--text-primary); - - input { - width: 16px; - height: 16px; - cursor: pointer; - } + flex-direction: column; + gap: 8px; } - .member-export-checkboxes { - display: grid; - gap: 10px; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + .chip-group-label { + font-size: 12px; + color: var(--text-secondary); + } - label { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; + .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); + } - input { - width: 16px; - height: 16px; - cursor: pointer; + &.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); } } } diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 6f889ca..05bf1af 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useLocation } from 'react-router-dom' -import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download } 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 ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' @@ -44,6 +44,12 @@ interface MemberMessageExportOptions { displayNamePreference: 'group-nickname' | 'remark' | 'nickname' } +interface MemberExportFormatOption { + value: MemberExportFormat + label: string + desc: string +} + function GroupAnalyticsPage() { const location = useLocation() const [groups, setGroups] = useState<GroupChatInfo[]>([]) @@ -78,6 +84,13 @@ function GroupAnalyticsPage() { // 成员详情弹框 const [selectedMember, setSelectedMember] = useState<GroupMember | 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>('') @@ -102,15 +115,50 @@ function GroupAnalyticsPage() { .filter(Boolean) }, [location.state]) - const memberExportFormatOptions = useMemo<Array<{ value: MemberExportFormat; label: string }>>(() => ([ - { value: 'excel', label: 'Excel' }, - { value: 'txt', label: 'TXT' }, - { value: 'json', label: 'JSON' }, - { value: 'chatlab', label: 'ChatLab' }, - { value: 'chatlab-jsonl', label: 'ChatLab JSONL' }, - { value: 'html', label: 'HTML' }, - { value: 'weclone', label: 'WeClone CSV' } + 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 { @@ -169,6 +217,23 @@ function GroupAnalyticsPage() { } }, [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(() => { if (preselectAppliedRef.current) return if (groups.length === 0 || preselectGroupIds.length === 0) return @@ -232,6 +297,10 @@ function GroupAnalyticsPage() { setSelectedGroup(group) setSelectedFunction(null) setSelectedExportMemberUsername('') + setMemberSearchKeyword('') + setShowMemberSelect(false) + setShowFormatSelect(false) + setShowDisplayNameSelect(false) } } @@ -718,32 +787,100 @@ function GroupAnalyticsPage() { ) : ( <> <div className="member-export-grid"> - <label className="member-export-field"> + <div className="member-export-field" ref={memberSelectDropdownRef}> <span>导出成员</span> - <select - value={selectedExportMemberUsername} - onChange={e => setSelectedExportMemberUsername(e.target.value)} + <button + type="button" + className={`select-trigger ${showMemberSelect ? 'open' : ''}`} + onClick={() => { + setShowMemberSelect(prev => !prev) + setShowFormatSelect(false) + setShowDisplayNameSelect(false) + }} > - {members.map(member => ( - <option key={member.username} value={member.username}> - {member.displayName || member.username} - </option> - ))} - </select> - </label> - <label className="member-export-field"> + <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> - <select - value={memberExportOptions.format} - onChange={e => handleMemberExportFormatChange(e.target.value as MemberExportFormat)} + <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 => ( - <option key={option.value} value={option.value}> - {option.label} - </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> ))} - </select> - </label> + </div> + )} + </div> <div className="member-export-field member-export-folder"> <span>导出目录</span> <div className="member-export-folder-row"> @@ -756,79 +893,105 @@ function GroupAnalyticsPage() { </div> <div className="member-export-options"> - <label className="member-export-switch"> - <span>导出媒体文件</span> - <input - type="checkbox" - checked={memberExportOptions.exportMedia} - onChange={e => setMemberExportOptions(prev => ({ ...prev, exportMedia: e.target.checked }))} - /> - </label> - <div className="member-export-checkboxes"> - <label> - <input - type="checkbox" - checked={memberExportOptions.exportImages} - disabled={!memberExportOptions.exportMedia} - onChange={e => setMemberExportOptions(prev => ({ ...prev, exportImages: e.target.checked }))} - /> - <span>图片</span> - </label> - <label> - <input - type="checkbox" - checked={memberExportOptions.exportVoices} - disabled={!memberExportOptions.exportMedia} - onChange={e => setMemberExportOptions(prev => ({ ...prev, exportVoices: e.target.checked }))} - /> - <span>语音</span> - </label> - <label> - <input - type="checkbox" - checked={memberExportOptions.exportVideos} - disabled={!memberExportOptions.exportMedia} - onChange={e => setMemberExportOptions(prev => ({ ...prev, exportVideos: e.target.checked }))} - /> - <span>视频</span> - </label> - <label> - <input - type="checkbox" - checked={memberExportOptions.exportEmojis} - disabled={!memberExportOptions.exportMedia} - onChange={e => setMemberExportOptions(prev => ({ ...prev, exportEmojis: e.target.checked }))} - /> - <span>表情</span> - </label> - <label> - <input - type="checkbox" - checked={memberExportOptions.exportVoiceAsText} - onChange={e => setMemberExportOptions(prev => ({ ...prev, exportVoiceAsText: e.target.checked }))} - /> - <span>语音转文字</span> - </label> - <label> - <input - type="checkbox" - checked={memberExportOptions.exportAvatars} - onChange={e => setMemberExportOptions(prev => ({ ...prev, exportAvatars: e.target.checked }))} - /> - <span>导出头像</span> - </label> - </div> - <label className="member-export-field"> - <span>显示名称规则</span> - <select - value={memberExportOptions.displayNamePreference} - onChange={e => setMemberExportOptions(prev => ({ ...prev, displayNamePreference: e.target.value as MemberMessageExportOptions['displayNamePreference'] }))} + <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 }))} > - <option value="group-nickname">群昵称优先</option> - <option value="remark">备注优先</option> - <option value="nickname">微信昵称</option> - </select> - </label> + 导出媒体文件 + </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"> From 96ff783bbd1ae05260bb84e723bbb461aef5828a Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Thu, 19 Feb 2026 17:55:01 +0800 Subject: [PATCH 11/12] =?UTF-8?q?html=E5=AF=BC=E5=87=BA=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportHtml.css | 11 +++++++++++ electron/services/exportHtmlStyles.ts | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/electron/services/exportHtml.css b/electron/services/exportHtml.css index c03d459..993e478 100644 --- a/electron/services/exportHtml.css +++ b/electron/services/exportHtml.css @@ -186,6 +186,17 @@ body { 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 { width: 22px; height: 22px; diff --git a/electron/services/exportHtmlStyles.ts b/electron/services/exportHtmlStyles.ts index 42d4e07..935eb49 100644 --- a/electron/services/exportHtmlStyles.ts +++ b/electron/services/exportHtmlStyles.ts @@ -186,6 +186,17 @@ body { 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 { width: 22px; height: 22px; From c4dc266f93e5560c1e133dfe437b72ad5da33308 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Thu, 19 Feb 2026 18:05:37 +0800 Subject: [PATCH 12/12] =?UTF-8?q?=E6=8E=92=E9=99=A4=E5=A5=BD=E5=8F=8B?= =?UTF-8?q?=E9=98=B2=E5=91=86=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 ++-- package.json | 2 +- src/pages/AnalyticsPage.scss | 8 +++++++- src/pages/AnalyticsPage.tsx | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 84cdacd..58bbb42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "weflow", - "version": "2.0.1", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "weflow", - "version": "2.0.1", + "version": "2.1.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.5.0", diff --git a/package.json b/package.json index 155eeb1..8bba440 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "weflow", - "version": "2.0.1", + "version": "2.1.0", "description": "WeFlow", "main": "dist-electron/main.js", "author": "cc", diff --git a/src/pages/AnalyticsPage.scss b/src/pages/AnalyticsPage.scss index 81102df..73d7ca9 100644 --- a/src/pages/AnalyticsPage.scss +++ b/src/pages/AnalyticsPage.scss @@ -45,6 +45,12 @@ font-weight: 600; color: var(--primary); } + + .error-actions { + display: flex; + align-items: center; + gap: 8px; + } } .page-header { @@ -521,4 +527,4 @@ display: flex; gap: 8px; } -} \ No newline at end of file +} diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index df8a6c7..1557679 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -108,6 +108,7 @@ function AnalyticsPage() { }, [loadExcludedUsernames]) const handleRefresh = () => loadData(true) + const isNoSessionError = error?.includes('未找到消息会话') ?? false const loadExcludeCandidates = useCallback(async () => { 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 .filter((candidate) => { 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) { return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}>重试</button></div>) }