From 0cba8e6d894d49c2e02a986be8329ed7edf7b4e3 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 31 Jan 2026 14:26:13 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E7=BE=A4=E6=88=90?= =?UTF-8?q?=E5=91=98=E7=AC=AC=E4=B8=80=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 4 + electron/preload.ts | 3 +- electron/services/groupAnalyticsService.ts | 196 +++++++++++++++++++++ src/pages/GroupAnalyticsPage.scss | 26 +++ src/pages/GroupAnalyticsPage.tsx | 42 ++++- src/types/electron.d.ts | 5 + 6 files changed, 274 insertions(+), 2 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 6f1baea..4a219b8 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -906,6 +906,10 @@ function registerIpcHandlers() { 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 () => { createAgreementWindow() diff --git a/electron/preload.ts b/electron/preload.ts index 1698bf5..c401232 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -183,7 +183,8 @@ contextBridge.exposeInMainWorld('electronAPI', { 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), 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) }, // 年度报告 diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 628e0bb..8b7629b 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs' +import * as fs from 'fs' import { ConfigService } from './config' import { wcdbService } from './wcdbService' @@ -41,6 +43,30 @@ class GroupAnalyticsService { this.configService = new ConfigService() } + // 并发控制:限制同时执行的 Promise 数量 + private async parallelLimit( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise + ): Promise { + const results: R[] = new Array(items.length) + let currentIndex = 0 + + async function runNext(): Promise { + 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 { const trimmed = name.trim() if (!trimmed) return trimmed @@ -65,6 +91,114 @@ class GroupAnalyticsService { 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 { + const nicknameMap = new Map() + + 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> { + 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() + } + + 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() + } + + return this.parseGroupNicknamesFromExtBuffer(extBuffer) + } catch (e) { + console.error('getGroupNicknamesForRoom error:', e) + return new Map() + } + } + + private escapeCsvValue(value: string): string { + if (value == null) return '' + const str = String(value) + if (/[",\n\r]/.test(str)) { + return `"${str.replace(/"/g, '""')}"` + } + return str + } + async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> { try { const conn = await this.ensureConnected() @@ -248,6 +382,68 @@ class GroupAnalyticsService { 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 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() + 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 header = ['微信昵称', '微信备注', '群昵称', 'wxid', '微信号'] + const rows: string[][] = [header] + + for (const member of members) { + const wxid = member.username + const contact = contactMap.get(wxid) + const fallbackName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || '') : '' + const nickName = contact?.nickName || fallbackName || '' + const remark = contact?.remark || '' + const groupNickname = groupNicknames.get(wxid.toLowerCase()) || '' + const alias = contact?.alias || '' + + rows.push([nickName, remark, groupNickname, wxid, alias]) + } + + const csvLines = rows.map((row) => row.map((cell) => this.escapeCsvValue(cell)).join(',')) + const content = '\ufeff' + csvLines.join('\n') + fs.writeFileSync(outputPath, content, 'utf8') + + return { success: true, count: members.length } + } catch (e) { + return { success: false, error: String(e) } + } + } } export const groupAnalyticsService = new GroupAnalyticsService() diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index 7993c7a..dcf6cec 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -656,6 +656,32 @@ 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 { diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 1a8dfe1..0f00ab7 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1,5 +1,5 @@ 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 ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' @@ -39,6 +39,7 @@ function GroupAnalyticsPage() { const [activeHours, setActiveHours] = useState>({}) 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 [selectedMember, setSelectedMember] = useState(null) @@ -181,6 +182,10 @@ function GroupAnalyticsPage() { return num.toLocaleString() } + const sanitizeFileName = (name: string) => { + return name.replace(/[<>:"/\\|?*]+/g, '_').trim() + } + const getHourlyOption = () => { const hours = Array.from({ length: 24 }, (_, i) => i) const data = hours.map(h => activeHours[h] || 0) @@ -252,6 +257,35 @@ function GroupAnalyticsPage() { 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}.csv` : `${baseName}.csv` + const saveResult = await window.electronAPI.dialog.saveFile({ + title: '导出群成员列表', + defaultPath, + filters: [{ name: 'CSV', extensions: ['csv'] }] + }) + 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) => { try { await navigator.clipboard.writeText(text) @@ -423,6 +457,12 @@ function GroupAnalyticsPage() { onRangeComplete={handleDateRangeComplete} /> )} + {selectedFunction === 'members' && ( + + )} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 8866bd9..2e424ea 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -232,6 +232,11 @@ export interface ElectronAPI { } error?: string }> + exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{ + success: boolean + count?: number + error?: string + }> } annualReport: { getAvailableYears: () => Promise<{ From c1145c8f89f1fa9f1f16630e21bf12987e542faf Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 31 Jan 2026 14:58:15 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E7=BE=A4=E6=88=90?= =?UTF-8?q?=E5=91=98=E7=AC=AC=E4=BA=8C=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/groupAnalyticsService.ts | 157 ++++++++++++++++++++- src/pages/GroupAnalyticsPage.tsx | 4 +- 2 files changed, 153 insertions(+), 8 deletions(-) diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 8b7629b..3dd1975 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -1,5 +1,6 @@ import * as fs from 'fs' -import * as fs from 'fs' +import * as path from 'path' +import ExcelJS from 'exceljs' import { ConfigService } from './config' import { wcdbService } from './wcdbService' @@ -199,6 +200,31 @@ class GroupAnalyticsService { 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 }> { try { const conn = await this.ensureConnected() @@ -388,6 +414,22 @@ class GroupAnalyticsService { 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 || '获取群成员失败' } @@ -420,30 +462,133 @@ class GroupAnalyticsService { } }) + const infoTitleRow = ['会话信息'] + const infoRow = ['微信ID', chatroomId, '', '昵称', groupName, '备注', sessionRemark || '', ''] + const metaRow = ['导出工具', exportGenerator, '导出版本', exportVersion, '平台', exportPlatform, '导出时间', exportTime] + const header = ['微信昵称', '微信备注', '群昵称', 'wxid', '微信号'] - const rows: string[][] = [header] + 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 groupNickname = groupNicknames.get(wxid.toLowerCase()) || '' + 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 csvLines = rows.map((row) => row.map((cell) => this.escapeCsvValue(cell)).join(',')) - const content = '\ufeff' + csvLines.join('\n') - fs.writeFileSync(outputPath, content, 'utf8') + 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() diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 0f00ab7..c7e6a36 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -264,11 +264,11 @@ function GroupAnalyticsPage() { 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}.csv` : `${baseName}.csv` + const defaultPath = downloadsPath ? `${downloadsPath}${separator}${baseName}.xlsx` : `${baseName}.xlsx` const saveResult = await window.electronAPI.dialog.saveFile({ title: '导出群成员列表', defaultPath, - filters: [{ name: 'CSV', extensions: ['csv'] }] + filters: [{ name: 'Excel', extensions: ['xlsx'] }] }) if (!saveResult || saveResult.canceled || !saveResult.filePath) return From b8dbc3caf182b415386bdeaeb4d3c32697d93328 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 31 Jan 2026 15:04:54 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E7=BE=A4=E8=81=8A=E5=88=86=E6=9E=90ui?= =?UTF-8?q?=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/groupAnalyticsService.ts | 32 ++++++++++++++++------ src/pages/GroupAnalyticsPage.scss | 14 +++++----- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 3dd1975..bd4e123 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -3,6 +3,7 @@ import * as path from 'path' import ExcelJS from 'exceljs' import { ConfigService } from './config' import { wcdbService } from './wcdbService' +import { chatService } from './chatService' export interface GroupChatInfo { username: string @@ -240,23 +241,38 @@ class GroupAnalyticsService { .map((row) => row.username || row.user_name || row.userName || '') .filter((username) => username.includes('@chatroom')) - const [displayNames, avatarUrls, memberCounts] = await Promise.all([ - wcdbService.getDisplayNames(groupIds), - wcdbService.getAvatarUrls(groupIds), - wcdbService.getGroupMemberCounts(groupIds) + const [memberCounts, contactInfo] = await Promise.all([ + wcdbService.getGroupMemberCounts(groupIds), + chatService.enrichSessionsContactInfo(groupIds) ]) + let fallbackNames: { success: boolean; map?: Record } | null = null + let fallbackAvatars: { success: boolean; map?: Record } | 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[] = [] 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({ username: groupId, - displayName: displayNames.success && displayNames.map - ? (displayNames.map[groupId] || groupId) - : groupId, + displayName, memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number' ? memberCounts.map[groupId] : 0, - avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[groupId] : undefined + avatarUrl }) } diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index dcf6cec..14cd529 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -333,7 +333,7 @@ .group-avatar { width: 44px; height: 44px; - border-radius: 50%; + border-radius: 8px; overflow: hidden; flex-shrink: 0; @@ -346,11 +346,11 @@ .avatar-placeholder { width: 100%; height: 100%; - background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); + background: var(--bg-tertiary); display: flex; align-items: center; justify-content: center; - color: #fff; + color: var(--text-secondary); } } @@ -390,7 +390,7 @@ .skeleton-avatar { width: 44px; height: 44px; - border-radius: 50%; + border-radius: 8px; background: var(--bg-tertiary); animation: pulse 1.5s infinite; } @@ -500,7 +500,7 @@ .group-avatar.large { width: 80px; height: 80px; - border-radius: 50%; + border-radius: 10px; overflow: hidden; margin: 0 auto 16px; @@ -513,11 +513,11 @@ .avatar-placeholder { width: 100%; height: 100%; - background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); + background: var(--bg-tertiary); display: flex; align-items: center; justify-content: center; - color: #fff; + color: var(--text-secondary); } } From 6e3bb9e361be75ba0ca164c1fea1208e3829a8ef Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 31 Jan 2026 15:24:21 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E5=9B=BE=E7=89=87=E8=A7=A3=E5=AF=86?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E6=9B=B4=E5=8A=A0=E6=BF=80=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.tsx | 93 ++++++++++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 21 deletions(-) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 8709aca..257e7cd 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1501,6 +1501,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o const imageClickTimerRef = useRef(null) const imageContainerRef = useRef(null) const imageAutoDecryptTriggered = useRef(false) + const imageAutoHdTriggered = useRef(null) + const [imageInView, setImageInView] = useState(false) + const imageForceHdAttempted = useRef(null) + const imageForceHdPending = useRef(false) const [voiceError, setVoiceError] = useState(false) const [voiceLoading, setVoiceLoading] = useState(false) const [isVoicePlaying, setIsVoicePlaying] = useState(false) @@ -1697,10 +1701,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o } }, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError]) - const requestImageDecrypt = useCallback(async (forceUpdate = false) => { - if (!isImage || imageLoading) return - setImageLoading(true) - setImageError(false) + const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => { + if (!isImage) return + if (imageLoading) return + if (!silent) { + setImageLoading(true) + setImageError(false) + } try { if (message.imageMd5 || message.imageDatName) { const result = await window.electronAPI.image.decrypt({ @@ -1726,14 +1733,25 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o setImageHasUpdate(false) return } - setImageError(true) + if (!silent) setImageError(true) } catch { - setImageError(true) + if (!silent) setImageError(true) } finally { - setImageLoading(false) + if (!silent) setImageLoading(false) } }, [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(() => { if (imageClickTimerRef.current) { window.clearTimeout(imageClickTimerRef.current) @@ -1846,6 +1864,47 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o return () => observer.disconnect() }, [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(() => { if (!isVoice) return @@ -2196,23 +2255,15 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o src={imageLocalPath} alt="图片" className="image-message" - onClick={() => setShowImagePreview(true)} + onClick={() => { + if (imageHasUpdate) { + void requestImageDecrypt(true, true) + } + setShowImagePreview(true) + }} onLoad={() => setImageError(false)} onError={() => setImageError(true)} /> - {imageHasUpdate && ( - - )} {showImagePreview && ( setShowImagePreview(false)} /> From d47166e6f9bdeb2947bdc68a505e9091b56fde2a Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 31 Jan 2026 15:39:59 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=93=E5=8C=85?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/VoiceTranscribeDialog.tsx | 2 +- src/pages/ChatPage.tsx | 49 ++++++++++++++++-------- src/pages/ContactsPage.tsx | 12 +++--- src/pages/ExportPage.tsx | 2 +- src/pages/SettingsPage.tsx | 6 +-- src/pages/SnsPage.tsx | 6 +-- src/pages/WelcomePage.tsx | 4 +- 7 files changed, 50 insertions(+), 31 deletions(-) diff --git a/src/components/VoiceTranscribeDialog.tsx b/src/components/VoiceTranscribeDialog.tsx index c8626ef..ff58e1a 100644 --- a/src/components/VoiceTranscribeDialog.tsx +++ b/src/components/VoiceTranscribeDialog.tsx @@ -23,7 +23,7 @@ export const VoiceTranscribeDialog: React.FC = ({ 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) { setDownloadProgress(payload.percent) } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 257e7cd..2b12197 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -491,7 +491,11 @@ function ChatPage(_props: ChatPageProps) { await new Promise(resolve => setTimeout(resolve, 0)) 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 + error?: string + } const dllTime = performance.now() - dllStart // DLL 调用后再次让出控制权 @@ -504,7 +508,8 @@ function ChatPage(_props: ChatPageProps) { 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) // 如果是自己的信息且当前个人头像为空,同步更新 @@ -545,7 +550,11 @@ function ChatPage(_props: ChatPageProps) { setIsRefreshingMessages(true) 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) { return } @@ -593,7 +602,12 @@ function ChatPage(_props: ChatPageProps) { const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null 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 (offset === 0) { setMessages(result.messages) @@ -690,7 +704,12 @@ function ChatPage(_props: ChatPageProps) { try { 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) { // 过滤掉已经在列表中的重复消息 @@ -1555,7 +1574,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o const contentToUse = message.content || (message as any).rawContent || message.parsedContent if (contentToUse) { 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) if (result && result.success && result.md5) { console.log('[Video Debug] Parsed MD5:', result.md5) @@ -1563,7 +1582,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o } else { console.error('[Video Debug] Failed to parse MD5:', result) } - }).catch((err) => { + }).catch((err: unknown) => { console.error('[Video Debug] Parse error:', err) }) } @@ -1671,7 +1690,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o } const pending = senderAvatarLoading.get(sender) if (pending) { - pending.then((result) => { + pending.then((result: { avatarUrl?: string; displayName?: string } | null) => { if (result) { setSenderAvatarUrl(result.avatarUrl) setSenderName(result.displayName) @@ -1787,7 +1806,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o sessionId: session.username, imageMd5: message.imageMd5 || undefined, imageDatName: message.imageDatName - }).then((result) => { + }).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }) => { if (cancelled) return if (result.success && result.localPath) { imageDataUrlCache.set(imageCacheKey, result.localPath) @@ -1805,7 +1824,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o useEffect(() => { 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 = payload.cacheKey === message.imageMd5 || payload.cacheKey === message.imageDatName || @@ -1822,7 +1841,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o useEffect(() => { 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 = payload.cacheKey === message.imageMd5 || payload.cacheKey === message.imageDatName || @@ -1992,7 +2011,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o useEffect(() => { if (!isVoice || voiceDataUrl) return 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) { const url = `data:audio/wav;base64,${result.data}` voiceDataUrlCache.set(voiceCacheKey, url) @@ -2125,7 +2144,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o console.log('[Video Debug] Loading video info for MD5:', videoMd5) 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) if (result && result.success) { setVideoInfo({ @@ -2138,7 +2157,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o console.error('[Video Debug] Video info failed:', result) setVideoInfo({ exists: false }) } - }).catch((err) => { + }).catch((err: unknown) => { console.error('[Video Debug] getVideoInfo error:', err) setVideoInfo({ exists: false }) }).finally(() => { @@ -2151,7 +2170,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false) useEffect(() => { - window.electronAPI.config.get('autoTranscribeVoice').then((value) => { + window.electronAPI.config.get('autoTranscribeVoice').then((value: unknown) => { setAutoTranscribeEnabled(value === true) }) }, []) diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index dd6dd7d..c4d071f 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -45,18 +45,18 @@ function ContactsPage() { if (contactsResult.success && contactsResult.contacts) { console.log('📊 总联系人数:', contactsResult.contacts.length) console.log('📊 按类型统计:', { - friends: contactsResult.contacts.filter(c => c.type === 'friend').length, - groups: contactsResult.contacts.filter(c => c.type === 'group').length, - officials: contactsResult.contacts.filter(c => c.type === 'official').length, - other: contactsResult.contacts.filter(c => c.type === 'other').length + friends: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'friend').length, + groups: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'group').length, + officials: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'official').length, + other: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'other').length }) // 获取头像URL - const usernames = contactsResult.contacts.map(c => c.username) + const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username) if (usernames.length > 0) { const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) if (avatarResult.success && avatarResult.contacts) { - contactsResult.contacts.forEach(contact => { + contactsResult.contacts.forEach((contact: ContactInfo) => { const enriched = avatarResult.contacts?.[contact.username] if (enriched?.avatarUrl) { contact.avatarUrl = enriched.avatarUrl diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 67d89fc..c94f12f 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -189,7 +189,7 @@ function ExportPage() { }, [loadSessions]) useEffect(() => { - const removeListener = window.electronAPI.export.onProgress?.((payload) => { + const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string }) => { setExportProgress({ current: payload.current, total: payload.total, diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 771bf15..f4603b3 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -155,10 +155,10 @@ function SettingsPage() { }, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect]) useEffect(() => { - const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { + const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => { setDbKeyStatus(payload.message) }) - const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => { + const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => { setImageKeyStatus(payload.message) }) return () => { @@ -270,7 +270,7 @@ function SettingsPage() { }, []) 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') { setWhisperDownloadProgress(payload.percent) } diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 8029dd1..e3efe81 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -165,8 +165,8 @@ export default function SnsPage() { scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight; } - const existingIds = new Set(currentPosts.map(p => p.id)); - const uniqueNewer = result.timeline.filter(p => !existingIds.has(p.id)); + const existingIds = new Set(currentPosts.map((p: SnsPost) => p.id)); + const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id)); if (uniqueNewer.length > 0) { setPosts(prev => [...uniqueNewer, ...prev]); @@ -253,7 +253,7 @@ export default function SnsPage() { })) 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) if (enriched.success && enriched.contacts) { setContacts(prev => prev.map(c => { diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 4955ef9..c68c64f 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -106,10 +106,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { } useEffect(() => { - const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { + const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => { setDbKeyStatus(payload.message) }) - const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => { + const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => { setImageKeyStatus(payload.message) }) return () => {