From f7610a3570e03daa56603c5e088dddeae51f0978 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 13 Mar 2026 19:38:17 +0800 Subject: [PATCH 01/11] =?UTF-8?q?=E5=9B=BE=E7=89=87=E8=A7=A3=E5=AF=86?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=9A=84=E6=97=B6=E5=80=99=20=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E5=AF=BC=E5=87=BA=E7=BC=A9=E7=95=A5=E5=9B=BE=20html?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 20864a5..d4b36ea 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -2157,12 +2157,22 @@ class ExportService { imageMd5, imageDatName }) - if (!thumbResult.success || !thumbResult.localPath) { - console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'} → 将显示 [图片] 占位符`) - return null + if (thumbResult.success && thumbResult.localPath) { + console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) + result.localPath = thumbResult.localPath + } else { + console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'}`) + // 最后尝试:直接从 imageStore 获取缓存的缩略图 data URL + const { imageStore } = await import('../main') + const cachedThumb = imageStore?.getCachedImage(sessionId, imageMd5, imageDatName) + if (cachedThumb) { + console.log(`[Export] 从 imageStore 获取到缓存缩略图 (localId=${msg.localId})`) + result.localPath = cachedThumb + } else { + console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`) + return null + } } - console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) - result.localPath = thumbResult.localPath } // 为每条消息生成稳定且唯一的文件名前缀,避免跨日期/消息发生同名覆盖 From 1c5cacf1ce188c1cc7e0d772d40cd742fa85c78b Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 13 Mar 2026 20:08:42 +0800 Subject: [PATCH 02/11] =?UTF-8?q?=E7=BB=99=E5=88=AB=E7=9A=84=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index d4b36ea..e789187 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -3467,9 +3467,13 @@ class ExportService { // 确定消息内容 let content: string | null + const mediaKey = `${msg.localType}_${msg.localId}` + const mediaItem = mediaCache.get(mediaKey) if (msg.localType === 34 && options.exportVoiceAsText) { // 使用预先转写的文字 content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + } else if (mediaItem && msg.localType === 3) { + content = mediaItem.relativePath } else { content = this.parseMessageContent( msg.content, From d7f7139f36cc875c191c332b31cf15a664bbc24e Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 13 Mar 2026 20:21:56 +0800 Subject: [PATCH 03/11] =?UTF-8?q?=E5=B0=9D=E8=AF=95=E4=BF=AE=E5=A4=8D=20ht?= =?UTF-8?q?tps://github.com/hicccc77/WeFlow/issues/378=20=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/chatService.ts | 21 +++++- electron/services/httpService.ts | 111 +++++++++++++++++++++++++------ 2 files changed, 107 insertions(+), 25 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 470933a..6f104fd 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -2974,7 +2974,9 @@ class ChatService { const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']) let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10) - const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null + const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) + || this.extractSenderUsernameFromContent(content) + || null const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0) if (senderUsername && (myWxidLower || cleanedWxidLower)) { @@ -4385,7 +4387,18 @@ class ChatService { } private stripSenderPrefix(content: string): string { - return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)\s*/, '') + return content.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|)\s*|\s*)/i, '') + } + + private extractSenderUsernameFromContent(content: string): string | null { + if (!content) return null + + const normalized = this.cleanUtf16(this.decodeHtmlEntities(String(content))) + const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|)/i.exec(normalized) + if (!match?.[1]) return null + + const candidate = match[1].trim() + return candidate || null } private decodeHtmlEntities(content: string): string { @@ -6594,7 +6607,9 @@ class ChatService { createTime: this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0), sortSeq: this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)), isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0), - senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null, + senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) + || this.extractSenderUsernameFromContent(rawContent) + || null, rawContent: rawContent, content: rawContent, // 添加原始内容供视频MD5解析使用 parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)) diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 86ea6c9..bfb8ce1 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -340,6 +340,7 @@ class HttpService { const trimmedRows = allRows.slice(0, limit) const finalHasMore = hasMore || allRows.length > limit const messages = chatService.mapRowsToMessagesForApi(trimmedRows) + await this.backfillMissingSenderUsernames(talker, messages) return { success: true, messages, hasMore: finalHasMore } } finally { await wcdbService.closeMessageCursor(cursor) @@ -359,6 +360,41 @@ class HttpService { return Math.min(Math.max(parsed, min), max) } + private async backfillMissingSenderUsernames(talker: string, messages: Message[]): Promise { + if (!talker.endsWith('@chatroom')) return + + const targets = messages.filter((msg) => !String(msg.senderUsername || '').trim()) + if (targets.length === 0) return + + const myWxid = (this.configService.get('myWxid') || '').trim() + for (const msg of targets) { + const localId = Number(msg.localId || 0) + if (Number.isFinite(localId) && localId > 0) { + try { + const detail = await wcdbService.getMessageById(talker, localId) + if (detail.success && detail.message) { + const hydrated = chatService.mapRowsToMessagesForApi([detail.message])[0] + if (hydrated?.senderUsername) { + msg.senderUsername = hydrated.senderUsername + } + if ((msg.isSend === null || msg.isSend === undefined) && hydrated?.isSend !== undefined) { + msg.isSend = hydrated.isSend + } + if (!msg.rawContent && hydrated?.rawContent) { + msg.rawContent = hydrated.rawContent + } + } + } catch (error) { + console.warn('[HttpService] backfill sender failed:', error) + } + } + + if (!msg.senderUsername && msg.isSend === 1 && myWxid) { + msg.senderUsername = myWxid + } + } + } + private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean { for (const key of keys) { const raw = url.searchParams.get(key) @@ -778,6 +814,49 @@ class HttpService { return {} } + private lookupGroupNickname(groupNicknamesMap: Map, sender: string): string { + if (!sender) return '' + return groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '' + } + + private resolveChatLabSenderInfo( + msg: Message, + talkerId: string, + talkerName: string, + myWxid: string, + isGroup: boolean, + senderNames: Record, + groupNicknamesMap: Map + ): { sender: string; accountName: string; groupNickname?: string } { + let sender = String(msg.senderUsername || '').trim() + let usedUnknownPlaceholder = false + const sameAsMe = sender && myWxid && sender.toLowerCase() === myWxid.toLowerCase() + const isSelf = msg.isSend === 1 || sameAsMe + + if (!sender && isSelf && myWxid) { + sender = myWxid + } + + if (!sender) { + if (msg.localType === 10000 || msg.localType === 266287972401) { + sender = talkerId + } else { + sender = `unknown_sender_${msg.localId || msg.createTime || 0}` + usedUnknownPlaceholder = true + } + } + + const groupNickname = isGroup ? this.lookupGroupNickname(groupNicknamesMap, sender) : '' + const displayName = senderNames[sender] || groupNickname || (usedUnknownPlaceholder ? '' : sender) + const accountName = isSelf ? '我' : (displayName || '未知发送者') + + return { + sender, + accountName, + groupNickname: groupNickname || undefined + } + } + /** * 转换为 ChatLab 格式 */ @@ -817,36 +896,24 @@ class HttpService { // 构建成员列表 const memberMap = new Map() for (const msg of messages) { - const sender = msg.senderUsername || '' - if (sender && !memberMap.has(sender)) { - const displayName = senderNames[sender] || sender - const isSelf = sender === myWxid || sender.toLowerCase() === myWxid.toLowerCase() - // 获取群昵称(尝试多种方式) - const groupNickname = isGroup - ? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '') - : '' - memberMap.set(sender, { - platformId: sender, - accountName: isSelf ? '我' : displayName, - groupNickname: groupNickname || undefined + const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap) + if (!memberMap.has(senderInfo.sender)) { + memberMap.set(senderInfo.sender, { + platformId: senderInfo.sender, + accountName: senderInfo.accountName, + groupNickname: senderInfo.groupNickname }) } } // 转换消息 const chatLabMessages: ChatLabMessage[] = messages.map(msg => { - const sender = msg.senderUsername || '' - const isSelf = msg.isSend === 1 || sender === myWxid - const accountName = isSelf ? '我' : (senderNames[sender] || sender) - // 获取该发送者的群昵称 - const groupNickname = isGroup - ? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '') - : '' + const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap) return { - sender, - accountName, - groupNickname: groupNickname || undefined, + sender: senderInfo.sender, + accountName: senderInfo.accountName, + groupNickname: senderInfo.groupNickname, timestamp: msg.createTime, type: this.mapMessageType(msg.localType, msg), content: this.getMessageContent(msg), From 4c551a8c91cb48df7d3ba967ab44416bcd061f9d Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 13 Mar 2026 20:40:16 +0800 Subject: [PATCH 04/11] =?UTF-8?q?=E5=9B=BE=E7=89=87=E8=A7=A3=E5=AF=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E4=BC=98=E5=8C=96https://github.com/hicccc77?= =?UTF-8?q?/WeFlow/issues/408#issuecomment-4053026902?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/imageSearchWorker.ts | 74 ++++--- electron/services/imageDecryptService.ts | 246 ++++++++++++++--------- 2 files changed, 187 insertions(+), 133 deletions(-) diff --git a/electron/imageSearchWorker.ts b/electron/imageSearchWorker.ts index 56826a2..429a00f 100644 --- a/electron/imageSearchWorker.ts +++ b/electron/imageSearchWorker.ts @@ -10,7 +10,7 @@ type WorkerPayload = { thumbOnly: boolean } -type Candidate = { score: number; path: string; isThumb: boolean; hasX: boolean } +type Candidate = { score: number; path: string; isThumb: boolean } const payload = workerData as WorkerPayload @@ -18,16 +18,26 @@ function looksLikeMd5(value: string): boolean { return /^[a-fA-F0-9]{16,32}$/.test(value) } +function stripDatVariantSuffix(base: string): string { + const lower = base.toLowerCase() + const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c'] + for (const suffix of suffixes) { + if (lower.endsWith(suffix)) { + return lower.slice(0, -suffix.length) + } + } + if (/[._][a-z]$/.test(lower)) { + return lower.slice(0, -2) + } + return lower +} + function hasXVariant(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) + return stripDatVariantSuffix(baseLower) !== baseLower } function hasImageVariantSuffix(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) -} - -function isLikelyImageDatBase(baseLower: string): boolean { - return hasImageVariantSuffix(baseLower) || looksLikeMd5(baseLower) + return stripDatVariantSuffix(baseLower) !== baseLower } function normalizeDatBase(name: string): string { @@ -35,10 +45,17 @@ function normalizeDatBase(name: string): string { if (base.endsWith('.dat') || base.endsWith('.jpg')) { base = base.slice(0, -4) } - while (/[._][a-z]$/.test(base)) { - base = base.slice(0, -2) + while (true) { + const stripped = stripDatVariantSuffix(base) + if (stripped === base) { + return base + } + base = stripped } - return base +} + +function isLikelyImageDatBase(baseLower: string): boolean { + return hasImageVariantSuffix(baseLower) || looksLikeMd5(normalizeDatBase(baseLower)) } function matchesDatName(fileName: string, datName: string): boolean { @@ -47,25 +64,23 @@ function matchesDatName(fileName: string, datName: string): boolean { const normalizedBase = normalizeDatBase(base) const normalizedTarget = normalizeDatBase(datName.toLowerCase()) if (normalizedBase === normalizedTarget) return true - const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`) - if (pattern.test(lower)) return true - return lower.endsWith('.dat') && lower.includes(datName) + return lower.endsWith('.dat') && lower.includes(normalizedTarget) } function scoreDatName(fileName: string): number { - if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1 - if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1 - return 2 + const lower = fileName.toLowerCase() + const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower + if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600 + if (!hasXVariant(baseLower)) return 500 + if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450 + if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 + if (isThumbnailDat(lower)) return 100 + return 350 } function isThumbnailDat(fileName: string): boolean { - return fileName.includes('.t.dat') || fileName.includes('_t.dat') -} - -function isHdDat(fileName: string): boolean { const lower = fileName.toLowerCase() - const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower - return base.endsWith('_hd') || base.endsWith('_h') + return lower.includes('.t.dat') || lower.includes('_t.dat') || lower.includes('_thumb.dat') } function walkForDat( @@ -105,20 +120,15 @@ function walkForDat( if (!lower.endsWith('.dat')) continue const baseLower = lower.slice(0, -4) if (!isLikelyImageDatBase(baseLower)) continue - if (!hasXVariant(baseLower)) continue if (!matchesDatName(lower, datName)) continue - // 排除高清图片格式 (_hd, _h) - if (isHdDat(lower)) continue matchedBases.add(baseLower) const isThumb = isThumbnailDat(lower) if (!allowThumbnail && isThumb) continue if (thumbOnly && !isThumb) continue - const score = scoreDatName(lower) candidates.push({ - score, + score: scoreDatName(lower), path: entryPath, - isThumb, - hasX: hasXVariant(baseLower) + isThumb }) } } @@ -126,10 +136,8 @@ function walkForDat( return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) } } - const withX = candidates.filter((item) => item.hasX) - const basePool = withX.length ? withX : candidates - const nonThumb = basePool.filter((item) => !item.isThumb) - const finalPool = thumbOnly ? basePool : (nonThumb.length ? nonThumb : basePool) + const nonThumb = candidates.filter((item) => !item.isThumb) + const finalPool = thumbOnly ? candidates : (nonThumb.length ? nonThumb : candidates) let best: { score: number; path: string } | null = null for (const item of finalPool) { diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 13dce67..a78b7ed 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -414,23 +414,33 @@ export class ImageDecryptService { if (!skipResolvedCache) { if (imageMd5) { const cached = this.resolvedCache.get(imageMd5) - if (cached && existsSync(cached)) return cached + if (cached && existsSync(cached)) { + const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) + this.cacheDatPath(accountDir, imageMd5, preferred) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferred) + return preferred + } } if (imageDatName) { const cached = this.resolvedCache.get(imageDatName) - if (cached && existsSync(cached)) return cached + if (cached && existsSync(cached)) { + const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) + this.cacheDatPath(accountDir, imageDatName, preferred) + if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferred) + return preferred + } } } // 1. 通过 MD5 快速定位 (MsgAttach 目录) if (imageMd5) { - const res = await this.fastProbabilisticSearch(accountDir, imageMd5, allowThumbnail) + const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail) if (res) return res } // 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位 if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { - const res = await this.fastProbabilisticSearch(accountDir, imageDatName, allowThumbnail) + const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail) if (res) return res } @@ -439,16 +449,17 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId }) const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId) if (hardlinkPath) { - const isThumb = this.isThumbnailPath(hardlinkPath) + const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail) + const isThumb = this.isThumbnailPath(preferredPath) if (allowThumbnail || !isThumb) { - this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: hardlinkPath }) - this.cacheDatPath(accountDir, imageMd5, hardlinkPath) - if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) - return hardlinkPath + this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: preferredPath }) + this.cacheDatPath(accountDir, imageMd5, preferredPath) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferredPath) + return preferredPath } // hardlink 找到的是缩略图,但要求高清图 // 尝试在同一目录下查找高清图变体(快速查找,不遍历) - const hdPath = this.findHdVariantInSameDir(hardlinkPath) + const hdPath = this.findHdVariantInSameDir(preferredPath) if (hdPath) { this.cacheDatPath(accountDir, imageMd5, hdPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) @@ -462,16 +473,19 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId }) const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) if (fallbackPath) { - const isThumb = this.isThumbnailPath(fallbackPath) + const preferredPath = this.getPreferredDatVariantPath(fallbackPath, allowThumbnail) + const isThumb = this.isThumbnailPath(preferredPath) if (allowThumbnail || !isThumb) { - this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: fallbackPath }) - this.cacheDatPath(accountDir, imageDatName, fallbackPath) - return fallbackPath + this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath }) + this.cacheDatPath(accountDir, imageDatName, preferredPath) + if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferredPath) + return preferredPath } // 找到缩略图但要求高清图,尝试同目录查找高清图变体 - const hdPath = this.findHdVariantInSameDir(fallbackPath) + const hdPath = this.findHdVariantInSameDir(preferredPath) if (hdPath) { this.cacheDatPath(accountDir, imageDatName, hdPath) + if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hdPath) return hdPath } return null @@ -484,14 +498,15 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId }) const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId) if (hardlinkPath) { - const isThumb = this.isThumbnailPath(hardlinkPath) + const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail) + const isThumb = this.isThumbnailPath(preferredPath) if (allowThumbnail || !isThumb) { - this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: hardlinkPath }) - this.cacheDatPath(accountDir, imageDatName, hardlinkPath) - return hardlinkPath + this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: preferredPath }) + this.cacheDatPath(accountDir, imageDatName, preferredPath) + return preferredPath } // hardlink 找到的是缩略图,但要求高清图 - const hdPath = this.findHdVariantInSameDir(hardlinkPath) + const hdPath = this.findHdVariantInSameDir(preferredPath) if (hdPath) { this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath @@ -510,9 +525,10 @@ export class ImageDecryptService { if (!skipResolvedCache) { const cached = this.resolvedCache.get(imageDatName) if (cached && existsSync(cached)) { - if (allowThumbnail || !this.isThumbnailPath(cached)) return cached + const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) + if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred // 缓存的是缩略图,尝试找高清图 - const hdPath = this.findHdVariantInSameDir(cached) + const hdPath = this.findHdVariantInSameDir(preferred) if (hdPath) return hdPath } } @@ -801,7 +817,8 @@ export class ImageDecryptService { const key = `${accountDir}|${datName}` const cached = this.resolvedCache.get(key) if (cached && existsSync(cached)) { - if (allowThumbnail || !this.isThumbnailPath(cached)) return cached + const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) + if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred } const root = join(accountDir, 'msg', 'attach') @@ -810,7 +827,7 @@ export class ImageDecryptService { // 优化1:快速概率性查找 // 包含:1. 基于文件名的前缀猜测 (旧版) // 2. 基于日期的最近月份扫描 (新版无索引时) - const fastHit = await this.fastProbabilisticSearch(root, datName) + const fastHit = await this.fastProbabilisticSearch(root, datName, allowThumbnail) if (fastHit) { this.resolvedCache.set(key, fastHit) return fastHit @@ -830,33 +847,28 @@ export class ImageDecryptService { * 包含:1. 微信旧版结构 filename.substr(0, 2)/... * 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename */ - private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise { + private async fastProbabilisticSearch(root: string, datName: string, allowThumbnail = true): Promise { const { promises: fs } = require('fs') const { join } = require('path') try { // --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) --- const lowerName = datName.toLowerCase() - let baseName = lowerName - if (baseName.endsWith('.dat')) { - baseName = baseName.slice(0, -4) - if (baseName.endsWith('_t') || baseName.endsWith('.t') || baseName.endsWith('_hd')) { - baseName = baseName.slice(0, -3) - } else if (baseName.endsWith('_thumb')) { - baseName = baseName.slice(0, -6) - } - } + const baseName = this.normalizeDatBase(lowerName) + const targetNames = this.buildPreferredDatNames(baseName, allowThumbnail) const candidates: string[] = [] if (/^[a-f0-9]{32}$/.test(baseName)) { const dir1 = baseName.substring(0, 2) const dir2 = baseName.substring(2, 4) - candidates.push( - join(root, dir1, dir2, datName), - join(root, dir1, dir2, 'Img', datName), - join(root, dir1, dir2, 'mg', datName), - join(root, dir1, dir2, 'Image', datName) - ) + for (const targetName of targetNames) { + candidates.push( + join(root, dir1, dir2, targetName), + join(root, dir1, dir2, 'Img', targetName), + join(root, dir1, dir2, 'mg', targetName), + join(root, dir1, dir2, 'Image', targetName) + ) + } } for (const path of candidates) { @@ -883,13 +895,6 @@ export class ImageDecryptService { months.push(mStr) } - const targetNames = [datName] - if (baseName !== lowerName) { - targetNames.push(`${baseName}.dat`) - targetNames.push(`${baseName}_t.dat`) - targetNames.push(`${baseName}_thumb.dat`) - } - const batchSize = 20 for (let i = 0; i < sessionDirs.length; i += batchSize) { const batch = sessionDirs.slice(i, i + batchSize) @@ -919,36 +924,13 @@ export class ImageDecryptService { /** * 在同一目录下查找高清图变体 - * 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat + * 优先 `_h`,再回退其他非缩略图变体 */ private findHdVariantInSameDir(thumbPath: string): string | null { try { const dir = dirname(thumbPath) - const fileName = basename(thumbPath).toLowerCase() - - // 提取基础名称(去掉 _t.dat 或 .t.dat) - let baseName = fileName - if (baseName.endsWith('_t.dat')) { - baseName = baseName.slice(0, -6) - } else if (baseName.endsWith('.t.dat')) { - baseName = baseName.slice(0, -6) - } else { - return null - } - - // 尝试查找高清图变体 - const variants = [ - `${baseName}_h.dat`, - `${baseName}.h.dat`, - `${baseName}.dat` - ] - - for (const variant of variants) { - const variantPath = join(dir, variant) - if (existsSync(variantPath)) { - return variantPath - } - } + const fileName = basename(thumbPath) + return this.findPreferredDatVariantInDir(dir, fileName, false) } catch { } return null } @@ -998,7 +980,86 @@ export class ImageDecryptService { void worker.terminate() resolve(null) }) - }) + }) + } + + private stripDatVariantSuffix(base: string): string { + const lower = base.toLowerCase() + const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c'] + for (const suffix of suffixes) { + if (lower.endsWith(suffix)) { + return lower.slice(0, -suffix.length) + } + } + if (/[._][a-z]$/.test(lower)) { + return lower.slice(0, -2) + } + return lower + } + + private getDatVariantPriority(name: string): number { + const lower = name.toLowerCase() + const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower + if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600 + if (!this.hasXVariant(baseLower)) return 500 + if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450 + if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 + if (this.isThumbnailDat(lower)) return 100 + return 350 + } + + private buildPreferredDatNames(baseName: string, allowThumbnail: boolean): string[] { + if (!baseName) return [] + const names = [ + `${baseName}_h.dat`, + `${baseName}.h.dat`, + `${baseName}.dat`, + `${baseName}_hd.dat`, + `${baseName}.hd.dat`, + `${baseName}_c.dat`, + `${baseName}.c.dat` + ] + if (allowThumbnail) { + names.push( + `${baseName}_thumb.dat`, + `${baseName}.thumb.dat`, + `${baseName}_t.dat`, + `${baseName}.t.dat` + ) + } + return Array.from(new Set(names)) + } + + private findPreferredDatVariantInDir(dirPath: string, baseName: string, allowThumbnail: boolean): string | null { + let entries: string[] + try { + entries = readdirSync(dirPath) + } catch { + return null + } + const target = this.normalizeDatBase(baseName.toLowerCase()) + let bestPath: string | null = null + let bestScore = Number.NEGATIVE_INFINITY + for (const entry of entries) { + const lower = entry.toLowerCase() + if (!lower.endsWith('.dat')) continue + if (!allowThumbnail && this.isThumbnailDat(lower)) continue + const baseLower = lower.slice(0, -4) + if (this.normalizeDatBase(baseLower) !== target) continue + const score = this.getDatVariantPriority(lower) + if (score > bestScore) { + bestScore = score + bestPath = join(dirPath, entry) + } + } + return bestPath + } + + private getPreferredDatVariantPath(datPath: string, allowThumbnail: boolean): string { + const lower = datPath.toLowerCase() + if (!lower.endsWith('.dat')) return datPath + const preferred = this.findPreferredDatVariantInDir(dirname(datPath), basename(datPath), allowThumbnail) + return preferred || datPath } private normalizeDatBase(name: string): string { @@ -1006,18 +1067,21 @@ export class ImageDecryptService { if (base.endsWith('.dat') || base.endsWith('.jpg')) { base = base.slice(0, -4) } - while (/[._][a-z]$/.test(base)) { - base = base.slice(0, -2) + for (;;) { + const stripped = this.stripDatVariantSuffix(base) + if (stripped === base) { + return base + } + base = stripped } - return base } private hasImageVariantSuffix(baseLower: string): boolean { - return /[._][a-z]$/.test(baseLower) + return this.stripDatVariantSuffix(baseLower) !== baseLower } private isLikelyImageDatBase(baseLower: string): boolean { - return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower) + return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(this.normalizeDatBase(baseLower)) } @@ -1206,24 +1270,7 @@ export class ImageDecryptService { } private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null { - let entries: string[] - try { - entries = readdirSync(dirPath) - } catch { - return null - } - const target = this.normalizeDatBase(baseName.toLowerCase()) - for (const entry of entries) { - const lower = entry.toLowerCase() - if (!lower.endsWith('.dat')) continue - if (this.isThumbnailDat(lower)) continue - const baseLower = lower.slice(0, -4) - // 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的) - if (!this.hasXVariant(baseLower)) continue - if (this.normalizeDatBase(baseLower) !== target) continue - return join(dirPath, entry) - } - return null + return this.findPreferredDatVariantInDir(dirPath, baseName, false) } private isNonThumbnailVariantDat(datPath: string): boolean { @@ -1231,8 +1278,7 @@ export class ImageDecryptService { if (!lower.endsWith('.dat')) return false if (this.isThumbnailDat(lower)) return false const baseLower = lower.slice(0, -4) - // 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的) - return this.hasXVariant(baseLower) + return this.isLikelyImageDatBase(baseLower) } private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void { @@ -1858,7 +1904,7 @@ export class ImageDecryptService { private hasXVariant(base: string): boolean { const lower = base.toLowerCase() - return lower.endsWith('_h') || lower.endsWith('_hd') || lower.endsWith('_thumb') || lower.endsWith('_t') + return this.stripDatVariantSuffix(lower) !== lower } private isHdPath(p: string): boolean { From 7697f382ef41ddab5590a68a4804b50ff9b59bfe Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 13 Mar 2026 20:49:53 +0800 Subject: [PATCH 05/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E6=8B=96=E5=8A=A8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index a21cafb..4b02936 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1605,6 +1605,7 @@ align-items: center; gap: 12px; border-bottom: 1px solid var(--border-color); + -webkit-app-region: drag; .session-avatar { width: 40px; @@ -1638,6 +1639,7 @@ display: flex; align-items: center; gap: 8px; + -webkit-app-region: no-drag; .jump-calendar-anchor { position: relative; From 6e371d75c8aaa5b0f3044cb96e09647c970d8e6f Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 13 Mar 2026 20:50:08 +0800 Subject: [PATCH 06/11] =?UTF-8?q?=E6=AF=8F=E6=AC=A1=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E7=9A=84=E6=97=B6=E5=80=99=20=E9=83=BD?= =?UTF-8?q?=E8=A7=A3=E5=AF=86=E4=B8=80=E9=81=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 0eec7d4..09f33db 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -5464,8 +5464,9 @@ function MessageBubble({ let finalImagePath = imageLocalPath let finalLiveVideoPath = imageLiveVideoPath || undefined - // If current cache is a thumbnail, wait for a silent force-HD decrypt before opening viewer. - if (imageHasUpdate) { + // Every explicit preview click re-runs the forced HD search/decrypt path so + // users don't need to re-enter the session after WeChat materializes a new original image. + if (message.imageMd5 || message.imageDatName) { try { const upgraded = await requestImageDecrypt(true, true) if (upgraded?.success && upgraded.localPath) { @@ -5497,7 +5498,6 @@ function MessageBubble({ void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath) }, [ - imageHasUpdate, imageLiveVideoPath, imageLocalPath, imageCacheKey, From b9e0535f63be6c368bfd060858986fdb677ce334 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 13 Mar 2026 21:13:45 +0800 Subject: [PATCH 07/11] =?UTF-8?q?https://github.com/hicccc77/WeFlow/issues?= =?UTF-8?q?/372=E5=AF=BC=E5=87=BA=E6=97=B6=E7=BB=99=E6=AF=8F=E4=B8=AA?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E9=83=BD=E6=8E=A5=E5=85=A5=E4=BA=86=E7=BE=A4?= =?UTF-8?q?=E6=88=90=E5=91=98=E6=98=BE=E7=A4=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 236 ++++++++++++++++++++++------- 1 file changed, 182 insertions(+), 54 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index e789187..dcf3956 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -107,6 +107,15 @@ interface MediaExportItem { posterDataUrl?: string } +interface ExportDisplayProfile { + wxid: string + nickname: string + remark: string + alias: string + groupNickname: string + displayName: string +} + type MessageCollectMode = 'full' | 'text-fast' | 'media-fast' type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' @@ -860,6 +869,50 @@ class ExportService { } } + private async resolveExportDisplayProfile( + wxid: string, + preference: ExportOptions['displayNamePreference'], + getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }>, + groupNicknamesMap: Map, + fallbackDisplayName = '', + extraGroupNicknameCandidates: Array = [] + ): Promise { + const resolvedWxid = String(wxid || '').trim() || String(fallbackDisplayName || '').trim() || 'unknown' + const contactResult = resolvedWxid ? await getContact(resolvedWxid) : { success: false as const } + const contact = contactResult.success ? contactResult.contact : null + const nickname = String(contact?.nickName || contact?.nick_name || fallbackDisplayName || resolvedWxid) + const remark = String(contact?.remark || '') + const alias = String(contact?.alias || '') + const groupNickname = this.resolveGroupNicknameByCandidates( + groupNicknamesMap, + [ + resolvedWxid, + contact?.username, + contact?.userName, + contact?.encryptUsername, + contact?.encryptUserName, + alias, + ...extraGroupNicknameCandidates + ] + ) || '' + const displayName = this.getPreferredDisplayName( + resolvedWxid, + nickname, + remark, + groupNickname, + preference || 'remark' + ) + + return { + wxid: resolvedWxid, + nickname, + remark, + alias, + groupNickname, + displayName + } + } + /** * 从转账消息 XML 中提取并解析 "谁转账给谁" 描述 * @param content 原始消息内容 XML @@ -3282,8 +3335,19 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) + const myInfo = await this.getContactInfo(cleanedMyWxid) + const contactCache = new Map() + const getContactCached = async (username: string) => { + if (contactCache.has(username)) { + return contactCache.get(username)! + } + const result = await wcdbService.getContact(username) + contactCache.set(username, result) + return result + } onProgress?.({ current: 0, @@ -3319,6 +3383,18 @@ class ExportService { await this.ensureVoiceModel(onProgress) } + const senderUsernames = new Set() + let senderScanIndex = 0 + for (const msg of allMessages) { + if ((senderScanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } + if (msg.senderUsername) senderUsernames.add(msg.senderUsername) + } + senderUsernames.add(sessionId) + senderUsernames.add(cleanedMyWxid) + await this.preloadContacts(senderUsernames, contactCache) + if (isGroup) { this.throwIfStopRequested(control) await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) @@ -3449,6 +3525,7 @@ class ExportService { }) const chatLabMessages: ChatLabMessage[] = [] + const senderProfileMap = new Map() let messageIndex = 0 for (const msg of allMessages) { if ((messageIndex++ & 0x7f) === 0) { @@ -3464,6 +3541,26 @@ class ExportService { const groupNickname = memberInfo.groupNickname || (isGroup ? this.resolveGroupNicknameByCandidates(groupNicknamesMap, [msg.senderUsername]) : '') || '' + const senderProfile = isGroup + ? await this.resolveExportDisplayProfile( + msg.senderUsername || cleanedMyWxid, + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (memberInfo.accountName || msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + : { + wxid: msg.senderUsername || cleanedMyWxid, + nickname: memberInfo.accountName || msg.senderUsername || '', + remark: '', + alias: '', + groupNickname, + displayName: memberInfo.accountName || msg.senderUsername || '' + } + if (senderProfile.wxid && !senderProfileMap.has(senderProfile.wxid)) { + senderProfileMap.set(senderProfile.wxid, senderProfile) + } // 确定消息内容 let content: string | null @@ -3504,8 +3601,8 @@ class ExportService { const message: ChatLabMessage = { sender: msg.senderUsername, - accountName: memberInfo.accountName, - groupNickname: groupNickname || undefined, + accountName: senderProfile.displayName || memberInfo.accountName, + groupNickname: (senderProfile.groupNickname || groupNickname) || undefined, timestamp: msg.createTime, type: this.convertMessageType(msg.localType, msg.content), content: content @@ -3621,10 +3718,27 @@ class ExportService { : new Map() const sessionAvatar = avatarMap.get(sessionId) - const members = Array.from(collected.memberSet.values()).map((info) => { + const members = await Promise.all(Array.from(collected.memberSet.values()).map(async (info) => { + const profile = isGroup + ? (senderProfileMap.get(info.member.platformId) || await this.resolveExportDisplayProfile( + info.member.platformId, + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + info.member.accountName || info.member.platformId, + this.isSameWxid(info.member.platformId, cleanedMyWxid) ? [rawMyWxid, cleanedMyWxid] : [] + )) + : null + const member = profile + ? { + ...info.member, + accountName: profile.displayName || info.member.accountName, + groupNickname: profile.groupNickname || info.member.groupNickname + } + : info.member const avatar = avatarMap.get(info.member.platformId) - return avatar ? { ...info.member, avatar } : info.member - }) + return avatar ? { ...member, avatar } : member + })) const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup, sessionAvatar) @@ -3697,6 +3811,7 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -4512,13 +4627,14 @@ class ExportService { } // 预加载群昵称 (仅群聊且完整列模式) - const groupNicknameCandidates = (isGroup && !useCompactColumns) + const groupNicknameCandidates = isGroup ? this.buildGroupNicknameIdCandidates([ ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid + cleanedMyWxid, + rawMyWxid ]) : [] - const groupNicknamesMap = (isGroup && !useCompactColumns) + const groupNicknamesMap = isGroup ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) : new Map() @@ -4637,30 +4753,26 @@ class ExportService { let senderRemark: string = '' let senderGroupNickname: string = '' // 群昵称 - - if (msg.isSend) { + if (isGroup) { + const senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderWxid = senderProfile.wxid + senderNickname = senderProfile.nickname + senderRemark = senderProfile.remark + senderGroupNickname = senderProfile.groupNickname + senderRole = senderProfile.displayName + } else if (msg.isSend) { // 我发送的消息 senderRole = '我' senderWxid = cleanedMyWxid senderNickname = myInfo.displayName || cleanedMyWxid senderRemark = '' - } else if (isGroup && msg.senderUsername) { - // 群消息 - senderWxid = msg.senderUsername - - // 用 getContact 获取联系人详情,分别取昵称和备注 - const contactDetail = await getContactCached(msg.senderUsername) - if (contactDetail.success && contactDetail.contact) { - // nickName 才是真正的昵称 - senderNickname = contactDetail.contact.nickName || msg.senderUsername - senderRemark = contactDetail.contact.remark || '' - // 身份:有备注显示备注,没有显示昵称 - senderRole = senderRemark || senderNickname - } else { - senderNickname = msg.senderUsername - senderRemark = '' - senderRole = msg.senderUsername - } } else { // 单聊对方消息 - 用 getContact 获取联系人详情 senderWxid = sessionId @@ -4676,12 +4788,6 @@ class ExportService { } } - // 获取群昵称 (仅群聊且完整列模式) - if (isGroup && !useCompactColumns && senderWxid) { - senderGroupNickname = this.resolveGroupNicknameByCandidates(groupNicknamesMap, [senderWxid]) - } - - const row = worksheet.getRow(currentRow) row.height = 24 @@ -4857,6 +4963,7 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -4919,7 +5026,8 @@ class ExportService { ? this.buildGroupNicknameIdCandidates([ ...Array.from(senderUsernames.values()), ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid + cleanedMyWxid, + rawMyWxid ]) : [] const groupNicknamesMap = isGroup @@ -5077,21 +5185,23 @@ class ExportService { let senderNickname: string let senderRemark = '' - if (msg.isSend) { + if (isGroup) { + const senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : (msg.senderUsername || ''), + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + senderWxid = senderProfile.wxid + senderNickname = senderProfile.nickname + senderRemark = senderProfile.remark + senderRole = senderProfile.displayName + } else if (msg.isSend) { senderRole = '我' senderWxid = cleanedMyWxid senderNickname = myInfo.displayName || cleanedMyWxid - } else if (isGroup && msg.senderUsername) { - senderWxid = msg.senderUsername - const contactDetail = await getContactCached(msg.senderUsername) - if (contactDetail.success && contactDetail.contact) { - senderNickname = contactDetail.contact.nickName || msg.senderUsername - senderRemark = contactDetail.contact.remark || '' - senderRole = senderRemark || senderNickname - } else { - senderNickname = msg.senderUsername - senderRole = msg.senderUsername - } } else { senderWxid = sessionId const contactDetail = await getContactCached(sessionId) @@ -5163,6 +5273,7 @@ class ExportService { const cleanedMyWxid = conn.cleanedWxid const isGroup = sessionId.includes('@chatroom') + const rawMyWxid = String(this.configService.get('myWxid') || '').trim() const sessionInfo = await this.getContactInfo(sessionId) const myInfo = await this.getContactInfo(cleanedMyWxid) @@ -5214,7 +5325,8 @@ class ExportService { ? this.buildGroupNicknameIdCandidates([ ...Array.from(senderUsernames.values()), ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid + cleanedMyWxid, + rawMyWxid ]) : [] const groupNicknamesMap = isGroup @@ -5344,7 +5456,17 @@ class ExportService { } let talker = myInfo.displayName || '我' - if (!msg.isSend) { + if (isGroup) { + const senderProfile = await this.resolveExportDisplayProfile( + msg.isSend ? cleanedMyWxid : senderWxid, + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + msg.isSend ? (myInfo.displayName || cleanedMyWxid) : senderWxid, + msg.isSend ? [rawMyWxid, cleanedMyWxid] : [] + ) + talker = senderProfile.displayName + } else if (!msg.isSend) { const contactDetail = await getContactCached(senderWxid) const senderNickname = contactDetail.success && contactDetail.contact ? (contactDetail.contact.nickName || senderWxid) @@ -5584,7 +5706,8 @@ class ExportService { ? this.buildGroupNicknameIdCandidates([ ...Array.from(senderUsernames.values()), ...collected.rows.map(msg => msg.senderUsername), - cleanedMyWxid + cleanedMyWxid, + rawMyWxid ]) : [] const groupNicknamesMap = isGroup @@ -5792,11 +5915,16 @@ class ExportService { const isSenderMe = msg.isSend const senderInfo = collected.memberSet.get(msg.senderUsername)?.member - const senderName = isSenderMe - ? (myInfo.displayName || '我') - : (isGroup - ? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername) - : (sessionInfo.displayName || sessionId)) + const senderName = isGroup + ? (await this.resolveExportDisplayProfile( + isSenderMe ? cleanedMyWxid : (msg.senderUsername || cleanedMyWxid), + options.displayNamePreference, + getContactCached, + groupNicknamesMap, + isSenderMe ? (myInfo.displayName || cleanedMyWxid) : (senderInfo?.accountName || msg.senderUsername || ''), + isSenderMe ? [rawMyWxid, cleanedMyWxid] : [] + )).displayName + : (isSenderMe ? (myInfo.displayName || '我') : (sessionInfo.displayName || sessionId)) const avatarHtml = getAvatarHtml(isSenderMe ? cleanedMyWxid : msg.senderUsername, senderName) From 731f02266923e053c6e5075f441e91dba5a90a8b Mon Sep 17 00:00:00 2001 From: superclaw Date: Sat, 14 Mar 2026 14:16:03 +0800 Subject: [PATCH 08/11] chore: remove monitor debug logs and add log clear action --- electron/main.ts | 11 +++++++++++ electron/preload.ts | 1 + electron/services/chatService.ts | 5 ++--- electron/services/wcdbCore.ts | 27 +++++++++++++++++++++++---- electron/services/wcdbService.ts | 2 +- electron/wcdbWorker.ts | 6 ++++-- src/pages/SettingsPage.tsx | 18 ++++++++++++++++++ src/types/electron.d.ts | 1 + 8 files changed, 61 insertions(+), 10 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 46b96a9..31e2d34 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -972,6 +972,17 @@ function registerIpcHandlers() { } }) + ipcMain.handle('log:clear', async () => { + try { + const logPath = join(app.getPath('userData'), 'logs', 'wcdb.log') + await mkdir(dirname(logPath), { recursive: true }) + await writeFile(logPath, '', 'utf8') + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + }) + ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => { return exportCardDiagnosticsService.snapshot(options?.limit) }) diff --git a/electron/preload.ts b/electron/preload.ts index 2f2874c..2dcc561 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -70,6 +70,7 @@ contextBridge.exposeInMainWorld('electronAPI', { log: { getPath: () => ipcRenderer.invoke('log:getPath'), read: () => ipcRenderer.invoke('log:read'), + clear: () => ipcRenderer.invoke('log:clear'), debug: (data: any) => ipcRenderer.send('log:debug', data) }, diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 3a6741a..0b81fe2 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -359,16 +359,15 @@ class ChatService { // 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更 wcdbService.setMonitor((type, json) => { this.handleSessionStatsMonitorChange(type, json) + const windows = BrowserWindow.getAllWindows() // 广播给所有渲染进程窗口 - BrowserWindow.getAllWindows().forEach((win) => { + windows.forEach((win) => { if (!win.isDestroyed()) { win.webContents.send('wcdb-change', { type, json }) } }) }) } - }) - } /** * 预热 media 数据库列表缓存(后台异步执行) diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index c118a79..a56cf78 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -173,7 +173,6 @@ export class WcdbCore { } } catch {} } - this.connectMonitorPipe(pipePath) return true } catch (e) { @@ -194,8 +193,14 @@ export class WcdbCore { let buffer = '' this.monitorPipeClient.on('data', (data: Buffer) => { - buffer += data.toString('utf8') - const lines = buffer.split('\n') + const rawChunk = data.toString('utf8') + // macOS 侧可能使用 '\0' 或无换行分隔,统一归一化并兜底拆包 + const normalizedChunk = rawChunk + .replace(/\u0000/g, '\n') + .replace(/}\s*{/g, '}\n{') + + buffer += normalizedChunk + const lines = buffer.split(/\r?\n/) buffer = lines.pop() || '' for (const line of lines) { if (line.trim()) { @@ -207,9 +212,23 @@ export class WcdbCore { } } } + + // 兜底:如果没有分隔符但已形成完整 JSON,则直接上报 + const tail = buffer.trim() + if (tail.startsWith('{') && tail.endsWith('}')) { + try { + const parsed = JSON.parse(tail) + this.monitorCallback?.(parsed.action || 'update', tail) + buffer = '' + } catch { + // 不可解析则继续等待下一块数据 + } + } }) - this.monitorPipeClient.on('error', () => {}) + this.monitorPipeClient.on('error', () => { + // 保持静默,与现有错误处理策略一致 + }) this.monitorPipeClient.on('close', () => { this.monitorPipeClient = null diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 6aee8e9..2f5715f 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -136,7 +136,7 @@ export class WcdbService { */ setMonitor(callback: (type: string, json: string) => void): void { this.monitorListener = callback; - this.callWorker('setMonitor').catch(() => { }); + this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { }); } /** diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 333527a..8a49cad 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -20,15 +20,17 @@ if (parentPort) { result = { success: true } break case 'setMonitor': - core.setMonitor((type, json) => { + { + const monitorOk = core.setMonitor((type, json) => { parentPort!.postMessage({ id: -1, type: 'monitor', payload: { type, json } }) }) - result = { success: true } + result = { success: monitorOk } break + } case 'testConnection': result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid) break diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index db39015..7f36e75 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -897,6 +897,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { } } + const handleClearLog = async () => { + const confirmed = window.confirm('确定清空 wcdb.log 吗?') + if (!confirmed) return + try { + const result = await window.electronAPI.log.clear() + if (!result.success) { + showMessage(result.error || '清空日志失败', false) + return + } + showMessage('日志已清空', true) + } catch (e: any) { + showMessage(`清空日志失败: ${e}`, false) + } + } + const handleClearAnalyticsCache = async () => { if (isClearingCache) return setIsClearingAnalyticsCache(true) @@ -1427,6 +1442,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { + diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 64806fc..efe7735 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -69,6 +69,7 @@ export interface ElectronAPI { log: { getPath: () => Promise read: () => Promise<{ success: boolean; content?: string; error?: string }> + clear: () => Promise<{ success: boolean; error?: string }> debug: (data: any) => void } diagnostics: { From ee0e71d50ef5ce65706ee57be707c9b0f3676e89 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:30:56 +0800 Subject: [PATCH 09/11] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/UpdateDialog.scss | 77 ++++++++++++++++++-------------- src/components/UpdateDialog.tsx | 1 - src/pages/SettingsPage.tsx | 1 - 3 files changed, 44 insertions(+), 35 deletions(-) diff --git a/src/components/UpdateDialog.scss b/src/components/UpdateDialog.scss index f12a6d8..a1a4e39 100644 --- a/src/components/UpdateDialog.scss +++ b/src/components/UpdateDialog.scss @@ -14,7 +14,7 @@ .update-dialog { width: 680px; - background: #f5f5f5; + background: var(--bg-secondary, #f5f5f5); border-radius: 24px; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); overflow: hidden; @@ -25,7 +25,7 @@ /* Top Section (White/Gradient) */ .dialog-header { - background: #ffffff; + background: var(--bg-primary, #ffffff); padding: 40px 20px 30px; display: flex; flex-direction: column; @@ -41,14 +41,14 @@ left: -50px; width: 200px; height: 200px; - background: radial-gradient(circle, rgba(255, 235, 220, 0.4) 0%, rgba(255, 255, 255, 0) 70%); - opacity: 0.8; + background: radial-gradient(circle, rgba(255, 235, 220, 0.15) 0%, rgba(255, 255, 255, 0) 70%); + opacity: 0.5; pointer-events: none; } .version-tag { - background: #f0eee9; - color: #8c7b6e; + background: var(--bg-tertiary, #f0eee9); + color: var(--text-tertiary, #8c7b6e); padding: 4px 16px; border-radius: 12px; font-size: 13px; @@ -60,21 +60,21 @@ h2 { font-size: 32px; font-weight: 800; - color: #333333; + color: var(--text-primary, #333333); margin: 0 0 12px; letter-spacing: -0.5px; } .subtitle { font-size: 15px; - color: #999999; + color: var(--text-secondary, #999999); font-weight: 400; } } /* Content Section (Light Gray) */ .dialog-content { - background: #f2f2f2; + background: var(--bg-tertiary, #f2f2f2); padding: 24px 40px 40px; flex: 1; display: flex; @@ -87,7 +87,7 @@ margin-bottom: 30px; .icon-box { - background: #fbfbfb; // Beige-ish white + background: var(--bg-primary, #fbfbfb); width: 48px; height: 48px; border-radius: 16px; @@ -96,7 +96,7 @@ justify-content: center; margin-right: 20px; flex-shrink: 0; - color: #8c7b6e; + color: var(--text-tertiary, #8c7b6e); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03); svg { @@ -107,27 +107,38 @@ .text-box { flex: 1; - h3 { - font-size: 18px; + h1, h2, h3, h4, h5, h6 { + color: var(--text-primary, #333333); font-weight: 700; - color: #333333; - margin: 0 0 8px; + margin: 16px 0 8px; + + &:first-child { + margin-top: 0; + } + } + + h2 { + font-size: 16px; + } + + h3 { + font-size: 15px; } p { font-size: 14px; - color: #666666; + color: var(--text-secondary, #666666); line-height: 1.6; - margin: 0; + margin: 4px 0; } ul { - margin: 8px 0 0 18px; + margin: 4px 0 0 18px; padding: 0; li { font-size: 14px; - color: #666666; + color: var(--text-secondary, #666666); line-height: 1.6; } } @@ -142,19 +153,19 @@ justify-content: space-between; margin-bottom: 8px; font-size: 12px; - color: #888; + color: var(--text-secondary, #888); font-weight: 500; } .progress-bar-bg { height: 6px; - background: #e0e0e0; + background: var(--border-color, #e0e0e0); border-radius: 3px; overflow: hidden; .progress-bar-fill { height: 100%; - background: #000000; + background: var(--text-primary, #000000); border-radius: 3px; transition: width 0.3s ease; } @@ -164,7 +175,7 @@ text-align: center; margin-top: 12px; font-size: 13px; - color: #666; + color: var(--text-secondary, #666); } } @@ -175,8 +186,8 @@ .btn-ignore { background: transparent; - color: #666666; - border: 1px solid #d0d0d0; + color: var(--text-secondary, #666666); + border: 1px solid var(--border-color, #d0d0d0); padding: 16px 32px; border-radius: 20px; font-size: 16px; @@ -185,9 +196,9 @@ transition: all 0.2s; &:hover { - background: #f5f5f5; - border-color: #999999; - color: #333333; + background: var(--bg-hover, #f5f5f5); + border-color: var(--text-secondary, #999999); + color: var(--text-primary, #333333); } &:active { @@ -196,11 +207,11 @@ } .btn-update { - background: #000000; - color: #ffffff; + background: var(--text-primary, #000000); + color: var(--bg-primary, #ffffff); border: none; padding: 16px 48px; - border-radius: 20px; // Pill shape + border-radius: 20px; font-size: 16px; font-weight: 600; cursor: pointer; @@ -231,7 +242,7 @@ right: 16px; background: rgba(0, 0, 0, 0.05); border: none; - color: #999; + color: var(--text-secondary, #999); cursor: pointer; width: 32px; height: 32px; @@ -244,7 +255,7 @@ &:hover { background: rgba(0, 0, 0, 0.1); - color: #333; + color: var(--text-primary, #333); transform: rotate(90deg); } } diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx index bafdb18..0dce27d 100644 --- a/src/components/UpdateDialog.tsx +++ b/src/components/UpdateDialog.tsx @@ -89,7 +89,6 @@ const UpdateDialog: React.FC = ({
-

优化

{updateInfo.releaseNotes ? (
) : ( diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 7f36e75..ae49e8e 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -2061,7 +2061,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { {isCheckingUpdate ? '检查中...' : '检查更新'} -
)}
From bdc7f8a8a841e409f03b5cca0799557add00ed95 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 14 Mar 2026 15:40:44 +0800 Subject: [PATCH 10/11] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58b8b58..477da32 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,9 @@ on: permissions: contents: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + jobs: release-mac-arm64: runs-on: macos-14 @@ -21,7 +24,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 22.12 + node-version: 24 cache: "npm" - name: Install Dependencies @@ -73,7 +76,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 22.12 + node-version: 24 cache: 'npm' - name: Install Dependencies From 009a0d64b82952562c59da4122a607d86b02d470 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 14 Mar 2026 15:58:50 +0800 Subject: [PATCH 11/11] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 477da32..c46b41b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,12 +17,12 @@ jobs: steps: - name: Check out git repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: 24 cache: "npm" @@ -69,12 +69,12 @@ jobs: steps: - name: Check out git repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Install Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: 24 cache: 'npm'