Merge pull request #145 from xunchahaha:dev

Dev
This commit is contained in:
xuncha
2026-01-31 15:40:39 +08:00
committed by GitHub
13 changed files with 572 additions and 69 deletions

View File

@@ -906,6 +906,10 @@ function registerIpcHandlers() {
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime) return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
}) })
ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => {
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
})
// 打开协议窗口 // 打开协议窗口
ipcMain.handle('window:openAgreementWindow', async () => { ipcMain.handle('window:openAgreementWindow', async () => {
createAgreementWindow() createAgreementWindow()

View File

@@ -183,7 +183,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId), getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime), getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime), getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime) getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath)
}, },
// 年度报告 // 年度报告

View File

@@ -1,5 +1,9 @@
import * as fs from 'fs'
import * as path from 'path'
import ExcelJS from 'exceljs'
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { chatService } from './chatService'
export interface GroupChatInfo { export interface GroupChatInfo {
username: string username: string
@@ -41,6 +45,30 @@ class GroupAnalyticsService {
this.configService = new ConfigService() this.configService = new ConfigService()
} }
// 并发控制:限制同时执行的 Promise 数量
private async parallelLimit<T, R>(
items: T[],
limit: number,
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length)
let currentIndex = 0
async function runNext(): Promise<void> {
while (currentIndex < items.length) {
const index = currentIndex++
results[index] = await fn(items[index], index)
}
}
const workers = Array(Math.min(limit, items.length))
.fill(null)
.map(() => runNext())
await Promise.all(workers)
return results
}
private cleanAccountDirName(name: string): string { private cleanAccountDirName(name: string): string {
const trimmed = name.trim() const trimmed = name.trim()
if (!trimmed) return trimmed if (!trimmed) return trimmed
@@ -65,6 +93,139 @@ class GroupAnalyticsService {
return { success: true } return { success: true }
} }
private looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
}
private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
/**
* 解析 ext_buffer 二进制数据,提取群成员的群昵称
*/
private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map<string, string> {
const nicknameMap = new Map<string, string>()
try {
const raw = buffer.toString('utf8')
const wxidPattern = /wxid_[a-z0-9_]+/gi
const wxids = raw.match(wxidPattern) || []
for (const wxid of wxids) {
const wxidLower = wxid.toLowerCase()
const wxidIndex = raw.toLowerCase().indexOf(wxidLower)
if (wxidIndex === -1) continue
const afterWxid = raw.slice(wxidIndex + wxid.length)
let nickname = ''
let foundStart = false
for (let i = 0; i < afterWxid.length && i < 100; i++) {
const char = afterWxid[i]
const code = char.charCodeAt(0)
const isPrintable = (
(code >= 0x4E00 && code <= 0x9FFF) ||
(code >= 0x3000 && code <= 0x303F) ||
(code >= 0xFF00 && code <= 0xFFEF) ||
(code >= 0x20 && code <= 0x7E)
)
if (isPrintable && code !== 0x01 && code !== 0x18) {
foundStart = true
nickname += char
} else if (foundStart) {
break
}
}
nickname = nickname.trim().replace(/[\x00-\x1F\x7F]/g, '')
if (nickname && nickname.length < 50) {
nicknameMap.set(wxidLower, nickname)
}
}
} catch (e) {
console.error('Failed to parse ext_buffer:', e)
}
return nicknameMap
}
/**
* 从 contact.db 的 chat_room 表获取群成员的群昵称
*/
private async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
try {
const sql = `SELECT ext_buffer FROM chat_room WHERE username = '${chatroomId.replace(/'/g, "''")}'`
const result = await wcdbService.execQuery('contact', null, sql)
if (!result.success || !result.rows || result.rows.length === 0) {
return new Map<string, string>()
}
let extBuffer = result.rows[0].ext_buffer
if (typeof extBuffer === 'string') {
if (this.looksLikeHex(extBuffer)) {
extBuffer = Buffer.from(extBuffer, 'hex')
} else if (this.looksLikeBase64(extBuffer)) {
extBuffer = Buffer.from(extBuffer, 'base64')
} else {
try {
extBuffer = Buffer.from(extBuffer, 'hex')
} catch {
extBuffer = Buffer.from(extBuffer, 'base64')
}
}
}
if (!extBuffer || !Buffer.isBuffer(extBuffer)) {
return new Map<string, string>()
}
return this.parseGroupNicknamesFromExtBuffer(extBuffer)
} catch (e) {
console.error('getGroupNicknamesForRoom error:', e)
return new Map<string, string>()
}
}
private escapeCsvValue(value: string): string {
if (value == null) return ''
const str = String(value)
if (/[",\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
private normalizeGroupNickname(value: string, wxid: string, fallback: string): string {
const trimmed = (value || '').trim()
if (!trimmed) return fallback
if (/^["'@]+$/.test(trimmed)) return fallback
if (trimmed.toLowerCase() === (wxid || '').toLowerCase()) return fallback
return trimmed
}
private sanitizeWorksheetName(name: string): string {
const cleaned = (name || '').replace(/[*?:\\/\\[\\]]/g, '_').trim()
const limited = cleaned.slice(0, 31)
return limited || 'Sheet1'
}
private formatDateTime(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0')
const year = date.getFullYear()
const month = pad(date.getMonth() + 1)
const day = pad(date.getDate())
const hour = pad(date.getHours())
const minute = pad(date.getMinutes())
const second = pad(date.getSeconds())
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
}
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> { async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
try { try {
const conn = await this.ensureConnected() const conn = await this.ensureConnected()
@@ -80,23 +241,38 @@ class GroupAnalyticsService {
.map((row) => row.username || row.user_name || row.userName || '') .map((row) => row.username || row.user_name || row.userName || '')
.filter((username) => username.includes('@chatroom')) .filter((username) => username.includes('@chatroom'))
const [displayNames, avatarUrls, memberCounts] = await Promise.all([ const [memberCounts, contactInfo] = await Promise.all([
wcdbService.getDisplayNames(groupIds), wcdbService.getGroupMemberCounts(groupIds),
wcdbService.getAvatarUrls(groupIds), chatService.enrichSessionsContactInfo(groupIds)
wcdbService.getGroupMemberCounts(groupIds)
]) ])
let fallbackNames: { success: boolean; map?: Record<string, string> } | null = null
let fallbackAvatars: { success: boolean; map?: Record<string, string> } | null = null
if (!contactInfo.success || !contactInfo.contacts) {
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(groupIds),
wcdbService.getAvatarUrls(groupIds)
])
fallbackNames = displayNames
fallbackAvatars = avatarUrls
}
const groups: GroupChatInfo[] = [] const groups: GroupChatInfo[] = []
for (const groupId of groupIds) { for (const groupId of groupIds) {
const contact = contactInfo.success && contactInfo.contacts ? contactInfo.contacts[groupId] : undefined
const displayName = contact?.displayName ||
(fallbackNames && fallbackNames.success && fallbackNames.map ? (fallbackNames.map[groupId] || '') : '') ||
groupId
const avatarUrl = contact?.avatarUrl ||
(fallbackAvatars && fallbackAvatars.success && fallbackAvatars.map ? fallbackAvatars.map[groupId] : undefined)
groups.push({ groups.push({
username: groupId, username: groupId,
displayName: displayNames.success && displayNames.map displayName,
? (displayNames.map[groupId] || groupId)
: groupId,
memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number' memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number'
? memberCounts.map[groupId] ? memberCounts.map[groupId]
: 0, : 0,
avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[groupId] : undefined avatarUrl
}) })
} }
@@ -248,6 +424,187 @@ class GroupAnalyticsService {
return { success: false, error: String(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()
if (!conn.success) return { success: false, error: conn.error }
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([chatroomId])
const groupName = groupDisplay.success && groupDisplay.map
? (groupDisplay.map[chatroomId] || chatroomId)
: chatroomId
const groupContact = await wcdbService.getContact(chatroomId)
const sessionRemark = (groupContact.success && groupContact.contact)
? (groupContact.contact.remark || '')
: ''
const membersResult = await wcdbService.getGroupMembers(chatroomId)
if (!membersResult.success || !membersResult.members) {
return { success: false, error: membersResult.error || '获取群成员失败' }
}
const members = membersResult.members as { username: string; avatarUrl?: string }[]
if (members.length === 0) {
return { success: false, error: '群成员为空' }
}
const usernames = members.map((m) => m.username).filter(Boolean)
const [displayNames, groupNicknames] = await Promise.all([
wcdbService.getDisplayNames(usernames),
this.getGroupNicknamesForRoom(chatroomId)
])
const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>()
const concurrency = 6
await this.parallelLimit(usernames, concurrency, async (username) => {
const result = await wcdbService.getContact(username)
if (result.success && result.contact) {
const contact = result.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 infoTitleRow = ['会话信息']
const infoRow = ['微信ID', chatroomId, '', '昵称', groupName, '备注', sessionRemark || '', '']
const metaRow = ['导出工具', exportGenerator, '导出版本', exportVersion, '平台', exportPlatform, '导出时间', exportTime]
const header = ['微信昵称', '微信备注', '群昵称', 'wxid', '微信号']
const rows: string[][] = [infoTitleRow, infoRow, metaRow, header]
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
for (const member of members) {
const wxid = member.username
const normalizedWxid = this.cleanAccountDirName(wxid || '')
const contact = contactMap.get(wxid)
const fallbackName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || '') : ''
const nickName = contact?.nickName || fallbackName || ''
const remark = contact?.remark || ''
const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || ''
const alias = contact?.alias || ''
const groupNickname = this.normalizeGroupNickname(
rawGroupNickname,
normalizedWxid === myWxid ? myWxid : wxid,
''
)
rows.push([nickName, remark, groupNickname, wxid, alias])
}
const ext = path.extname(outputPath).toLowerCase()
if (ext === '.csv') {
const csvLines = rows.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 sheet = workbook.addWorksheet(this.sanitizeWorksheetName('群成员列表'))
let currentRow = 1
const titleCell = sheet.getCell(currentRow, 1)
titleCell.value = '会话信息'
titleCell.font = { name: 'Calibri', bold: true, size: 11 }
titleCell.alignment = { vertical: 'middle', horizontal: 'left' }
sheet.getRow(currentRow).height = 25
currentRow++
sheet.getCell(currentRow, 1).value = '微信ID'
sheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
sheet.mergeCells(currentRow, 2, currentRow, 3)
sheet.getCell(currentRow, 2).value = chatroomId
sheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 11 }
sheet.getCell(currentRow, 4).value = '昵称'
sheet.getCell(currentRow, 4).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 5).value = groupName
sheet.getCell(currentRow, 5).font = { name: 'Calibri', size: 11 }
sheet.getCell(currentRow, 6).value = '备注'
sheet.getCell(currentRow, 6).font = { name: 'Calibri', bold: true, size: 11 }
sheet.mergeCells(currentRow, 7, currentRow, 8)
sheet.getCell(currentRow, 7).value = sessionRemark
sheet.getCell(currentRow, 7).font = { name: 'Calibri', size: 11 }
sheet.getRow(currentRow).height = 20
currentRow++
sheet.getCell(currentRow, 1).value = '导出工具'
sheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 2).value = exportGenerator
sheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 10 }
sheet.getCell(currentRow, 3).value = '导出版本'
sheet.getCell(currentRow, 3).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 4).value = exportVersion
sheet.getCell(currentRow, 4).font = { name: 'Calibri', size: 10 }
sheet.getCell(currentRow, 5).value = '平台'
sheet.getCell(currentRow, 5).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 6).value = exportPlatform
sheet.getCell(currentRow, 6).font = { name: 'Calibri', size: 10 }
sheet.getCell(currentRow, 7).value = '导出时间'
sheet.getCell(currentRow, 7).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 8).value = exportTime
sheet.getCell(currentRow, 8).font = { name: 'Calibri', size: 10 }
sheet.getRow(currentRow).height = 20
currentRow++
const headerRow = sheet.getRow(currentRow)
headerRow.height = 22
header.forEach((text, index) => {
const cell = headerRow.getCell(index + 1)
cell.value = text
cell.font = { name: 'Calibri', bold: true, size: 11 }
})
currentRow++
sheet.getColumn(1).width = 28
sheet.getColumn(2).width = 28
sheet.getColumn(3).width = 28
sheet.getColumn(4).width = 36
sheet.getColumn(5).width = 28
sheet.getColumn(6).width = 18
sheet.getColumn(7).width = 24
sheet.getColumn(8).width = 22
for (let i = 4; i < rows.length; i++) {
const [nickName, remark, groupNickname, wxid, alias] = rows[i]
const row = sheet.getRow(currentRow)
row.getCell(1).value = nickName
row.getCell(2).value = remark
row.getCell(3).value = groupNickname
row.getCell(4).value = wxid
row.getCell(5).value = alias
row.alignment = { vertical: 'top', wrapText: true }
currentRow++
}
await workbook.xlsx.writeFile(outputPath)
}
return { success: true, count: members.length }
} catch (e) {
return { success: false, error: String(e) }
}
}
} }
export const groupAnalyticsService = new GroupAnalyticsService() export const groupAnalyticsService = new GroupAnalyticsService()

View File

@@ -23,7 +23,7 @@ export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
return return
} }
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload) => { const removeListener = window.electronAPI.whisper.onDownloadProgress((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => {
if (payload.percent !== undefined) { if (payload.percent !== undefined) {
setDownloadProgress(payload.percent) setDownloadProgress(payload.percent)
} }

View File

@@ -491,7 +491,11 @@ function ChatPage(_props: ChatPageProps) {
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
const dllStart = performance.now() const dllStart = performance.now()
const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) as {
success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string
}
const dllTime = performance.now() - dllStart const dllTime = performance.now() - dllStart
// DLL 调用后再次让出控制权 // DLL 调用后再次让出控制权
@@ -504,7 +508,8 @@ function ChatPage(_props: ChatPageProps) {
if (result.success && result.contacts) { if (result.success && result.contacts) {
// 将更新加入队列,用于侧边栏更新 // 将更新加入队列,用于侧边栏更新
for (const [username, contact] of Object.entries(result.contacts)) { const contacts = result.contacts || {}
for (const [username, contact] of Object.entries(contacts)) {
contactUpdateQueueRef.current.set(username, contact) contactUpdateQueueRef.current.set(username, contact)
// 如果是自己的信息且当前个人头像为空,同步更新 // 如果是自己的信息且当前个人头像为空,同步更新
@@ -545,7 +550,11 @@ function ChatPage(_props: ChatPageProps) {
setIsRefreshingMessages(true) setIsRefreshingMessages(true)
try { try {
// 获取最新消息并增量添加 // 获取最新消息并增量添加
const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50) const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50) as {
success: boolean;
messages?: Message[];
error?: string
}
if (!result.success || !result.messages) { if (!result.success || !result.messages) {
return return
} }
@@ -593,7 +602,12 @@ function ChatPage(_props: ChatPageProps) {
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
try { try {
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime) const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime) as {
success: boolean;
messages?: Message[];
hasMore?: boolean;
error?: string
}
if (result.success && result.messages) { if (result.success && result.messages) {
if (offset === 0) { if (offset === 0) {
setMessages(result.messages) setMessages(result.messages)
@@ -690,7 +704,12 @@ function ChatPage(_props: ChatPageProps) {
try { try {
const lastMsg = messages[messages.length - 1] const lastMsg = messages[messages.length - 1]
// 从最后一条消息的时间开始往后找 // 从最后一条消息的时间开始往后找
const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true) const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true) as {
success: boolean;
messages?: Message[];
hasMore?: boolean;
error?: string
}
if (result.success && result.messages) { if (result.success && result.messages) {
// 过滤掉已经在列表中的重复消息 // 过滤掉已经在列表中的重复消息
@@ -1501,6 +1520,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const imageClickTimerRef = useRef<number | null>(null) const imageClickTimerRef = useRef<number | null>(null)
const imageContainerRef = useRef<HTMLDivElement>(null) const imageContainerRef = useRef<HTMLDivElement>(null)
const imageAutoDecryptTriggered = useRef(false) const imageAutoDecryptTriggered = useRef(false)
const imageAutoHdTriggered = useRef<string | null>(null)
const [imageInView, setImageInView] = useState(false)
const imageForceHdAttempted = useRef<string | null>(null)
const imageForceHdPending = useRef(false)
const [voiceError, setVoiceError] = useState(false) const [voiceError, setVoiceError] = useState(false)
const [voiceLoading, setVoiceLoading] = useState(false) const [voiceLoading, setVoiceLoading] = useState(false)
const [isVoicePlaying, setIsVoicePlaying] = useState(false) const [isVoicePlaying, setIsVoicePlaying] = useState(false)
@@ -1551,7 +1574,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const contentToUse = message.content || (message as any).rawContent || message.parsedContent const contentToUse = message.content || (message as any).rawContent || message.parsedContent
if (contentToUse) { if (contentToUse) {
console.log('[Video Debug] Parsing MD5 from content, length:', contentToUse.length) console.log('[Video Debug] Parsing MD5 from content, length:', contentToUse.length)
window.electronAPI.video.parseVideoMd5(contentToUse).then((result) => { window.electronAPI.video.parseVideoMd5(contentToUse).then((result: { success: boolean; md5?: string; error?: string }) => {
console.log('[Video Debug] Parse result:', result) console.log('[Video Debug] Parse result:', result)
if (result && result.success && result.md5) { if (result && result.success && result.md5) {
console.log('[Video Debug] Parsed MD5:', result.md5) console.log('[Video Debug] Parsed MD5:', result.md5)
@@ -1559,7 +1582,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
} else { } else {
console.error('[Video Debug] Failed to parse MD5:', result) console.error('[Video Debug] Failed to parse MD5:', result)
} }
}).catch((err) => { }).catch((err: unknown) => {
console.error('[Video Debug] Parse error:', err) console.error('[Video Debug] Parse error:', err)
}) })
} }
@@ -1667,7 +1690,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
} }
const pending = senderAvatarLoading.get(sender) const pending = senderAvatarLoading.get(sender)
if (pending) { if (pending) {
pending.then((result) => { pending.then((result: { avatarUrl?: string; displayName?: string } | null) => {
if (result) { if (result) {
setSenderAvatarUrl(result.avatarUrl) setSenderAvatarUrl(result.avatarUrl)
setSenderName(result.displayName) setSenderName(result.displayName)
@@ -1697,10 +1720,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
} }
}, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError]) }, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError])
const requestImageDecrypt = useCallback(async (forceUpdate = false) => { const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
if (!isImage || imageLoading) return if (!isImage) return
if (imageLoading) return
if (!silent) {
setImageLoading(true) setImageLoading(true)
setImageError(false) setImageError(false)
}
try { try {
if (message.imageMd5 || message.imageDatName) { if (message.imageMd5 || message.imageDatName) {
const result = await window.electronAPI.image.decrypt({ const result = await window.electronAPI.image.decrypt({
@@ -1726,14 +1752,25 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
setImageHasUpdate(false) setImageHasUpdate(false)
return return
} }
setImageError(true) if (!silent) setImageError(true)
} catch { } catch {
setImageError(true) if (!silent) setImageError(true)
} finally { } finally {
setImageLoading(false) if (!silent) setImageLoading(false)
} }
}, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64]) }, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64])
const triggerForceHd = useCallback(() => {
if (!message.imageMd5 && !message.imageDatName) return
if (imageForceHdAttempted.current === imageCacheKey) return
if (imageForceHdPending.current) return
imageForceHdAttempted.current = imageCacheKey
imageForceHdPending.current = true
requestImageDecrypt(true, true).finally(() => {
imageForceHdPending.current = false
})
}, [imageCacheKey, message.imageDatName, message.imageMd5, requestImageDecrypt])
const handleImageClick = useCallback(() => { const handleImageClick = useCallback(() => {
if (imageClickTimerRef.current) { if (imageClickTimerRef.current) {
window.clearTimeout(imageClickTimerRef.current) window.clearTimeout(imageClickTimerRef.current)
@@ -1769,7 +1806,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
sessionId: session.username, sessionId: session.username,
imageMd5: message.imageMd5 || undefined, imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName imageDatName: message.imageDatName
}).then((result) => { }).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }) => {
if (cancelled) return if (cancelled) return
if (result.success && result.localPath) { if (result.success && result.localPath) {
imageDataUrlCache.set(imageCacheKey, result.localPath) imageDataUrlCache.set(imageCacheKey, result.localPath)
@@ -1787,7 +1824,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
useEffect(() => { useEffect(() => {
if (!isImage) return if (!isImage) return
const unsubscribe = window.electronAPI.image.onUpdateAvailable((payload) => { const unsubscribe = window.electronAPI.image.onUpdateAvailable((payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => {
const matchesCacheKey = const matchesCacheKey =
payload.cacheKey === message.imageMd5 || payload.cacheKey === message.imageMd5 ||
payload.cacheKey === message.imageDatName || payload.cacheKey === message.imageDatName ||
@@ -1804,7 +1841,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
useEffect(() => { useEffect(() => {
if (!isImage) return if (!isImage) return
const unsubscribe = window.electronAPI.image.onCacheResolved((payload) => { const unsubscribe = window.electronAPI.image.onCacheResolved((payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => {
const matchesCacheKey = const matchesCacheKey =
payload.cacheKey === message.imageMd5 || payload.cacheKey === message.imageMd5 ||
payload.cacheKey === message.imageDatName || payload.cacheKey === message.imageDatName ||
@@ -1846,6 +1883,47 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
return () => observer.disconnect() return () => observer.disconnect()
}, [isImage, imageLocalPath, message.imageMd5, message.imageDatName, requestImageDecrypt]) }, [isImage, imageLocalPath, message.imageMd5, message.imageDatName, requestImageDecrypt])
// 进入视野时自动尝试切换高清图
useEffect(() => {
if (!isImage) return
const container = imageContainerRef.current
if (!container) return
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
setImageInView(entry.isIntersecting)
},
{ rootMargin: '120px', threshold: 0 }
)
observer.observe(container)
return () => observer.disconnect()
}, [isImage])
useEffect(() => {
if (!isImage || !imageHasUpdate || !imageInView) return
if (imageAutoHdTriggered.current === imageCacheKey) return
imageAutoHdTriggered.current = imageCacheKey
triggerForceHd()
}, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd])
useEffect(() => {
if (!isImage || !showImagePreview || !imageHasUpdate) return
if (imageAutoHdTriggered.current === imageCacheKey) return
imageAutoHdTriggered.current = imageCacheKey
triggerForceHd()
}, [isImage, showImagePreview, imageHasUpdate, imageCacheKey, triggerForceHd])
// 更激进:进入视野/打开预览时,无论 hasUpdate 与否都尝试强制高清
useEffect(() => {
if (!isImage || !imageInView) return
triggerForceHd()
}, [isImage, imageInView, triggerForceHd])
useEffect(() => {
if (!isImage || !showImagePreview) return
triggerForceHd()
}, [isImage, showImagePreview, triggerForceHd])
useEffect(() => { useEffect(() => {
if (!isVoice) return if (!isVoice) return
@@ -1933,7 +2011,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
useEffect(() => { useEffect(() => {
if (!isVoice || voiceDataUrl) return if (!isVoice || voiceDataUrl) return
window.electronAPI.chat.resolveVoiceCache(session.username, String(message.localId)) window.electronAPI.chat.resolveVoiceCache(session.username, String(message.localId))
.then(result => { .then((result: { success: boolean; hasCache: boolean; data?: string; error?: string }) => {
if (result.success && result.hasCache && result.data) { if (result.success && result.hasCache && result.data) {
const url = `data:audio/wav;base64,${result.data}` const url = `data:audio/wav;base64,${result.data}`
voiceDataUrlCache.set(voiceCacheKey, url) voiceDataUrlCache.set(voiceCacheKey, url)
@@ -2066,7 +2144,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
console.log('[Video Debug] Loading video info for MD5:', videoMd5) console.log('[Video Debug] Loading video info for MD5:', videoMd5)
setVideoLoading(true) setVideoLoading(true)
window.electronAPI.video.getVideoInfo(videoMd5).then((result) => { window.electronAPI.video.getVideoInfo(videoMd5).then((result: { success: boolean; exists: boolean; videoUrl?: string; coverUrl?: string; thumbUrl?: string; error?: string }) => {
console.log('[Video Debug] getVideoInfo result:', result) console.log('[Video Debug] getVideoInfo result:', result)
if (result && result.success) { if (result && result.success) {
setVideoInfo({ setVideoInfo({
@@ -2079,7 +2157,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
console.error('[Video Debug] Video info failed:', result) console.error('[Video Debug] Video info failed:', result)
setVideoInfo({ exists: false }) setVideoInfo({ exists: false })
} }
}).catch((err) => { }).catch((err: unknown) => {
console.error('[Video Debug] getVideoInfo error:', err) console.error('[Video Debug] getVideoInfo error:', err)
setVideoInfo({ exists: false }) setVideoInfo({ exists: false })
}).finally(() => { }).finally(() => {
@@ -2092,7 +2170,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false) const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)
useEffect(() => { useEffect(() => {
window.electronAPI.config.get('autoTranscribeVoice').then((value) => { window.electronAPI.config.get('autoTranscribeVoice').then((value: unknown) => {
setAutoTranscribeEnabled(value === true) setAutoTranscribeEnabled(value === true)
}) })
}, []) }, [])
@@ -2196,23 +2274,15 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
src={imageLocalPath} src={imageLocalPath}
alt="图片" alt="图片"
className="image-message" className="image-message"
onClick={() => setShowImagePreview(true)} onClick={() => {
if (imageHasUpdate) {
void requestImageDecrypt(true, true)
}
setShowImagePreview(true)
}}
onLoad={() => setImageError(false)} onLoad={() => setImageError(false)}
onError={() => setImageError(true)} onError={() => setImageError(true)}
/> />
{imageHasUpdate && (
<button
className="image-update-button"
type="button"
title="发现更高清图片,点击更新"
onClick={(event) => {
event.stopPropagation()
void requestImageDecrypt(true)
}}
>
<RefreshCw size={14} />
</button>
)}
</div> </div>
{showImagePreview && ( {showImagePreview && (
<ImagePreview src={imageLocalPath} onClose={() => setShowImagePreview(false)} /> <ImagePreview src={imageLocalPath} onClose={() => setShowImagePreview(false)} />

View File

@@ -45,18 +45,18 @@ function ContactsPage() {
if (contactsResult.success && contactsResult.contacts) { if (contactsResult.success && contactsResult.contacts) {
console.log('📊 总联系人数:', contactsResult.contacts.length) console.log('📊 总联系人数:', contactsResult.contacts.length)
console.log('📊 按类型统计:', { console.log('📊 按类型统计:', {
friends: contactsResult.contacts.filter(c => c.type === 'friend').length, friends: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'friend').length,
groups: contactsResult.contacts.filter(c => c.type === 'group').length, groups: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'group').length,
officials: contactsResult.contacts.filter(c => c.type === 'official').length, officials: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'official').length,
other: contactsResult.contacts.filter(c => c.type === 'other').length other: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'other').length
}) })
// 获取头像URL // 获取头像URL
const usernames = contactsResult.contacts.map(c => c.username) const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username)
if (usernames.length > 0) { if (usernames.length > 0) {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (avatarResult.success && avatarResult.contacts) { if (avatarResult.success && avatarResult.contacts) {
contactsResult.contacts.forEach(contact => { contactsResult.contacts.forEach((contact: ContactInfo) => {
const enriched = avatarResult.contacts?.[contact.username] const enriched = avatarResult.contacts?.[contact.username]
if (enriched?.avatarUrl) { if (enriched?.avatarUrl) {
contact.avatarUrl = enriched.avatarUrl contact.avatarUrl = enriched.avatarUrl

View File

@@ -189,7 +189,7 @@ function ExportPage() {
}, [loadSessions]) }, [loadSessions])
useEffect(() => { useEffect(() => {
const removeListener = window.electronAPI.export.onProgress?.((payload) => { const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string }) => {
setExportProgress({ setExportProgress({
current: payload.current, current: payload.current,
total: payload.total, total: payload.total,

View File

@@ -333,7 +333,7 @@
.group-avatar { .group-avatar {
width: 44px; width: 44px;
height: 44px; height: 44px;
border-radius: 50%; border-radius: 8px;
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
@@ -346,11 +346,11 @@
.avatar-placeholder { .avatar-placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); background: var(--bg-tertiary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #fff; color: var(--text-secondary);
} }
} }
@@ -390,7 +390,7 @@
.skeleton-avatar { .skeleton-avatar {
width: 44px; width: 44px;
height: 44px; height: 44px;
border-radius: 50%; border-radius: 8px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
animation: pulse 1.5s infinite; animation: pulse 1.5s infinite;
} }
@@ -500,7 +500,7 @@
.group-avatar.large { .group-avatar.large {
width: 80px; width: 80px;
height: 80px; height: 80px;
border-radius: 50%; border-radius: 10px;
overflow: hidden; overflow: hidden;
margin: 0 auto 16px; margin: 0 auto 16px;
@@ -513,11 +513,11 @@
.avatar-placeholder { .avatar-placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); background: var(--bg-tertiary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #fff; color: var(--text-secondary);
} }
} }
@@ -656,6 +656,32 @@
cursor: not-allowed; cursor: not-allowed;
} }
} }
.export-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: none;
background: var(--bg-tertiary);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
-webkit-app-region: no-drag;
font-size: 12px;
flex-shrink: 0;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
} }
.content-body { .content-body {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react' import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker' import DateRangePicker from '../components/DateRangePicker'
@@ -39,6 +39,7 @@ function GroupAnalyticsPage() {
const [activeHours, setActiveHours] = useState<Record<number, number>>({}) const [activeHours, setActiveHours] = useState<Record<number, number>>({})
const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null) const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null)
const [functionLoading, setFunctionLoading] = useState(false) const [functionLoading, setFunctionLoading] = useState(false)
const [isExportingMembers, setIsExportingMembers] = useState(false)
// 成员详情弹框 // 成员详情弹框
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null) const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
@@ -181,6 +182,10 @@ function GroupAnalyticsPage() {
return num.toLocaleString() return num.toLocaleString()
} }
const sanitizeFileName = (name: string) => {
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
}
const getHourlyOption = () => { const getHourlyOption = () => {
const hours = Array.from({ length: 24 }, (_, i) => i) const hours = Array.from({ length: 24 }, (_, i) => i)
const data = hours.map(h => activeHours[h] || 0) const data = hours.map(h => activeHours[h] || 0)
@@ -252,6 +257,35 @@ function GroupAnalyticsPage() {
setCopiedField(null) setCopiedField(null)
} }
const handleExportMembers = async () => {
if (!selectedGroup || isExportingMembers) return
setIsExportingMembers(true)
try {
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
const baseName = sanitizeFileName(`${selectedGroup.displayName || selectedGroup.username}_群成员列表`)
const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/'
const defaultPath = downloadsPath ? `${downloadsPath}${separator}${baseName}.xlsx` : `${baseName}.xlsx`
const saveResult = await window.electronAPI.dialog.saveFile({
title: '导出群成员列表',
defaultPath,
filters: [{ name: 'Excel', extensions: ['xlsx'] }]
})
if (!saveResult || saveResult.canceled || !saveResult.filePath) return
const result = await window.electronAPI.groupAnalytics.exportGroupMembers(selectedGroup.username, saveResult.filePath)
if (result.success) {
alert(`导出成功,共 ${result.count ?? members.length}`)
} else {
alert(`导出失败:${result.error || '未知错误'}`)
}
} catch (e) {
console.error('导出群成员失败:', e)
alert(`导出失败:${String(e)}`)
} finally {
setIsExportingMembers(false)
}
}
const handleCopy = async (text: string, field: string) => { const handleCopy = async (text: string, field: string) => {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
@@ -423,6 +457,12 @@ function GroupAnalyticsPage() {
onRangeComplete={handleDateRangeComplete} onRangeComplete={handleDateRangeComplete}
/> />
)} )}
{selectedFunction === 'members' && (
<button className="export-btn" onClick={handleExportMembers} disabled={functionLoading || isExportingMembers}>
{isExportingMembers ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
<span></span>
</button>
)}
<button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}> <button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}>
<RefreshCw size={16} className={functionLoading ? 'spin' : ''} /> <RefreshCw size={16} className={functionLoading ? 'spin' : ''} />
</button> </button>

View File

@@ -155,10 +155,10 @@ function SettingsPage() {
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect]) }, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message) setDbKeyStatus(payload.message)
}) })
const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => { const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
setImageKeyStatus(payload.message) setImageKeyStatus(payload.message)
}) })
return () => { return () => {
@@ -270,7 +270,7 @@ function SettingsPage() {
}, []) }, [])
useEffect(() => { useEffect(() => {
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload) => { const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => {
if (typeof payload.percent === 'number') { if (typeof payload.percent === 'number') {
setWhisperDownloadProgress(payload.percent) setWhisperDownloadProgress(payload.percent)
} }

View File

@@ -165,8 +165,8 @@ export default function SnsPage() {
scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight; scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight;
} }
const existingIds = new Set(currentPosts.map(p => p.id)); const existingIds = new Set(currentPosts.map((p: SnsPost) => p.id));
const uniqueNewer = result.timeline.filter(p => !existingIds.has(p.id)); const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id));
if (uniqueNewer.length > 0) { if (uniqueNewer.length > 0) {
setPosts(prev => [...uniqueNewer, ...prev]); setPosts(prev => [...uniqueNewer, ...prev]);
@@ -253,7 +253,7 @@ export default function SnsPage() {
})) }))
setContacts(initialContacts) setContacts(initialContacts)
const usernames = initialContacts.map(c => c.username) const usernames = initialContacts.map((c: { username: string }) => c.username)
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (enriched.success && enriched.contacts) { if (enriched.success && enriched.contacts) {
setContacts(prev => prev.map(c => { setContacts(prev => prev.map(c => {

View File

@@ -106,10 +106,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
} }
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message) setDbKeyStatus(payload.message)
}) })
const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => { const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
setImageKeyStatus(payload.message) setImageKeyStatus(payload.message)
}) })
return () => { return () => {

View File

@@ -232,6 +232,11 @@ export interface ElectronAPI {
} }
error?: string error?: string
}> }>
exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{
success: boolean
count?: number
error?: string
}>
} }
annualReport: { annualReport: {
getAvailableYears: () => Promise<{ getAvailableYears: () => Promise<{