From 48e5ce807d47a36ebe75e146b0f9be17e742b2b1 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:24:31 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=A1=E5=88=92=E4=BC=98=E5=8C=96=20P2/5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/preload.ts | 10 +- electron/services/exportService.ts | 79 ++- electron/services/imageDecryptService.ts | 108 +++- electron/services/videoService.ts | 789 +++++++++++++++-------- electron/services/wcdbCore.ts | 134 +++- electron/services/wcdbService.ts | 12 + electron/wcdbWorker.ts | 6 + src/pages/ChatPage.scss | 5 + src/pages/ChatPage.tsx | 570 ++++++++++------ 9 files changed, 1207 insertions(+), 506 deletions(-) diff --git a/electron/preload.ts b/electron/preload.ts index 47fe7ef..b225964 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -242,12 +242,14 @@ contextBridge.exposeInMainWorld('electronAPI', { preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => ipcRenderer.invoke('image:preload', payloads), onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { - ipcRenderer.on('image:updateAvailable', (_, payload) => callback(payload)) - return () => ipcRenderer.removeAllListeners('image:updateAvailable') + const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload) + ipcRenderer.on('image:updateAvailable', listener) + return () => ipcRenderer.removeListener('image:updateAvailable', listener) }, onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => { - ipcRenderer.on('image:cacheResolved', (_, payload) => callback(payload)) - return () => ipcRenderer.removeAllListeners('image:cacheResolved') + const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload) + ipcRenderer.on('image:cacheResolved', listener) + return () => ipcRenderer.removeListener('image:cacheResolved', listener) } }, diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 16b0d53..49ce8f2 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -2332,7 +2332,8 @@ class ExportService { sessionId, imageMd5, imageDatName, - force: true // 导出优先高清,失败再回退缩略图 + force: true, // 导出优先高清,失败再回退缩略图 + preferFilePath: true }) if (!result.success || !result.localPath) { @@ -2341,7 +2342,8 @@ class ExportService { const thumbResult = await imageDecryptService.resolveCachedImage({ sessionId, imageMd5, - imageDatName + imageDatName, + preferFilePath: true }) if (thumbResult.success && thumbResult.localPath) { console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) @@ -2404,6 +2406,55 @@ class ExportService { } } + private async preloadMediaLookupCaches( + _sessionId: string, + messages: any[], + options: { exportImages?: boolean; exportVideos?: boolean }, + control?: ExportTaskControl + ): Promise { + if (!Array.isArray(messages) || messages.length === 0) return + + const md5Pattern = /^[a-f0-9]{32}$/i + const imageMd5Set = new Set() + const videoMd5Set = new Set() + + let scanIndex = 0 + for (const msg of messages) { + if ((scanIndex++ & 0x7f) === 0) { + this.throwIfStopRequested(control) + } + + if (options.exportImages && msg?.localType === 3) { + const imageMd5 = String(msg?.imageMd5 || '').trim().toLowerCase() + if (imageMd5) { + imageMd5Set.add(imageMd5) + } else { + const imageDatName = String(msg?.imageDatName || '').trim().toLowerCase() + if (md5Pattern.test(imageDatName)) { + imageMd5Set.add(imageDatName) + } + } + } + + if (options.exportVideos && msg?.localType === 43) { + const videoMd5 = String(msg?.videoMd5 || '').trim().toLowerCase() + if (videoMd5) videoMd5Set.add(videoMd5) + } + } + + const preloadTasks: Array> = [] + if (imageMd5Set.size > 0) { + preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))) + } + if (videoMd5Set.size > 0) { + preloadTasks.push(videoService.preloadVideoHardlinkMd5s(Array.from(videoMd5Set))) + } + if (preloadTasks.length === 0) return + + await Promise.all(preloadTasks.map((task) => task.catch(() => { }))) + this.throwIfStopRequested(control) + } + /** * 导出语音文件 */ @@ -3644,6 +3695,10 @@ class ExportService { const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) if (voiceMediaMessages.length > 0) { await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) @@ -4127,6 +4182,10 @@ class ExportService { const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) if (voiceMediaMessages.length > 0) { await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) @@ -4934,6 +4993,10 @@ class ExportService { const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) if (voiceMediaMessages.length > 0) { await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) @@ -5600,6 +5663,10 @@ class ExportService { const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) if (voiceMediaMessages.length > 0) { await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) @@ -5938,6 +6005,10 @@ class ExportService { const mediaDirCache = new Set() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) if (voiceMediaMessages.length > 0) { await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) @@ -6344,6 +6415,10 @@ class ExportService { const mediaCache = new Map() if (mediaMessages.length > 0) { + await this.preloadMediaLookupCaches(sessionId, mediaMessages, { + exportImages: options.exportImages, + exportVideos: options.exportVideos + }, control) const voiceMediaMessages = mediaMessages.filter(msg => msg.localType === 34) if (voiceMediaMessages.length > 0) { await this.preloadVoiceWavCache(sessionId, voiceMediaMessages, control) diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index d1b5b21..32a2ae0 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -55,6 +55,17 @@ type DecryptResult = { isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) } +type CachedImagePayload = { + sessionId?: string + imageMd5?: string + imageDatName?: string + preferFilePath?: boolean +} + +type DecryptImagePayload = CachedImagePayload & { + force?: boolean +} + export class ImageDecryptService { private configService = new ConfigService() private resolvedCache = new Map() @@ -100,7 +111,7 @@ export class ImageDecryptService { } } - async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise { + async resolveCachedImage(payload: CachedImagePayload): Promise { await this.ensureCacheIndexed() const cacheKeys = this.getCacheKeys(payload) const cacheKey = cacheKeys[0] @@ -110,7 +121,7 @@ export class ImageDecryptService { for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) if (cached && existsSync(cached) && this.isImageFile(cached)) { - const dataUrl = this.fileToDataUrl(cached) + const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) const isThumb = this.isThumbnailPath(cached) const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false if (isThumb) { @@ -118,8 +129,8 @@ export class ImageDecryptService { } else { this.updateFlags.delete(key) } - this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached)) - return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate } + this.emitCacheResolved(payload, key, this.resolveEmitPath(cached, payload.preferFilePath)) + return { success: true, localPath, hasUpdate } } if (cached && !this.isImageFile(cached)) { this.resolvedCache.delete(key) @@ -130,7 +141,7 @@ export class ImageDecryptService { const existing = this.findCachedOutput(key, false, payload.sessionId) if (existing) { this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing) - const dataUrl = this.fileToDataUrl(existing) + const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath) const isThumb = this.isThumbnailPath(existing) const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false if (isThumb) { @@ -138,15 +149,15 @@ export class ImageDecryptService { } else { this.updateFlags.delete(key) } - this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing)) - return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate } + this.emitCacheResolved(payload, key, this.resolveEmitPath(existing, payload.preferFilePath)) + return { success: true, localPath, hasUpdate } } } this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: '未找到缓存图片' } } - async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise { + async decryptImage(payload: DecryptImagePayload): Promise { await this.ensureCacheIndexed() const cacheKeys = this.getCacheKeys(payload) const cacheKey = cacheKeys[0] @@ -160,9 +171,8 @@ export class ImageDecryptService { if (cached && existsSync(cached) && this.isImageFile(cached) && !this.isThumbnailPath(cached)) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached) this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) - const dataUrl = this.fileToDataUrl(cached) - const localPath = dataUrl || this.filePathToUrl(cached) - this.emitCacheResolved(payload, cacheKey, localPath) + const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath)) return { success: true, localPath } } if (cached && !this.isImageFile(cached)) { @@ -175,9 +185,8 @@ export class ImageDecryptService { if (!existingHd || this.isThumbnailPath(existingHd)) continue this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd) this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) - const dataUrl = this.fileToDataUrl(existingHd) - const localPath = dataUrl || this.filePathToUrl(existingHd) - this.emitCacheResolved(payload, cacheKey, localPath) + const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath)) return { success: true, localPath } } } @@ -185,9 +194,8 @@ export class ImageDecryptService { if (!payload.force) { const cached = this.resolvedCache.get(cacheKey) if (cached && existsSync(cached) && this.isImageFile(cached)) { - const dataUrl = this.fileToDataUrl(cached) - const localPath = dataUrl || this.filePathToUrl(cached) - this.emitCacheResolved(payload, cacheKey, localPath) + const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath)) return { success: true, localPath } } if (cached && !this.isImageFile(cached)) { @@ -207,8 +215,44 @@ export class ImageDecryptService { } } + async preloadImageHardlinkMd5s(md5List: string[]): Promise { + const normalizedList = Array.from( + new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)) + ) + if (normalizedList.length === 0) return + + const wxid = this.configService.get('myWxid') + const dbPath = this.configService.get('dbPath') + if (!wxid || !dbPath) return + + const accountDir = this.resolveAccountDir(dbPath, wxid) + if (!accountDir) return + + try { + const ready = await this.ensureWcdbReady() + if (!ready) return + const requests = normalizedList.map((md5) => ({ md5, accountDir })) + const result = await wcdbService.resolveImageHardlinkBatch(requests) + if (!result.success || !Array.isArray(result.rows)) return + + for (const row of result.rows) { + const md5 = String(row?.md5 || '').trim().toLowerCase() + if (!md5) continue + const fullPath = String(row?.data?.full_path || '').trim() + if (!fullPath || !existsSync(fullPath)) continue + this.cacheDatPath(accountDir, md5, fullPath) + const fileName = String(row?.data?.file_name || '').trim().toLowerCase() + if (fileName) { + this.cacheDatPath(accountDir, fileName, fullPath) + } + } + } catch { + // ignore preload failures + } + } + private async decryptImageInternal( - payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }, + payload: DecryptImagePayload, cacheKey: string ): Promise { this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force }) @@ -248,10 +292,9 @@ export class ImageDecryptService { if (!extname(datPath).toLowerCase().includes('dat')) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) - const dataUrl = this.fileToDataUrl(datPath) - const localPath = dataUrl || this.filePathToUrl(datPath) + const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath) const isThumb = this.isThumbnailPath(datPath) - this.emitCacheResolved(payload, cacheKey, localPath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath)) return { success: true, localPath, isThumb } } @@ -263,10 +306,9 @@ export class ImageDecryptService { // 如果要求高清但找到的是缩略图,继续解密高清图 if (!(payload.force && !isHd)) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing) - const dataUrl = this.fileToDataUrl(existing) - const localPath = dataUrl || this.filePathToUrl(existing) + const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath) const isThumb = this.isThumbnailPath(existing) - this.emitCacheResolved(payload, cacheKey, localPath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath)) return { success: true, localPath, isThumb } } } @@ -326,9 +368,11 @@ export class ImageDecryptService { if (!isThumb) { this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) } - const dataUrl = this.bufferToDataUrl(decrypted, finalExt) - const localPath = dataUrl || this.filePathToUrl(outputPath) - this.emitCacheResolved(payload, cacheKey, localPath) + const localPath = payload.preferFilePath + ? outputPath + : (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath)) + const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath) + this.emitCacheResolved(payload, cacheKey, emitPath) return { success: true, localPath, isThumb } } catch (e) { this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) @@ -1494,6 +1538,16 @@ export class ImageDecryptService { return `data:${mimeType};base64,${buffer.toString('base64')}` } + private resolveLocalPathForPayload(filePath: string, preferFilePath?: boolean): string { + if (preferFilePath) return filePath + return this.resolveEmitPath(filePath, false) + } + + private resolveEmitPath(filePath: string, preferFilePath?: boolean): string { + if (preferFilePath) return this.filePathToUrl(filePath) + return this.fileToDataUrl(filePath) || this.filePathToUrl(filePath) + } + private fileToDataUrl(filePath: string): string | null { try { const ext = extname(filePath).toLowerCase() diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 09aad4d..cdd892a 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -5,310 +5,539 @@ import { ConfigService } from './config' import { wcdbService } from './wcdbService' export interface VideoInfo { - videoUrl?: string // 视频文件路径(用于 readFile) - coverUrl?: string // 封面 data URL - thumbUrl?: string // 缩略图 data URL - exists: boolean + videoUrl?: string // 视频文件路径(用于 readFile) + coverUrl?: string // 封面 data URL + thumbUrl?: string // 缩略图 data URL + exists: boolean +} + +interface TimedCacheEntry { + value: T + expiresAt: number +} + +interface VideoIndexEntry { + videoPath?: string + coverPath?: string + thumbPath?: string } class VideoService { - private configService: ConfigService + private configService: ConfigService + private hardlinkResolveCache = new Map>() + private videoInfoCache = new Map>() + private videoDirIndexCache = new Map>>() + private pendingVideoInfo = new Map>() + private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 + private readonly videoInfoCacheTtlMs = 2 * 60 * 1000 + private readonly videoIndexCacheTtlMs = 90 * 1000 + private readonly maxCacheEntries = 2000 + private readonly maxIndexEntries = 6 - constructor() { - this.configService = new ConfigService() + constructor() { + this.configService = new ConfigService() + } + + private log(message: string, meta?: Record): void { + try { + const timestamp = new Date().toISOString() + const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' + const logDir = join(app.getPath('userData'), 'logs') + if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }) + appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8') + } catch { } + } + + private readTimedCache(cache: Map>, key: string): T | undefined { + const hit = cache.get(key) + if (!hit) return undefined + if (hit.expiresAt <= Date.now()) { + cache.delete(key) + return undefined + } + return hit.value + } + + private writeTimedCache( + cache: Map>, + key: string, + value: T, + ttlMs: number, + maxEntries: number + ): void { + cache.set(key, { value, expiresAt: Date.now() + ttlMs }) + if (cache.size <= maxEntries) return + + const now = Date.now() + for (const [cacheKey, entry] of cache) { + if (entry.expiresAt <= now) { + cache.delete(cacheKey) + } } - private log(message: string, meta?: Record): void { - try { - const timestamp = new Date().toISOString() - const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' - const logDir = join(app.getPath('userData'), 'logs') - if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }) - appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8') - } catch {} + while (cache.size > maxEntries) { + const oldestKey = cache.keys().next().value as string | undefined + if (!oldestKey) break + cache.delete(oldestKey) + } + } + + /** + * 获取数据库根目录 + */ + private getDbPath(): string { + return this.configService.get('dbPath') || '' + } + + /** + * 获取当前用户的wxid + */ + private getMyWxid(): string { + return this.configService.get('myWxid') || '' + } + + /** + * 清理 wxid 目录名(去掉后缀) + */ + private cleanWxid(wxid: string): string { + const trimmed = wxid.trim() + if (!trimmed) return trimmed + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed } - /** - * 获取数据库根目录 - */ - private getDbPath(): string { - return this.configService.get('dbPath') || '' + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + if (suffixMatch) return suffixMatch[1] + + return trimmed + } + + private getScopeKey(dbPath: string, wxid: string): string { + return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase() + } + + private resolveVideoBaseDir(dbPath: string, wxid: string): string { + const cleanedWxid = this.cleanWxid(wxid) + const dbPathLower = dbPath.toLowerCase() + const wxidLower = wxid.toLowerCase() + const cleanedWxidLower = cleanedWxid.toLowerCase() + const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) + if (dbPathContainsWxid) { + return join(dbPath, 'msg', 'video') + } + return join(dbPath, wxid, 'msg', 'video') + } + + private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] { + const dbPathLower = dbPath.toLowerCase() + const wxidLower = wxid.toLowerCase() + const cleanedWxidLower = cleanedWxid.toLowerCase() + const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) + + if (dbPathContainsWxid) { + return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')] } - /** - * 获取当前用户的wxid - */ - private getMyWxid(): string { - return this.configService.get('myWxid') || '' + return [ + join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'), + join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db') + ] + } + + /** + * 从 video_hardlink_info_v4 表查询视频文件名 + * 使用 wcdb 专属接口查询加密的 hardlink.db + */ + private async resolveVideoHardlinks( + md5List: string[], + dbPath: string, + wxid: string, + cleanedWxid: string + ): Promise> { + const scopeKey = this.getScopeKey(dbPath, wxid) + const normalizedList = Array.from( + new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)) + ) + const resolvedMap = new Map() + let unresolved = [...normalizedList] + + for (const md5 of normalizedList) { + const cacheKey = `${scopeKey}|${md5}` + const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey) + if (cached === undefined) continue + if (cached) resolvedMap.set(md5, cached) + unresolved = unresolved.filter((item) => item !== md5) } - /** - * 获取缓存目录(解密后的数据库存放位置) - */ - private getCachePath(): string { - return this.configService.getCacheBasePath() - } + if (unresolved.length === 0) return resolvedMap - /** - * 清理 wxid 目录名(去掉后缀) - */ - private cleanWxid(wxid: string): string { - const trimmed = wxid.trim() - if (!trimmed) return trimmed - - if (trimmed.toLowerCase().startsWith('wxid_')) { - const match = trimmed.match(/^(wxid_[^_]+)/i) - if (match) return match[1] - return trimmed - } - - const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) - if (suffixMatch) return suffixMatch[1] - - return trimmed - } - - /** - * 从 video_hardlink_info_v4 表查询视频文件名 - * 使用 wcdb 专属接口查询加密的 hardlink.db - */ - private async queryVideoFileName(md5: string): Promise { - const dbPath = this.getDbPath() - const wxid = this.getMyWxid() - const cleanedWxid = this.cleanWxid(wxid) - - this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, dbPath }) - - if (!wxid) { - this.log('queryVideoFileName: wxid 为空') - return undefined - } - - // 使用 wcdbService.execQuery 查询加密的 hardlink.db - if (dbPath) { - const dbPathLower = dbPath.toLowerCase() - const wxidLower = wxid.toLowerCase() - const cleanedWxidLower = cleanedWxid.toLowerCase() - const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower) - - const encryptedDbPaths: string[] = [] - if (dbPathContainsWxid) { - encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')) - } else { - encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db')) - encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')) - } - - for (const p of encryptedDbPaths) { - if (existsSync(p)) { - try { - this.log('尝试加密 hardlink.db', { path: p }) - const result = await wcdbService.resolveVideoHardlinkMd5(md5, p) - if (result.success && result.data?.resolved_md5) { - const realMd5 = String(result.data.resolved_md5) - this.log('加密 hardlink.db 命中', { file_name: result.data.file_name, realMd5 }) - return realMd5 - } - this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) }) - } catch (e) { - this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) }) - } - } else { - this.log('加密 hardlink.db 不存在', { path: p }) - } - } - } - this.log('queryVideoFileName: 所有方法均未找到', { md5 }) - return undefined - } - - /** - * 将文件转换为 data URL - */ - private fileToDataUrl(filePath: string, mimeType: string): string | undefined { - try { - if (!existsSync(filePath)) return undefined - const buffer = readFileSync(filePath) - return `data:${mimeType};base64,${buffer.toString('base64')}` - } catch { - return undefined - } - } - - /** - * 根据视频MD5获取视频文件信息 - * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ - * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg - */ - async getVideoInfo(videoMd5: string): Promise { - const dbPath = this.getDbPath() - const wxid = this.getMyWxid() - - this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid }) - - if (!dbPath || !wxid || !videoMd5) { - this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 }) - return { exists: false } - } - - // 先尝试从数据库查询真正的视频文件名 - const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 - this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 }) - - // 检查 dbPath 是否已经包含 wxid,避免重复拼接 - const dbPathLower = dbPath.toLowerCase() - const wxidLower = wxid.toLowerCase() - const cleanedWxid = this.cleanWxid(wxid) - - let videoBaseDir: string - if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) { - videoBaseDir = join(dbPath, 'msg', 'video') + const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid) + for (const p of encryptedDbPaths) { + if (!existsSync(p) || unresolved.length === 0) continue + const requests = unresolved.map((md5) => ({ md5, dbPath: p })) + try { + const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests) + if (batchResult.success && Array.isArray(batchResult.rows)) { + for (const row of batchResult.rows) { + const index = Number.isFinite(Number(row?.index)) ? Math.floor(Number(row?.index)) : -1 + const inputMd5 = index >= 0 && index < requests.length + ? requests[index].md5 + : String(row?.md5 || '').trim().toLowerCase() + if (!inputMd5) continue + const resolvedMd5 = row?.success && row?.data?.resolved_md5 + ? String(row.data.resolved_md5).trim().toLowerCase() + : '' + if (!resolvedMd5) continue + const cacheKey = `${scopeKey}|${inputMd5}` + this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries) + resolvedMap.set(inputMd5, resolvedMd5) + } } else { - videoBaseDir = join(dbPath, wxid, 'msg', 'video') + // 兼容不支持批量接口的版本,回退单条请求。 + for (const req of requests) { + try { + const single = await wcdbService.resolveVideoHardlinkMd5(req.md5, req.dbPath) + const resolvedMd5 = single.success && single.data?.resolved_md5 + ? String(single.data.resolved_md5).trim().toLowerCase() + : '' + if (!resolvedMd5) continue + const cacheKey = `${scopeKey}|${req.md5}` + this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries) + resolvedMap.set(req.md5, resolvedMd5) + } catch { } + } } + } catch (e) { + this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) }) + } - this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) }) - - if (!existsSync(videoBaseDir)) { - this.log('getVideoInfo: videoBaseDir 不存在') - return { exists: false } - } - - // 遍历年月目录查找视频文件 - try { - const allDirs = readdirSync(videoBaseDir) - const yearMonthDirs = allDirs - .filter(dir => { - const dirPath = join(videoBaseDir, dir) - return statSync(dirPath).isDirectory() - }) - .sort((a, b) => b.localeCompare(a)) - - this.log('扫描目录', { dirs: yearMonthDirs }) - - for (const yearMonth of yearMonthDirs) { - const dirPath = join(videoBaseDir, yearMonth) - const videoPath = join(dirPath, `${realVideoMd5}.mp4`) - - if (existsSync(videoPath)) { - // 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw,但封面不带) - const baseMd5 = realVideoMd5.replace(/_raw$/, '') - const coverPath = join(dirPath, `${baseMd5}.jpg`) - const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`) - - // 列出同目录下与该 md5 相关的所有文件,帮助排查封面命名 - const allFiles = readdirSync(dirPath) - const relatedFiles = allFiles.filter(f => f.toLowerCase().startsWith(realVideoMd5.slice(0, 8).toLowerCase())) - this.log('找到视频,相关文件列表', { - videoPath, - coverExists: existsSync(coverPath), - thumbExists: existsSync(thumbPath), - relatedFiles, - coverPath, - thumbPath - }) - - return { - videoUrl: videoPath, - coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), - thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), - exists: true - } - } - } - - // 没找到,列出所有目录里的 mp4 文件帮助排查(最多每目录 10 个) - this.log('未找到视频,开始全目录扫描', { - lookingForOriginal: `${videoMd5}.mp4`, - lookingForResolved: `${realVideoMd5}.mp4`, - hardlinkResolved: realVideoMd5 !== videoMd5 - }) - for (const yearMonth of yearMonthDirs) { - const dirPath = join(videoBaseDir, yearMonth) - try { - const allFiles = readdirSync(dirPath) - const mp4Files = allFiles.filter(f => f.endsWith('.mp4')).slice(0, 10) - // 检查原始 md5 是否部分匹配(前8位) - const partialMatch = mp4Files.filter(f => f.toLowerCase().startsWith(videoMd5.slice(0, 8).toLowerCase())) - this.log(`目录 ${yearMonth} 扫描结果`, { - totalFiles: allFiles.length, - mp4Count: allFiles.filter(f => f.endsWith('.mp4')).length, - sampleMp4: mp4Files, - partialMatchByOriginalMd5: partialMatch - }) - } catch (e) { - this.log(`目录 ${yearMonth} 读取失败`, { error: String(e) }) - } - } - } catch (e) { - this.log('getVideoInfo 遍历出错', { error: String(e) }) - } - - this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 }) - return { exists: false } + unresolved = unresolved.filter((md5) => !resolvedMap.has(md5)) } - /** - * 根据消息内容解析视频MD5 - */ - parseVideoMd5(content: string): string | undefined { - if (!content) return undefined + for (const md5 of unresolved) { + const cacheKey = `${scopeKey}|${md5}` + this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries) + } - // 打印原始 XML 前 800 字符,帮助排查自己发的视频结构 - this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) }) + return resolvedMap + } + private async queryVideoFileName(md5: string): Promise { + const normalizedMd5 = String(md5 || '').trim().toLowerCase() + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + const cleanedWxid = this.cleanWxid(wxid) + + this.log('queryVideoFileName 开始', { md5: normalizedMd5, wxid, cleanedWxid, dbPath }) + + if (!normalizedMd5 || !wxid || !dbPath) { + this.log('queryVideoFileName: 参数缺失', { hasMd5: !!normalizedMd5, hasWxid: !!wxid, hasDbPath: !!dbPath }) + return undefined + } + + const resolvedMap = await this.resolveVideoHardlinks([normalizedMd5], dbPath, wxid, cleanedWxid) + const resolved = resolvedMap.get(normalizedMd5) + if (resolved) { + this.log('queryVideoFileName 命中', { input: normalizedMd5, resolved }) + return resolved + } + return undefined + } + + async preloadVideoHardlinkMd5s(md5List: string[]): Promise { + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + const cleanedWxid = this.cleanWxid(wxid) + if (!dbPath || !wxid) return + await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid) + } + + /** + * 将文件转换为 data URL + */ + private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined { + try { + if (!filePath || !existsSync(filePath)) return undefined + const buffer = readFileSync(filePath) + return `data:${mimeType};base64,${buffer.toString('base64')}` + } catch { + return undefined + } + } + + private getOrBuildVideoIndex(videoBaseDir: string): Map { + const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir) + if (cached) return cached + + const index = new Map() + const ensureEntry = (key: string): VideoIndexEntry => { + let entry = index.get(key) + if (!entry) { + entry = {} + index.set(key, entry) + } + return entry + } + + try { + const yearMonthDirs = readdirSync(videoBaseDir) + .filter((dir) => { + const dirPath = join(videoBaseDir, dir) + try { + return statSync(dirPath).isDirectory() + } catch { + return false + } + }) + .sort((a, b) => b.localeCompare(a)) + + for (const yearMonth of yearMonthDirs) { + const dirPath = join(videoBaseDir, yearMonth) + let files: string[] = [] try { - // 收集所有 md5 相关属性,方便对比 - const allMd5Attrs: string[] = [] - const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi - let match - while ((match = md5Regex.exec(content)) !== null) { - allMd5Attrs.push(match[0]) - } - this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs }) - - // 方法1:从 提取(收到的视频) - const videoMsgMd5Match = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (videoMsgMd5Match) { - this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] }) - return videoMsgMd5Match[1].toLowerCase() - } - - // 方法2:从 提取(自己发的视频,没有 md5 只有 rawmd5) - const rawMd5Match = /]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (rawMd5Match) { - this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] }) - return rawMd5Match[1].toLowerCase() - } - - // 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等) - const attrMatch = /(?... 标签 - const md5TagMatch = /([a-fA-F0-9]+)<\/md5>/i.exec(content) - if (md5TagMatch) { - this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] }) - return md5TagMatch[1].toLowerCase() - } - - // 方法5:兜底取 rawmd5 属性(任意位置) - const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) - if (rawMd5Fallback) { - this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] }) - return rawMd5Fallback[1].toLowerCase() - } - - this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length }) - } catch (e) { - this.log('parseVideoMd5 异常', { error: String(e) }) + files = readdirSync(dirPath) + } catch { + continue } - return undefined + for (const file of files) { + const lower = file.toLowerCase() + const fullPath = join(dirPath, file) + + if (lower.endsWith('.mp4')) { + const md5 = lower.slice(0, -4) + const entry = ensureEntry(md5) + if (!entry.videoPath) entry.videoPath = fullPath + if (md5.endsWith('_raw')) { + const baseMd5 = md5.replace(/_raw$/, '') + const baseEntry = ensureEntry(baseMd5) + if (!baseEntry.videoPath) baseEntry.videoPath = fullPath + } + continue + } + + if (!lower.endsWith('.jpg')) continue + const jpgBase = lower.slice(0, -4) + if (jpgBase.endsWith('_thumb')) { + const baseMd5 = jpgBase.slice(0, -6) + const entry = ensureEntry(baseMd5) + if (!entry.thumbPath) entry.thumbPath = fullPath + } else { + const entry = ensureEntry(jpgBase) + if (!entry.coverPath) entry.coverPath = fullPath + } + } + } + + for (const [key, entry] of index) { + if (!key.endsWith('_raw')) continue + const baseKey = key.replace(/_raw$/, '') + const baseEntry = index.get(baseKey) + if (!baseEntry) continue + if (!entry.coverPath) entry.coverPath = baseEntry.coverPath + if (!entry.thumbPath) entry.thumbPath = baseEntry.thumbPath + } + } catch (e) { + this.log('构建视频索引失败', { videoBaseDir, error: String(e) }) } + + this.writeTimedCache( + this.videoDirIndexCache, + videoBaseDir, + index, + this.videoIndexCacheTtlMs, + this.maxIndexEntries + ) + return index + } + + private getVideoInfoFromIndex(index: Map, md5: string): VideoInfo | null { + const normalizedMd5 = String(md5 || '').trim().toLowerCase() + if (!normalizedMd5) return null + + const candidates = [normalizedMd5] + const baseMd5 = normalizedMd5.replace(/_raw$/, '') + if (baseMd5 !== normalizedMd5) { + candidates.push(baseMd5) + } else { + candidates.push(`${normalizedMd5}_raw`) + } + + for (const key of candidates) { + const entry = index.get(key) + if (!entry?.videoPath) continue + if (!existsSync(entry.videoPath)) continue + return { + videoUrl: entry.videoPath, + coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'), + thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'), + exists: true + } + } + + return null + } + + private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string): VideoInfo | null { + try { + const yearMonthDirs = readdirSync(videoBaseDir) + .filter((dir) => { + const dirPath = join(videoBaseDir, dir) + try { + return statSync(dirPath).isDirectory() + } catch { + return false + } + }) + .sort((a, b) => b.localeCompare(a)) + + for (const yearMonth of yearMonthDirs) { + const dirPath = join(videoBaseDir, yearMonth) + const videoPath = join(dirPath, `${realVideoMd5}.mp4`) + if (!existsSync(videoPath)) continue + const baseMd5 = realVideoMd5.replace(/_raw$/, '') + const coverPath = join(dirPath, `${baseMd5}.jpg`) + const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`) + return { + videoUrl: videoPath, + coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), + thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), + exists: true + } + } + } catch (e) { + this.log('fallback 扫描视频目录失败', { error: String(e) }) + } + return null + } + + /** + * 根据视频MD5获取视频文件信息 + * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ + * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg + */ + async getVideoInfo(videoMd5: string): Promise { + const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase() + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + + this.log('getVideoInfo 开始', { videoMd5: normalizedMd5, dbPath, wxid }) + + if (!dbPath || !wxid || !normalizedMd5) { + this.log('getVideoInfo: 参数缺失', { hasDbPath: !!dbPath, hasWxid: !!wxid, hasVideoMd5: !!normalizedMd5 }) + return { exists: false } + } + + const scopeKey = this.getScopeKey(dbPath, wxid) + const cacheKey = `${scopeKey}|${normalizedMd5}` + + const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey) + if (cachedInfo) return cachedInfo + + const pending = this.pendingVideoInfo.get(cacheKey) + if (pending) return pending + + const task = (async (): Promise => { + const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5 + const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid) + + if (!existsSync(videoBaseDir)) { + const miss = { exists: false } + this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries) + return miss + } + + const index = this.getOrBuildVideoIndex(videoBaseDir) + const indexed = this.getVideoInfoFromIndex(index, realVideoMd5) + if (indexed) { + this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries) + return indexed + } + + const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5) + if (fallback) { + this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries) + return fallback + } + + const miss = { exists: false } + this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries) + this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 }) + return miss + })() + + this.pendingVideoInfo.set(cacheKey, task) + try { + return await task + } finally { + this.pendingVideoInfo.delete(cacheKey) + } + } + + /** + * 根据消息内容解析视频MD5 + */ + parseVideoMd5(content: string): string | undefined { + if (!content) return undefined + + // 打印原始 XML 前 800 字符,帮助排查自己发的视频结构 + this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) }) + + try { + // 收集所有 md5 相关属性,方便对比 + const allMd5Attrs: string[] = [] + const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi + let match + while ((match = md5Regex.exec(content)) !== null) { + allMd5Attrs.push(match[0]) + } + this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs }) + + // 方法1:从 提取(收到的视频) + const videoMsgMd5Match = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (videoMsgMd5Match) { + this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] }) + return videoMsgMd5Match[1].toLowerCase() + } + + // 方法2:从 提取(自己发的视频,没有 md5 只有 rawmd5) + const rawMd5Match = /]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (rawMd5Match) { + this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] }) + return rawMd5Match[1].toLowerCase() + } + + // 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等) + const attrMatch = /(?... 标签 + const md5TagMatch = /([a-fA-F0-9]+)<\/md5>/i.exec(content) + if (md5TagMatch) { + this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] }) + return md5TagMatch[1].toLowerCase() + } + + // 方法5:兜底取 rawmd5 属性(任意位置) + const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (rawMd5Fallback) { + this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] }) + return rawMd5Fallback[1].toLowerCase() + } + + this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length }) + } catch (e) { + this.log('parseVideoMd5 异常', { error: String(e) }) + } + + return undefined + } } export const videoService = new VideoService() diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index eab7252..10e1ae2 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -108,6 +108,9 @@ export class WcdbCore { private avatarUrlCache: Map = new Map() private readonly avatarCacheTtlMs = 10 * 60 * 1000 + private imageHardlinkCache: Map = new Map() + private videoHardlinkCache: Map = new Map() + private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 private logTimer: NodeJS.Timeout | null = null private lastLogTail: string | null = null private lastResolvedLogPath: string | null = null @@ -1281,6 +1284,52 @@ export class WcdbCore { return { begin: normalizedBegin, end: normalizedEnd } } + private makeHardlinkCacheKey(primary: string, secondary?: string | null): string { + const a = String(primary || '').trim().toLowerCase() + const b = String(secondary || '').trim().toLowerCase() + return `${a}\u001f${b}` + } + + private readHardlinkCache( + cache: Map, + key: string + ): { success: boolean; data?: any; error?: string } | null { + const entry = cache.get(key) + if (!entry) return null + if (Date.now() - entry.updatedAt > this.hardlinkCacheTtlMs) { + cache.delete(key) + return null + } + return this.cloneHardlinkResult(entry.result) + } + + private writeHardlinkCache( + cache: Map, + key: string, + result: { success: boolean; data?: any; error?: string } + ): void { + cache.set(key, { + result: this.cloneHardlinkResult(result), + updatedAt: Date.now() + }) + } + + private cloneHardlinkResult(result: { success: boolean; data?: any; error?: string }): { success: boolean; data?: any; error?: string } { + const data = result.data && typeof result.data === 'object' + ? { ...result.data } + : result.data + return { + success: result.success === true, + data, + error: result.error + } + } + + private clearHardlinkCaches(): void { + this.imageHardlinkCache.clear() + this.videoHardlinkCache.clear() + } + isReady(): boolean { return this.ensureReady() } @@ -1388,6 +1437,7 @@ export class WcdbCore { this.currentWxid = null this.currentDbStoragePath = null this.initialized = false + this.clearHardlinkCaches() this.stopLogPolling() } } @@ -2751,13 +2801,22 @@ export class WcdbCore { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.wcdbResolveImageHardlink) return { success: false, error: '接口未就绪' } try { + const normalizedMd5 = String(md5 || '').trim().toLowerCase() + const normalizedAccountDir = String(accountDir || '').trim() + if (!normalizedMd5) return { success: false, error: 'md5 为空' } + const cacheKey = this.makeHardlinkCacheKey(normalizedMd5, normalizedAccountDir) + const cached = this.readHardlinkCache(this.imageHardlinkCache, cacheKey) + if (cached) return cached + const outPtr = [null as any] - const result = this.wcdbResolveImageHardlink(this.handle, md5, accountDir || null, outPtr) + const result = this.wcdbResolveImageHardlink(this.handle, normalizedMd5, normalizedAccountDir || null, outPtr) if (result !== 0 || !outPtr[0]) return { success: false, error: `解析图片 hardlink 失败: ${result}` } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析图片 hardlink 响应失败' } const data = JSON.parse(jsonStr) || {} - return { success: true, data } + const finalResult = { success: true, data } + this.writeHardlinkCache(this.imageHardlinkCache, cacheKey, finalResult) + return finalResult } catch (e) { return { success: false, error: String(e) } } @@ -2767,13 +2826,80 @@ export class WcdbCore { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.wcdbResolveVideoHardlinkMd5) return { success: false, error: '接口未就绪' } try { + const normalizedMd5 = String(md5 || '').trim().toLowerCase() + const normalizedDbPath = String(dbPath || '').trim() + if (!normalizedMd5) return { success: false, error: 'md5 为空' } + const cacheKey = this.makeHardlinkCacheKey(normalizedMd5, normalizedDbPath) + const cached = this.readHardlinkCache(this.videoHardlinkCache, cacheKey) + if (cached) return cached + const outPtr = [null as any] - const result = this.wcdbResolveVideoHardlinkMd5(this.handle, md5, dbPath || null, outPtr) + const result = this.wcdbResolveVideoHardlinkMd5(this.handle, normalizedMd5, normalizedDbPath || null, outPtr) if (result !== 0 || !outPtr[0]) return { success: false, error: `解析视频 hardlink 失败: ${result}` } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) return { success: false, error: '解析视频 hardlink 响应失败' } const data = JSON.parse(jsonStr) || {} - return { success: true, data } + const finalResult = { success: true, data } + this.writeHardlinkCache(this.videoHardlinkCache, cacheKey, finalResult) + return finalResult + } catch (e) { + return { success: false, error: String(e) } + } + } + + async resolveImageHardlinkBatch( + requests: Array<{ md5: string; accountDir?: string }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' } + try { + const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = [] + for (let i = 0; i < requests.length; i += 1) { + const req = requests[i] || { md5: '' } + const normalizedMd5 = String(req.md5 || '').trim().toLowerCase() + if (!normalizedMd5) { + rows.push({ index: i, md5: '', success: false, error: 'md5 为空' }) + continue + } + const result = await this.resolveImageHardlink(normalizedMd5, req.accountDir) + rows.push({ + index: i, + md5: normalizedMd5, + success: result.success === true, + data: result.data, + error: result.error + }) + } + return { success: true, rows } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async resolveVideoHardlinkMd5Batch( + requests: Array<{ md5: string; dbPath?: string }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!Array.isArray(requests)) return { success: false, error: '参数错误: requests 必须是数组' } + try { + const rows: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }> = [] + for (let i = 0; i < requests.length; i += 1) { + const req = requests[i] || { md5: '' } + const normalizedMd5 = String(req.md5 || '').trim().toLowerCase() + if (!normalizedMd5) { + rows.push({ index: i, md5: '', success: false, error: 'md5 为空' }) + continue + } + const result = await this.resolveVideoHardlinkMd5(normalizedMd5, req.dbPath) + rows.push({ + index: i, + md5: normalizedMd5, + success: result.success === true, + data: result.data, + error: result.error + }) + } + return { success: true, rows } } catch (e) { return { success: false, error: String(e) } } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 98ca962..d8f331d 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -505,10 +505,22 @@ export class WcdbService { return this.callWorker('resolveImageHardlink', { md5, accountDir }) } + async resolveImageHardlinkBatch( + requests: Array<{ md5: string; accountDir?: string }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { + return this.callWorker('resolveImageHardlinkBatch', { requests }) + } + async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> { return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath }) } + async resolveVideoHardlinkMd5Batch( + requests: Array<{ md5: string; dbPath?: string }> + ): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> { + return this.callWorker('resolveVideoHardlinkMd5Batch', { requests }) + } + /** * 获取朋友圈 */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 55ab3af..61b637c 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -200,9 +200,15 @@ if (parentPort) { case 'resolveImageHardlink': result = await core.resolveImageHardlink(payload.md5, payload.accountDir) break + case 'resolveImageHardlinkBatch': + result = await core.resolveImageHardlinkBatch(payload.requests) + break case 'resolveVideoHardlinkMd5': result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath) break + case 'resolveVideoHardlinkMd5Batch': + result = await core.resolveVideoHardlinkMd5Batch(payload.requests) + break case 'getSnsTimeline': result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime) break diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index f053a24..9ae0779 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1777,6 +1777,10 @@ } } +.message-virtuoso { + width: 100%; +} + .loading-messages.loading-overlay { position: absolute; inset: 0; @@ -1894,6 +1898,7 @@ .message-wrapper { display: flex; flex-direction: column; + margin-bottom: 16px; -webkit-app-region: no-drag; &.sent { diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 10349bf..0aaaa99 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -2,6 +2,8 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' +import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' +import { useShallow } from 'zustand/react/shallow' import { useChatStore } from '../stores/chatStore' import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore' @@ -774,7 +776,6 @@ function ChatPage(props: ChatPageProps) { isConnecting, connectionError, sessions, - filteredSessions, currentSessionId, isLoadingSessions, messages, @@ -786,7 +787,6 @@ function ChatPage(props: ChatPageProps) { setConnecting, setConnectionError, setSessions, - setFilteredSessions, setCurrentSession, setLoadingSessions, setMessages, @@ -797,11 +797,46 @@ function ChatPage(props: ChatPageProps) { hasMoreLater, setHasMoreLater, setSearchKeyword - } = useChatStore() + } = useChatStore(useShallow((state) => ({ + isConnected: state.isConnected, + isConnecting: state.isConnecting, + connectionError: state.connectionError, + sessions: state.sessions, + currentSessionId: state.currentSessionId, + isLoadingSessions: state.isLoadingSessions, + messages: state.messages, + isLoadingMessages: state.isLoadingMessages, + isLoadingMore: state.isLoadingMore, + hasMoreMessages: state.hasMoreMessages, + searchKeyword: state.searchKeyword, + setConnected: state.setConnected, + setConnecting: state.setConnecting, + setConnectionError: state.setConnectionError, + setSessions: state.setSessions, + setCurrentSession: state.setCurrentSession, + setLoadingSessions: state.setLoadingSessions, + setMessages: state.setMessages, + appendMessages: state.appendMessages, + setLoadingMessages: state.setLoadingMessages, + setLoadingMore: state.setLoadingMore, + setHasMoreMessages: state.setHasMoreMessages, + hasMoreLater: state.hasMoreLater, + setHasMoreLater: state.setHasMoreLater, + setSearchKeyword: state.setSearchKeyword + }))) const messageListRef = useRef(null) + const [messageListScrollParent, setMessageListScrollParent] = useState(null) + const messageVirtuosoRef = useRef(null) + const visibleMessageRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: 0 }) + const topRangeLoadLockRef = useRef(false) + const bottomRangeLoadLockRef = useRef(false) const searchInputRef = useRef(null) const sidebarRef = useRef(null) + const handleMessageListScrollParentRef = useCallback((node: HTMLDivElement | null) => { + messageListRef.current = node + setMessageListScrollParent(node) + }, []) const getMessageKey = useCallback((msg: Message): string => { if (msg.messageKey) return msg.messageKey @@ -857,6 +892,7 @@ function ChatPage(props: ChatPageProps) { ) const [standaloneInitialLoadRequested, setStandaloneInitialLoadRequested] = useState(false) const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) + const [autoTranscribeVoiceEnabled, setAutoTranscribeVoiceEnabled] = useState(false) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState>(new Set()) const [isPreparingExportDialog, setIsPreparingExportDialog] = useState(false) @@ -877,8 +913,36 @@ function ChatPage(props: ChatPageProps) { const [tempFields, setTempFields] = useState([]) // 批量语音转文字相关状态(进度/结果 由全局 store 管理) - const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore() - const { isBatchDecrypting, progress: batchDecryptProgress, startDecrypt, updateProgress: updateDecryptProgress, finishDecrypt, setShowToast: setShowBatchDecryptToast } = useBatchImageDecryptStore() + const { + isBatchTranscribing, + batchTranscribeProgress, + startTranscribe, + updateProgress, + finishTranscribe, + setShowBatchProgress + } = useBatchTranscribeStore(useShallow((state) => ({ + isBatchTranscribing: state.isBatchTranscribing, + batchTranscribeProgress: state.progress, + startTranscribe: state.startTranscribe, + updateProgress: state.updateProgress, + finishTranscribe: state.finishTranscribe, + setShowBatchProgress: state.setShowToast + }))) + const { + isBatchDecrypting, + batchDecryptProgress, + startDecrypt, + updateDecryptProgress, + finishDecrypt, + setShowBatchDecryptToast + } = useBatchImageDecryptStore(useShallow((state) => ({ + isBatchDecrypting: state.isBatchDecrypting, + batchDecryptProgress: state.progress, + startDecrypt: state.startDecrypt, + updateDecryptProgress: state.updateProgress, + finishDecrypt: state.finishDecrypt, + setShowBatchDecryptToast: state.setShowToast + }))) const [showBatchConfirm, setShowBatchConfirm] = useState(false) const [batchVoiceCount, setBatchVoiceCount] = useState(0) const [batchVoiceMessages, setBatchVoiceMessages] = useState(null) @@ -943,8 +1007,6 @@ function ChatPage(props: ChatPageProps) { const sessionSwitchRequestSeqRef = useRef(0) const initialLoadRequestedSessionRef = useRef(null) const prevSessionRef = useRef(null) - const isLoadingMessagesRef = useRef(false) - const isLoadingMoreRef = useRef(false) const isConnectedRef = useRef(false) const isRefreshingRef = useRef(false) const searchKeywordRef = useRef('') @@ -2224,7 +2286,6 @@ function ChatPage(props: ChatPageProps) { setIsLoadingGroupMembers(false) setCurrentSession(null) setSessions([]) - setFilteredSessions([]) setMessages([]) setSearchKeyword('') setConnectionError(null) @@ -2243,7 +2304,6 @@ function ChatPage(props: ChatPageProps) { setConnecting, setConnectionError, setCurrentSession, - setFilteredSessions, setHasMoreLater, setHasMoreMessages, setMessages, @@ -2254,6 +2314,24 @@ function ChatPage(props: ChatPageProps) { setSessions ]) + useEffect(() => { + let canceled = false + void configService.getAutoTranscribeVoice() + .then((enabled) => { + if (!canceled) { + setAutoTranscribeVoiceEnabled(Boolean(enabled)) + } + }) + .catch(() => { + if (!canceled) { + setAutoTranscribeVoiceEnabled(false) + } + }) + return () => { + canceled = true + } + }, []) + useEffect(() => { let cancelled = false void (async () => { @@ -2270,6 +2348,8 @@ function ChatPage(props: ChatPageProps) { // 同步 currentSessionId 到 ref useEffect(() => { currentSessionRef.current = currentSessionId + topRangeLoadLockRef.current = false + bottomRangeLoadLockRef.current = false }, [currentSessionId]) const hydrateSessionStatuses = useCallback(async (sessionList: ChatSession[]) => { @@ -2611,7 +2691,11 @@ function ChatPage(props: ChatPageProps) { flashNewMessages(newOnes.map(getMessageKey)) // 滚动到底部 requestAnimationFrame(() => { - if (messageListRef.current) { + const latestMessages = useChatStore.getState().messages || [] + const lastIndex = latestMessages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) + } else if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) @@ -2660,7 +2744,11 @@ function ChatPage(props: ChatPageProps) { flashNewMessages(newMessages.map(getMessageKey)) // 滚动到底部 requestAnimationFrame(() => { - if (messageListRef.current) { + const currentMessages = useChatStore.getState().messages || [] + const lastIndex = currentMessages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) + } else if (messageListRef.current) { messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) @@ -2739,7 +2827,16 @@ function ChatPage(props: ChatPageProps) { setLoadingMore(true) } - // 记录加载前的第一条消息元素 + const visibleRange = visibleMessageRangeRef.current + const visibleStartIndex = Math.min( + Math.max(visibleRange.startIndex, 0), + Math.max(messages.length - 1, 0) + ) + const anchorMessageKeyBeforePrepend = offset > 0 && messages.length > 0 + ? getMessageKey(messages[visibleStartIndex]) + : null + + // 记录加载前的第一条消息元素(非虚拟列表回退路径) const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null try { @@ -2792,13 +2889,21 @@ function ChatPage(props: ChatPageProps) { // 日期跳转时滚动到顶部,否则滚动到底部 requestAnimationFrame(() => { - if (messageListRef.current) { - if (isDateJumpRef.current) { + if (isDateJumpRef.current) { + if (messageVirtuosoRef.current && result.messages.length > 0) { + messageVirtuosoRef.current.scrollToIndex({ index: 0, align: 'start', behavior: 'auto' }) + } else if (messageListRef.current) { messageListRef.current.scrollTop = 0 - isDateJumpRef.current = false - } else { - messageListRef.current.scrollTop = messageListRef.current.scrollHeight } + isDateJumpRef.current = false + return + } + + const lastIndex = result.messages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' }) + } else if (messageListRef.current) { + messageListRef.current.scrollTop = messageListRef.current.scrollHeight } }) } else { @@ -2816,12 +2921,27 @@ function ChatPage(props: ChatPageProps) { } } - // 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置 - if (firstMsgEl && listEl) { - requestAnimationFrame(() => { + // 加载更早消息后保持视口锚点,避免跳屏 + requestAnimationFrame(() => { + if (messageVirtuosoRef.current) { + if (anchorMessageKeyBeforePrepend) { + const latestMessages = useChatStore.getState().messages || [] + const anchorIndex = latestMessages.findIndex((msg) => getMessageKey(msg) === anchorMessageKeyBeforePrepend) + if (anchorIndex >= 0) { + messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' }) + return + } + } + if (result.messages.length > 0) { + messageVirtuosoRef.current.scrollToIndex({ index: result.messages.length, align: 'start', behavior: 'auto' }) + } + return + } + + if (firstMsgEl && listEl) { listEl.scrollTop = firstMsgEl.offsetTop - 80 - }) - } + } + }) } // 日期跳转(ascending=true):不往上加载更早的,往下加载更晚的 if (ascending) { @@ -3775,43 +3895,66 @@ function ChatPage(props: ChatPageProps) { setGlobalMsgAuthoritativeSessionCount(0) }, []) - // 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行) - const scrollTimeoutRef = useRef(null) - const handleScroll = useCallback(() => { - if (!messageListRef.current) return - - // 节流:延迟执行,避免滚动时频繁计算 - if (scrollTimeoutRef.current) { - cancelAnimationFrame(scrollTimeoutRef.current) + const handleMessageRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { + visibleMessageRangeRef.current = range + const total = messages.length + if (total <= 0) { + setShowScrollToBottom(prev => (prev ? false : prev)) + return } - scrollTimeoutRef.current = requestAnimationFrame(() => { - if (!messageListRef.current) return + const remaining = (total - 1) - range.endIndex + const shouldShowScrollButton = remaining > 3 + setShowScrollToBottom(prev => (prev === shouldShowScrollButton ? prev : shouldShowScrollButton)) - const { scrollTop, clientHeight, scrollHeight } = messageListRef.current + if ( + range.startIndex <= 2 && + !topRangeLoadLockRef.current && + !isLoadingMore && + !isLoadingMessages && + hasMoreMessages && + currentSessionId + ) { + topRangeLoadLockRef.current = true + void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) + } - // 显示回到底部按钮:距离底部超过 300px - const distanceFromBottom = scrollHeight - scrollTop - clientHeight - setShowScrollToBottom(distanceFromBottom > 300) + if ( + range.endIndex >= total - 3 && + !bottomRangeLoadLockRef.current && + !isLoadingMore && + !isLoadingMessages && + hasMoreLater && + currentSessionId + ) { + bottomRangeLoadLockRef.current = true + void loadLaterMessages() + } + }, [ + messages.length, + isLoadingMore, + isLoadingMessages, + hasMoreMessages, + hasMoreLater, + currentSessionId, + currentOffset, + jumpStartTime, + jumpEndTime, + loadMessages, + loadLaterMessages + ]) - // 预加载:当滚动到顶部 30% 区域时开始加载 - if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) { - const threshold = clientHeight * 0.3 - if (scrollTop < threshold) { - loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) - } - } + const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => { + if (!atBottom) { + bottomRangeLoadLockRef.current = false + } + }, []) - // 预加载更晚的消息 - if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) { - const threshold = clientHeight * 0.3 - const distanceFromBottom = scrollHeight - scrollTop - clientHeight - if (distanceFromBottom < threshold) { - loadLaterMessages() - } - } - }) - }, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages]) + const handleMessageAtTopStateChange = useCallback((atTop: boolean) => { + if (!atTop) { + topRangeLoadLockRef.current = false + } + }, []) const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => { @@ -3885,13 +4028,22 @@ function ChatPage(props: ChatPageProps) { // 滚动到底部 const scrollToBottom = useCallback(() => { + const lastIndex = messages.length - 1 + if (lastIndex >= 0 && messageVirtuosoRef.current) { + messageVirtuosoRef.current.scrollToIndex({ + index: lastIndex, + align: 'end', + behavior: 'smooth' + }) + return + } if (messageListRef.current) { messageListRef.current.scrollTo({ top: messageListRef.current.scrollHeight, behavior: 'smooth' }) } - }, []) + }, [messages.length]) // 拖动调节侧边栏宽度 const handleResizeStart = useCallback((e: React.MouseEvent) => { @@ -4021,9 +4173,11 @@ function ChatPage(props: ChatPageProps) { }, [sessions]) useEffect(() => { - isLoadingMessagesRef.current = isLoadingMessages - isLoadingMoreRef.current = isLoadingMore - }, [isLoadingMessages, isLoadingMore]) + if (!isLoadingMore) { + topRangeLoadLockRef.current = false + bottomRangeLoadLockRef.current = false + } + }, [isLoadingMore]) useEffect(() => { if (initialRevealTimerRef.current !== null) { @@ -4195,17 +4349,16 @@ function ChatPage(props: ChatPageProps) { }, [sessions, persistSessionListCache]) // 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口 - useEffect(() => { + const filteredSessions = useMemo(() => { if (!Array.isArray(sessions)) { - setFilteredSessions([]) - return + return [] } // 检查是否有折叠的群聊 const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) const hasFoldedGroups = foldedGroups.length > 0 - let visible = sessions.filter(s => { + const visible = sessions.filter(s => { if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false return true }) @@ -4246,37 +4399,34 @@ function ChatPage(props: ChatPageProps) { } if (!searchKeyword.trim()) { - setFilteredSessions(visible) - return + return visible } const lower = searchKeyword.toLowerCase() - setFilteredSessions(visible - .filter(s => { - const matchedByName = s.displayName?.toLowerCase().includes(lower) - const matchedByUsername = s.username.toLowerCase().includes(lower) - const matchedByAlias = s.alias?.toLowerCase().includes(lower) - return matchedByName || matchedByUsername || matchedByAlias - }) - .map(s => { - const matchedByName = s.displayName?.toLowerCase().includes(lower) - const matchedByUsername = s.username.toLowerCase().includes(lower) - const matchedByAlias = s.alias?.toLowerCase().includes(lower) + return visible + .filter(s => { + const matchedByName = s.displayName?.toLowerCase().includes(lower) + const matchedByUsername = s.username.toLowerCase().includes(lower) + const matchedByAlias = s.alias?.toLowerCase().includes(lower) + return matchedByName || matchedByUsername || matchedByAlias + }) + .map(s => { + const matchedByName = s.displayName?.toLowerCase().includes(lower) + const matchedByUsername = s.username.toLowerCase().includes(lower) + const matchedByAlias = s.alias?.toLowerCase().includes(lower) - let matchedField: 'wxid' | 'alias' | 'name' | undefined = undefined - - if (matchedByUsername && !matchedByName && !matchedByAlias) { - matchedField = 'wxid' - } else if (matchedByAlias && !matchedByName && !matchedByUsername) { - matchedField = 'alias' - } else if (matchedByName && !matchedByUsername && !matchedByAlias) { - matchedField = 'name' - } + let matchedField: 'wxid' | 'alias' | 'name' | undefined = undefined - // ✅ 关键点:返回一个新对象,解耦全局状态 - return { ...s, matchedField } - }) - ) - }, [sessions, searchKeyword, setFilteredSessions]) + if (matchedByUsername && !matchedByName && !matchedByAlias) { + matchedField = 'wxid' + } else if (matchedByAlias && !matchedByName && !matchedByUsername) { + matchedField = 'alias' + } else if (matchedByName && !matchedByUsername && !matchedByAlias) { + matchedField = 'name' + } + + return { ...s, matchedField } + }) + }, [sessions, searchKeyword]) // 折叠群列表(独立计算,供折叠 panel 使用) const foldedSessions = useMemo(() => { @@ -4314,6 +4464,25 @@ function ChatPage(props: ChatPageProps) { }) }, [sessions, searchKeyword, foldedView]) + const sessionLookupMap = useMemo(() => { + const map = new Map() + for (const session of sessions) { + const username = String(session.username || '').trim() + if (!username) continue + map.set(username, session) + } + return map + }, [sessions]) + const groupedGlobalMsgResults = useMemo(() => { + const grouped = globalMsgResults.reduce((acc, msg) => { + const sessionId = (msg as any).sessionId || '未知' + if (!acc[sessionId]) acc[sessionId] = [] + acc[sessionId].push(msg) + return acc + }, {} as Record) + return Object.entries(grouped) + }, [globalMsgResults]) + const hasSessionRecords = Array.isArray(sessions) && sessions.length > 0 const shouldShowSessionsSkeleton = isLoadingSessions && !hasSessionRecords const isSessionListSyncing = (isLoadingSessions || isRefreshingSessions) && hasSessionRecords @@ -5080,6 +5249,85 @@ function ChatPage(props: ChatPageProps) { } } + const messageVirtuosoComponents = useMemo(() => ({ + Header: () => ( + hasMoreMessages ? ( +
+ {isLoadingMore ? ( + <> + + 加载更多... + + ) : ( + 向上滚动加载更多 + )} +
+ ) : null + ), + Footer: () => ( + hasMoreLater ? ( +
+ {isLoadingMore ? ( + <> + + 正在加载后续消息... + + ) : ( + 向下滚动查看更新消息 + )} +
+ ) : null + ) + }), [hasMoreMessages, hasMoreLater, isLoadingMore]) + + const renderMessageListItem = useCallback((index: number, msg: Message) => { + const prevMsg = index > 0 ? messages[index - 1] : undefined + const showDateDivider = shouldShowDateDivider(msg, prevMsg) + const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) + const isSent = msg.isSend === 1 + const isSystem = isSystemMessage(msg.localType) + const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') + const messageKey = getMessageKey(msg) + + return ( +
+ {showDateDivider && ( +
+ {formatDateDivider(msg.createTime)} +
+ )} + +
+ ) + }, [ + messages, + highlightedMessageSet, + getMessageKey, + formatDateDivider, + currentSession, + myAvatarUrl, + isCurrentSessionGroup, + autoTranscribeVoiceEnabled, + handleRequireModelDownload, + handleContextMenu, + isSelectionMode, + selectedMessages, + handleToggleSelection + ]) + return (
{/* 自定义删除确认对话框 */} @@ -5232,17 +5480,10 @@ function ChatPage(props: ChatPageProps) { )}
- {Object.entries( - globalMsgResults.reduce((acc, msg) => { - const sessionId = (msg as any).sessionId || '未知'; - if (!acc[sessionId]) acc[sessionId] = []; - acc[sessionId].push(msg); - return acc; - }, {} as Record) - ).map(([sessionId, messages]) => { - const session = sessions.find(s => s.username === sessionId); - const firstMsg = messages[0]; - const count = messages.length; + {groupedGlobalMsgResults.map(([sessionId, messages]) => { + const session = sessionLookupMap.get(sessionId) + const firstMsg = messages[0] + const count = messages.length return (
- ); + ) })}
@@ -5661,77 +5902,27 @@ function ChatPage(props: ChatPageProps) { )}
- {hasMoreMessages && ( -
- {isLoadingMore ? ( - <> - - 加载更多... - - ) : ( - 向上滚动加载更多 - )} -
- )} - - {!isLoadingMessages && messages.length === 0 && !hasMoreMessages && ( + {!isLoadingMessages && messages.length === 0 && !hasMoreMessages ? (
该联系人没有聊天记录
- )} - - {(messages || []).map((msg, index) => { - const prevMsg = index > 0 ? messages[index - 1] : undefined - const showDateDivider = shouldShowDateDivider(msg, prevMsg) - - // 显示时间:第一条消息,或者与上一条消息间隔超过5分钟 - const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) - const isSent = msg.isSend === 1 - const isSystem = isSystemMessage(msg.localType) - - // 系统消息居中显示 - const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') - - const messageKey = getMessageKey(msg) - return ( -
- {showDateDivider && ( -
- {formatDateDivider(msg.createTime)} -
- )} - -
- ) - })} - - {hasMoreLater && ( -
- {isLoadingMore ? ( - <> - - 正在加载后续消息... - - ) : ( - 向下滚动查看更新消息 - )} -
+ ) : ( + getMessageKey(msg)} + components={messageVirtuosoComponents} + itemContent={renderMessageListItem} + /> )} {/* 回到底部按钮 */} @@ -6665,6 +6856,7 @@ function MessageBubble({ showTime, myAvatarUrl, isGroupChat, + autoTranscribeVoiceEnabled, onRequireModelDownload, onContextMenu, isSelectionMode, @@ -6677,6 +6869,7 @@ function MessageBubble({ showTime?: boolean; myAvatarUrl?: string; isGroupChat?: boolean; + autoTranscribeVoiceEnabled?: boolean; onRequireModelDownload?: (sessionId: string, messageId: string) => void; onContextMenu?: (e: React.MouseEvent, message: Message) => void; isSelectionMode?: boolean; @@ -6737,7 +6930,6 @@ function MessageBubble({ const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false) const [voiceTranscriptError, setVoiceTranscriptError] = useState(false) const voiceTranscriptRequestedRef = useRef(false) - const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(true) const [voiceCurrentTime, setVoiceCurrentTime] = useState(0) const [voiceDuration, setVoiceDuration] = useState(0) const [voiceWaveform, setVoiceWaveform] = useState([]) @@ -6787,15 +6979,6 @@ function MessageBubble({ } }, [isVideo, message.videoMd5, message.content, message.parsedContent]) - // 加载自动转文字配置 - useEffect(() => { - const loadConfig = async () => { - const enabled = await configService.getAutoTranscribeVoice() - setAutoTranscribeVoice(enabled) - } - loadConfig() - }, []) - const formatTime = (timestamp: number): string => { if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间' const date = new Date(timestamp * 1000) @@ -7427,25 +7610,14 @@ function MessageBubble({ void requestVideoInfo() }, [isVideo, isVideoVisible, videoInfo, requestVideoInfo]) - - // 根据设置决定是否自动转写 - const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false) - useEffect(() => { - window.electronAPI.config.get('autoTranscribeVoice').then((value: unknown) => { - setAutoTranscribeEnabled(value === true) - }) - }, []) - - useEffect(() => { - if (!autoTranscribeEnabled) return + if (!autoTranscribeVoiceEnabled) return if (!isVoice) return if (!voiceDataUrl) return - if (!autoTranscribeVoice) return // 如果自动转文字已关闭,不自动转文字 if (voiceTranscriptError) return if (voiceTranscriptLoading || voiceTranscript !== undefined || voiceTranscriptRequestedRef.current) return void requestVoiceTranscript() - }, [autoTranscribeEnabled, isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript]) + }, [autoTranscribeVoiceEnabled, isVoice, voiceDataUrl, voiceTranscript, voiceTranscriptError, voiceTranscriptLoading, requestVoiceTranscript]) // Selection mode handling removed from here to allow normal rendering // We will wrap the output instead @@ -8689,4 +8861,24 @@ function MessageBubble({ ) } +const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => { + if (prevProps.message !== nextProps.message) return false + if (prevProps.messageKey !== nextProps.messageKey) return false + if (prevProps.showTime !== nextProps.showTime) return false + if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false + if (prevProps.isGroupChat !== nextProps.isGroupChat) return false + if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false + if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false + if (prevProps.isSelected !== nextProps.isSelected) return false + if (prevProps.onRequireModelDownload !== nextProps.onRequireModelDownload) return false + if (prevProps.onContextMenu !== nextProps.onContextMenu) return false + if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false + + return ( + prevProps.session.username === nextProps.session.username && + prevProps.session.displayName === nextProps.session.displayName && + prevProps.session.avatarUrl === nextProps.session.avatarUrl + ) +}) + export default ChatPage