diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index ad35ec8..87dc4e4 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1477,6 +1477,87 @@ class ExportService { return result } + /** + * 导出头像为外部文件(仅用于HTML格式) + * 将头像保存到 avatars/ 子目录,返回相对路径 + */ + private async exportAvatarsToFiles( + members: Array<{ username: string; avatarUrl?: string }>, + outputDir: string + ): Promise> { + const result = new Map() + if (members.length === 0) return result + + // 创建 avatars 子目录 + const avatarsDir = path.join(outputDir, 'avatars') + if (!fs.existsSync(avatarsDir)) { + fs.mkdirSync(avatarsDir, { recursive: true }) + } + + for (const member of members) { + const fileInfo = this.resolveAvatarFile(member.avatarUrl) + if (!fileInfo) continue + try { + let data: Buffer | null = null + let mime = fileInfo.mime + if (fileInfo.data) { + data = fileInfo.data + } else if (fileInfo.sourcePath && fs.existsSync(fileInfo.sourcePath)) { + data = await fs.promises.readFile(fileInfo.sourcePath) + } else if (fileInfo.sourceUrl) { + const downloaded = await this.downloadToBuffer(fileInfo.sourceUrl) + if (downloaded) { + data = downloaded.data + mime = downloaded.mime || mime + } + } + if (!data) continue + + // 优先使用内容检测出的 MIME 类型 + const detectedMime = this.detectMimeType(data) + const finalMime = detectedMime || mime || this.inferImageMime(fileInfo.ext) + + // 根据 MIME 类型确定文件扩展名 + const ext = this.getExtensionFromMime(finalMime) + + // 清理用户名作为文件名(移除非法字符,限制长度) + const sanitizedUsername = member.username + .replace(/[<>:"/\\|?*@]/g, '_') + .substring(0, 100) + + const filename = `${sanitizedUsername}${ext}` + const avatarPath = path.join(avatarsDir, filename) + + // 保存头像文件 + await fs.promises.writeFile(avatarPath, data) + + // 返回相对路径 + result.set(member.username, `avatars/${filename}`) + } catch { + continue + } + } + + return result + } + + private getExtensionFromMime(mime: string): string { + switch (mime) { + case 'image/png': + return '.png' + case 'image/gif': + return '.gif' + case 'image/webp': + return '.webp' + case 'image/bmp': + return '.bmp' + case 'image/jpeg': + default: + return '.jpg' + } + } + + private detectMimeType(buffer: Buffer): string | null { if (buffer.length < 4) return null @@ -2772,7 +2853,7 @@ class ExportService { } const avatarMap = options.exportAvatars - ? await this.exportAvatars( + ? await this.exportAvatarsToFiles( [ ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ username, @@ -2780,7 +2861,8 @@ class ExportService { })), { username: sessionId, avatarUrl: sessionInfo.avatarUrl }, { username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl } - ] + ], + path.dirname(outputPath) ) : new Map() @@ -2797,7 +2879,7 @@ class ExportService { : (sessionInfo.displayName || sessionId)) const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername) const avatarHtml = avatarData - ? `${this.escapeAttribute(senderName)}` + ? `${this.escapeAttribute(senderName)}` : `${this.escapeHtml(this.getAvatarFallback(senderName))}` const timeText = this.formatTimestamp(msg.createTime) diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 889efdf..3e09181 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -16,6 +16,10 @@ export interface GroupMember { username: string displayName: string avatarUrl?: string + nickname?: string + alias?: string + remark?: string + groupNickname?: string } export interface GroupMessageRank { @@ -211,14 +215,55 @@ class GroupAnalyticsService { } const members = result.members as { username: string; avatarUrl?: string }[] - const usernames = members.map((m) => m.username) - const displayNames = await wcdbService.getDisplayNames(usernames) + const usernames = members.map((m) => m.username).filter(Boolean) - const data: GroupMember[] = members.map((m) => ({ - username: m.username, - displayName: displayNames.success && displayNames.map ? (displayNames.map[m.username] || m.username) : m.username, - avatarUrl: m.avatarUrl - })) + const [displayNames, groupNicknames] = await Promise.all([ + wcdbService.getDisplayNames(usernames), + this.getGroupNicknamesForRoom(chatroomId) + ]) + + const contactMap = new Map() + const concurrency = 6 + await this.parallelLimit(usernames, concurrency, async (username) => { + const contactResult = await wcdbService.getContact(username) + if (contactResult.success && contactResult.contact) { + const contact = contactResult.contact as any + contactMap.set(username, { + remark: contact.remark || '', + nickName: contact.nickName || contact.nick_name || '', + alias: contact.alias || '' + }) + } else { + contactMap.set(username, { remark: '', nickName: '', alias: '' }) + } + }) + + const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '') + const data: GroupMember[] = members.map((m) => { + const wxid = m.username || '' + const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid + const contact = contactMap.get(wxid) + const nickname = contact?.nickName || '' + const remark = contact?.remark || '' + const alias = contact?.alias || '' + const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || '' + const normalizedWxid = this.cleanAccountDirName(wxid) + const groupNickname = this.normalizeGroupNickname( + rawGroupNickname, + normalizedWxid === myWxid ? myWxid : wxid, + '' + ) + + return { + username: wxid, + displayName, + nickname, + alias, + remark, + groupNickname, + avatarUrl: m.avatarUrl + } + }) return { success: true, data } } catch (e) { diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index c7e6a36..c37f0f4 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -16,6 +16,10 @@ interface GroupMember { username: string displayName: string avatarUrl?: string + nickname?: string + alias?: string + remark?: string + groupNickname?: string } interface GroupMessageRank { @@ -298,6 +302,10 @@ function GroupAnalyticsPage() { const renderMemberModal = () => { if (!selectedMember) return null + const nickname = (selectedMember.nickname || '').trim() + const alias = (selectedMember.alias || '').trim() + const remark = (selectedMember.remark || '').trim() + const groupNickname = (selectedMember.groupNickname || '').trim() return (
setSelectedMember(null)}> @@ -320,11 +328,40 @@ function GroupAnalyticsPage() {
昵称 - {selectedMember.displayName} - + {nickname || '未设置'} + {nickname && ( + + )}
+ {alias && ( +
+ 微信号 + {alias} + +
+ )} + {groupNickname && ( +
+ 群昵称 + {groupNickname} + +
+ )} + {remark && ( +
+ 备注 + {remark} + +
+ )} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 16ede9f..aeff9dd 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -220,6 +220,10 @@ export interface ElectronAPI { username: string displayName: string avatarUrl?: string + nickname?: string + alias?: string + remark?: string + groupNickname?: string }> error?: string }>