From 3c9ab6763c8f007450f72d4d68c49fb4b6227c18 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 31 Jan 2026 13:49:21 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E6=96=B9=E9=9D=A2?= =?UTF-8?q?=E5=86=8D=E4=BC=98=E5=8C=96=20=E5=AA=92=E4=BD=93=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index fda4063..948a2b0 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -142,6 +142,12 @@ class ExportService { this.configService = new ConfigService() } + private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback + const raw = Math.floor(value) + return Math.max(1, Math.min(raw, max)) + } + private cleanAccountDirName(dirName: string): string { const trimmed = dirName.trim() if (!trimmed) return trimmed @@ -1740,9 +1746,9 @@ class ExportService { phase: 'exporting-media' }) - // 并行导出媒体,限制 8 个并发 - const MEDIA_CONCURRENCY = 8 - await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { + // 并行导出媒体,并发数跟随导出设置 + const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { @@ -1956,8 +1962,8 @@ class ExportService { phase: 'exporting-media' }) - const MEDIA_CONCURRENCY = 8 - await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { + const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { @@ -2348,8 +2354,8 @@ class ExportService { phase: 'exporting-media' }) - const MEDIA_CONCURRENCY = 8 - await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { + const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { @@ -2653,8 +2659,8 @@ class ExportService { phase: 'exporting-media' }) - const MEDIA_CONCURRENCY = 8 - await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { + const mediaConcurrency = this.getClampedConcurrency(options.exportConcurrency) + await parallelLimit(mediaMessages, mediaConcurrency, async (msg) => { const mediaKey = `${msg.localType}_${msg.localId}` if (!mediaCache.has(mediaKey)) { const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { From 04fc5f91043847fe950861277ced9e5d228cb2d3 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 31 Jan 2026 14:00:01 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E5=90=8E=E7=9A=84=E5=BC=82=E5=B8=B8=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/SettingsPage.tsx | 49 +++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 65a4550..771bf15 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -373,7 +373,7 @@ function SettingsPage() { const applyWxidSelection = async ( selectedWxid: string, - options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string } + options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string; keysOverride?: WxidKeys } ) => { if (!selectedWxid) return @@ -389,9 +389,9 @@ function SettingsPage() { } const preferCurrentKeys = options?.preferCurrentKeys ?? false - const keys = preferCurrentKeys + const keys = options?.keysOverride ?? (preferCurrentKeys ? buildKeysFromState() - : buildKeysFromConfig(await configService.getWxidConfig(selectedWxid)) + : buildKeysFromConfig(await configService.getWxidConfig(selectedWxid))) setWxid(selectedWxid) applyKeysToState(keys) @@ -471,7 +471,7 @@ function SettingsPage() { const handleScanWxid = async ( silent = false, - options?: { preferCurrentKeys?: boolean; showDialog?: boolean } + options?: { preferCurrentKeys?: boolean; showDialog?: boolean; keysOverride?: WxidKeys } ) => { if (!dbPath) { if (!silent) showMessage('请先选择数据库目录', false) @@ -485,7 +485,8 @@ function SettingsPage() { await applyWxidSelection(wxids[0].wxid, { preferCurrentKeys: options?.preferCurrentKeys ?? false, showToast: !silent, - toastText: `已检测到账号:${wxids[0].wxid}` + toastText: `已检测到账号:${wxids[0].wxid}`, + keysOverride: options?.keysOverride }) } else if (wxids.length > 1 && allowDialog) { setShowWxidSelect(true) @@ -573,7 +574,9 @@ function SettingsPage() { setDecryptKey(result.key) setDbKeyStatus('密钥获取成功') showMessage('已自动获取解密密钥', true) - await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false }) + await syncCurrentKeys({ decryptKey: result.key, wxid }) + const keysOverride = buildKeysFromInputs({ decryptKey: result.key }) + await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false, keysOverride }) } else { if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { setIsManualStartPrompt(true) @@ -840,7 +843,7 @@ function SettingsPage() { const value = e.target.value setDecryptKey(value) if (value && value.length === 64) { - scheduleConfigSave('keys', () => syncCurrentKeys({ decryptKey: value })) + scheduleConfigSave('keys', () => syncCurrentKeys({ decryptKey: value, wxid })) // showMessage('解密密钥已保存', true) } }} @@ -900,12 +903,40 @@ function SettingsPage() { value={wxid} onChange={(e) => { const value = e.target.value + const previousWxid = wxid setWxid(value) scheduleConfigSave('wxid', async () => { + if (previousWxid && previousWxid !== value) { + const currentKeys = buildKeysFromState() + await configService.setWxidConfig(previousWxid, { + decryptKey: currentKeys.decryptKey, + imageXorKey: typeof currentKeys.imageXorKey === 'number' ? currentKeys.imageXorKey : 0, + imageAesKey: currentKeys.imageAesKey + }) + } if (value) { await configService.setMyWxid(value) await syncCurrentKeys({ wxid: value }) // Sync keys to the new wxid entry } + + if (value && previousWxid !== value) { + if (isDbConnected) { + try { + await window.electronAPI.chat.close() + const result = await window.electronAPI.chat.connect() + setDbConnected(result.success, dbPath || undefined) + if (!result.success && result.error) { + showMessage(result.error, false) + } + } catch (e: any) { + showMessage(`切换账号后重新连接失败: ${e}`, false) + setDbConnected(false) + } + } + clearAnalyticsStoreCache() + resetChatStore() + window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: value } })) + } }) }} /> @@ -925,7 +956,7 @@ function SettingsPage() { setImageXorKey(value) const parsed = parseImageXorKey(value) if (value === '' || parsed !== null) { - scheduleConfigSave('keys', () => syncCurrentKeys({ imageXorKey: value })) + scheduleConfigSave('keys', () => syncCurrentKeys({ imageXorKey: value, wxid })) } }} /> @@ -941,7 +972,7 @@ function SettingsPage() { onChange={(e) => { const value = e.target.value setImageAesKey(value) - scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value })) + scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid })) }} /> + )} 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 4/7] =?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 5/7] =?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 6/7] =?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 7/7] =?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 () => {