mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev
This commit is contained in:
@@ -1477,6 +1477,87 @@ class ExportService {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出头像为外部文件(仅用于HTML格式)
|
||||||
|
* 将头像保存到 avatars/ 子目录,返回相对路径
|
||||||
|
*/
|
||||||
|
private async exportAvatarsToFiles(
|
||||||
|
members: Array<{ username: string; avatarUrl?: string }>,
|
||||||
|
outputDir: string
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const result = new Map<string, string>()
|
||||||
|
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 {
|
private detectMimeType(buffer: Buffer): string | null {
|
||||||
if (buffer.length < 4) return null
|
if (buffer.length < 4) return null
|
||||||
|
|
||||||
@@ -2772,7 +2853,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const avatarMap = options.exportAvatars
|
const avatarMap = options.exportAvatars
|
||||||
? await this.exportAvatars(
|
? await this.exportAvatarsToFiles(
|
||||||
[
|
[
|
||||||
...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
|
...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
|
||||||
username,
|
username,
|
||||||
@@ -2780,7 +2861,8 @@ class ExportService {
|
|||||||
})),
|
})),
|
||||||
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
|
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
|
||||||
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
|
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
|
||||||
]
|
],
|
||||||
|
path.dirname(outputPath)
|
||||||
)
|
)
|
||||||
: new Map<string, string>()
|
: new Map<string, string>()
|
||||||
|
|
||||||
@@ -2797,7 +2879,7 @@ class ExportService {
|
|||||||
: (sessionInfo.displayName || sessionId))
|
: (sessionInfo.displayName || sessionId))
|
||||||
const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername)
|
const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername)
|
||||||
const avatarHtml = avatarData
|
const avatarHtml = avatarData
|
||||||
? `<img src="${this.escapeAttribute(avatarData)}" alt="${this.escapeAttribute(senderName)}" />`
|
? `<img src="${this.escapeAttribute(encodeURI(avatarData))}" alt="${this.escapeAttribute(senderName)}" />`
|
||||||
: `<span>${this.escapeHtml(this.getAvatarFallback(senderName))}</span>`
|
: `<span>${this.escapeHtml(this.getAvatarFallback(senderName))}</span>`
|
||||||
|
|
||||||
const timeText = this.formatTimestamp(msg.createTime)
|
const timeText = this.formatTimestamp(msg.createTime)
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export interface GroupMember {
|
|||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
nickname?: string
|
||||||
|
alias?: string
|
||||||
|
remark?: string
|
||||||
|
groupNickname?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupMessageRank {
|
export interface GroupMessageRank {
|
||||||
@@ -211,14 +215,55 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const members = result.members as { username: string; avatarUrl?: string }[]
|
const members = result.members as { username: string; avatarUrl?: string }[]
|
||||||
const usernames = members.map((m) => m.username)
|
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||||
const displayNames = await wcdbService.getDisplayNames(usernames)
|
|
||||||
|
|
||||||
const data: GroupMember[] = members.map((m) => ({
|
const [displayNames, groupNicknames] = await Promise.all([
|
||||||
username: m.username,
|
wcdbService.getDisplayNames(usernames),
|
||||||
displayName: displayNames.success && displayNames.map ? (displayNames.map[m.username] || m.username) : m.username,
|
this.getGroupNicknamesForRoom(chatroomId)
|
||||||
|
])
|
||||||
|
|
||||||
|
const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>()
|
||||||
|
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
|
avatarUrl: m.avatarUrl
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return { success: true, data }
|
return { success: true, data }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ interface GroupMember {
|
|||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
nickname?: string
|
||||||
|
alias?: string
|
||||||
|
remark?: string
|
||||||
|
groupNickname?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroupMessageRank {
|
interface GroupMessageRank {
|
||||||
@@ -298,6 +302,10 @@ function GroupAnalyticsPage() {
|
|||||||
|
|
||||||
const renderMemberModal = () => {
|
const renderMemberModal = () => {
|
||||||
if (!selectedMember) return null
|
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 (
|
return (
|
||||||
<div className="member-modal-overlay" onClick={() => setSelectedMember(null)}>
|
<div className="member-modal-overlay" onClick={() => setSelectedMember(null)}>
|
||||||
@@ -320,11 +328,40 @@ function GroupAnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="detail-row">
|
<div className="detail-row">
|
||||||
<span className="detail-label">昵称</span>
|
<span className="detail-label">昵称</span>
|
||||||
<span className="detail-value">{selectedMember.displayName}</span>
|
<span className="detail-value">{nickname || '未设置'}</span>
|
||||||
<button className="copy-btn" onClick={() => handleCopy(selectedMember.displayName, 'displayName')}>
|
{nickname && (
|
||||||
{copiedField === 'displayName' ? <Check size={14} /> : <Copy size={14} />}
|
<button className="copy-btn" onClick={() => handleCopy(nickname, 'nickname')}>
|
||||||
|
{copiedField === 'nickname' ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{alias && (
|
||||||
|
<div className="detail-row">
|
||||||
|
<span className="detail-label">微信号</span>
|
||||||
|
<span className="detail-value">{alias}</span>
|
||||||
|
<button className="copy-btn" onClick={() => handleCopy(alias, 'alias')}>
|
||||||
|
{copiedField === 'alias' ? <Check size={14} /> : <Copy size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{groupNickname && (
|
||||||
|
<div className="detail-row">
|
||||||
|
<span className="detail-label">群昵称</span>
|
||||||
|
<span className="detail-value">{groupNickname}</span>
|
||||||
|
<button className="copy-btn" onClick={() => handleCopy(groupNickname, 'groupNickname')}>
|
||||||
|
{copiedField === 'groupNickname' ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{remark && (
|
||||||
|
<div className="detail-row">
|
||||||
|
<span className="detail-label">备注</span>
|
||||||
|
<span className="detail-value">{remark}</span>
|
||||||
|
<button className="copy-btn" onClick={() => handleCopy(remark, 'remark')}>
|
||||||
|
{copiedField === 'remark' ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
4
src/types/electron.d.ts
vendored
4
src/types/electron.d.ts
vendored
@@ -220,6 +220,10 @@ export interface ElectronAPI {
|
|||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
nickname?: string
|
||||||
|
alias?: string
|
||||||
|
remark?: string
|
||||||
|
groupNickname?: string
|
||||||
}>
|
}>
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
|||||||
Reference in New Issue
Block a user