From 8bb9576f10d4eab5cd407da9df4a62c33dc25bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= <969409112@qq.com> Date: Sat, 11 Jul 2026 17:53:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E4=BC=98=E5=85=88=E6=89=93=E5=BC=80=E5=8E=9F?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/image-decrypt-service.ts | 60 +++++++++++++----- src/main/index.ts | 67 +++++++++++++------- src/preload/index.d.ts | 5 +- src/preload/index.ts | 8 ++- src/renderer/src/assets/main.css | 14 +++++ src/renderer/src/components/ImageBubble.tsx | 69 ++++++++++++++------- 6 files changed, 162 insertions(+), 61 deletions(-) diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts index abcdebb..c1b2ab3 100644 --- a/src/main/image-decrypt-service.ts +++ b/src/main/image-decrypt-service.ts @@ -72,24 +72,33 @@ export class ImageDecryptService { /** * 根据 md5 查找图片文件 (WechatExplorer 风格) */ - findImageFile(md5?: string, imageDatName?: string): string | null { + findImageFile( + md5?: string, + imageDatName?: string, + options?: { allowThumbnail?: boolean } + ): string | null { const accountDir = this.getAccountDir() if (!accountDir) return null + const allowThumbnail = options?.allowThumbnail !== false const normalizedMd5 = this.normalizeDatBase(md5 || '') const normalizedDatName = this.normalizeDatBase(imageDatName || '') console.log('[ImageDecrypt] findImageFile:', { md5: normalizedMd5, imageDatName: normalizedDatName, - accountDir + accountDir, + allowThumbnail }) for (const key of this.uniq([normalizedMd5, normalizedDatName])) { const hardlink = this.wcdb4Client?.resolveImageHardlink(key) const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : '' if (fullPath && existsSync(fullPath)) { - console.log('[ImageDecrypt] hardlink hit:', fullPath) - return this.getPreferredDatVariantPath(fullPath, true) + const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail) + if (allowThumbnail || !this.isThumbnailName(basename(selected))) { + console.log('[ImageDecrypt] hardlink hit:', selected) + return selected + } } } @@ -104,18 +113,22 @@ export class ImageDecryptService { if (searchKeys.length === 0) return null for (const key of searchKeys) { - const directHit = this.fastProbabilisticSearch(attachDir, key) + const directHit = this.fastProbabilisticSearch(attachDir, key, allowThumbnail) if (directHit) return directHit } - const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0]) + const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail) if (legacyHit) return legacyHit console.log('[ImageDecrypt] findImageFile miss for:', searchKeys) return null } - private fastProbabilisticSearch(attachDir: string, datName: string): string | null { + private fastProbabilisticSearch( + attachDir: string, + datName: string, + allowThumbnail = true + ): string | null { const normalized = this.normalizeDatBase(datName) if (!normalized) return null @@ -131,7 +144,7 @@ export class ImageDecryptService { join(attachDir, dir1, dir2, 'Image', variant), join(attachDir, dir1, dir2, 'image', variant) ] - const found = this.getLargestExistingPath(candidates, true) + const found = this.getLargestExistingPath(candidates, allowThumbnail) if (found) { console.log('[ImageDecrypt] prefix path hit:', found) return found @@ -159,7 +172,7 @@ export class ImageDecryptService { const found = this.getLargestExistingPath( variants.map((variant) => join(imgDir, variant)), - true + allowThumbnail ) if (found) { console.log('[ImageDecrypt] found at:', found) @@ -175,7 +188,11 @@ export class ImageDecryptService { return null } - private findImageFileInLegacyDirs(accountDir: string, datName: string): string | null { + private findImageFileInLegacyDirs( + accountDir: string, + datName: string, + allowThumbnail = true + ): string | null { const normalized = this.normalizeDatBase(datName) if (!normalized) return null @@ -186,18 +203,27 @@ export class ImageDecryptService { ].filter((root) => existsSync(root)) for (const root of roots) { - const found = this.recursiveFindDat(root, normalized, 5) + const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail) if (found) return found } return null } - private recursiveFindDat(dir: string, datName: string, depth: number): string | null { + private recursiveFindDat( + dir: string, + datName: string, + depth: number, + allowThumbnail = true + ): string | null { if (depth < 0) return null try { - const variants = new Set(this.buildPreferredDatNames(datName)) + const variants = new Set( + this.buildPreferredDatNames(datName).filter( + (name) => allowThumbnail || !this.isThumbnailName(name) + ) + ) const entries = readdirSync(dir) for (const entry of entries) { const fullPath = join(dir, entry) @@ -211,7 +237,7 @@ export class ImageDecryptService { for (const entry of entries) { const fullPath = join(dir, entry) if (!statSync(fullPath).isDirectory()) continue - const found = this.recursiveFindDat(fullPath, datName, depth - 1) + const found = this.recursiveFindDat(fullPath, datName, depth - 1, allowThumbnail) if (found) return found } } catch { @@ -422,6 +448,8 @@ export class ImageDecryptService { `${base}.dat`, `${base}_hd.dat`, `${base}_h.dat`, + `${base}_b.dat`, + `${base}_w.dat`, `${base}_c.dat`, `${base}_t.dat`, `${base}.thumb.dat`, @@ -470,6 +498,10 @@ export class ImageDecryptService { return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat') } + isThumbnailFile(filePath: string): boolean { + return this.isThumbnailName(basename(filePath)) + } + private unwrapWxgf(buffer: Buffer): Buffer { if ( buffer.length < 20 || diff --git a/src/main/index.ts b/src/main/index.ts index e0600c9..e44f396 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import './preload-env' +import './preload-env' import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard, Menu, Tray } from 'electron' import { join } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' @@ -76,7 +76,7 @@ function getConfiguredImageKeys(): { xorKey: string; aesKey: string } { function createWindow(): void { - // 创建浏览器窗口 + // 鍒涘缓娴忚鍣ㄧ獥鍙? const mainWindow = new BrowserWindow({ width: 1400, height: 800, @@ -98,8 +98,8 @@ function createWindow(): void { return { action: 'deny' } }) - // 基于 electron-vite cli 的渲染器 HMR - // 加载开发环境的远程 URL 或生产环境的本地 html 文件 + // 鍩轰簬 electron-vite cli 鐨勬覆鏌撳櫒 HMR + // 鍔犺浇寮€鍙戠幆澧冪殑杩滅▼ URL 鎴栫敓浜х幆澧冪殑鏈湴 html 鏂囦欢 if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { @@ -107,8 +107,8 @@ function createWindow(): void { } } -// 当 Electron 完成初始化并准备好创建浏览器窗口时,将调用此方法 -// 某些 API 只能在此事件发生后使用 +// 褰?Electron 瀹屾垚鍒濆鍖栧苟鍑嗗濂藉垱寤烘祻瑙堝櫒绐楀彛鏃讹紝灏嗚皟鐢ㄦ鏂规硶 +// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢? app.whenReady().then(async () => { console.log(`WechatExplorer main build: ${BUILD_MARK}`) @@ -122,12 +122,12 @@ app.whenReady().then(async () => { console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError) } - // 为窗口设置应用程序用户模型 ID + // 涓虹獥鍙h缃簲鐢ㄧ▼搴忕敤鎴锋ā鍨?ID electronApp.setAppUserModelId('com.electron') - // 在开发环境中默认按 F12 打开或关闭 DevTools - // 在生产环境中忽略 CommandOrControl + R - // 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils + // 鍦ㄥ紑鍙戠幆澧冧腑榛樿鎸?F12 鎵撳紑鎴栧叧闂?DevTools + // 鍦ㄧ敓浜х幆澧冧腑蹇界暐 CommandOrControl + R + // 鍙傝 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) @@ -297,7 +297,7 @@ app.whenReady().then(async () => { return { success: false, error: '未配置 API Key' } } - // 动态导入以避免如果未安装或初始类型缺失的问题 + // 鍔ㄦ€佸鍏ヤ互閬垮厤濡傛灉鏈畨瑁呮垨鍒濆绫诲瀷缂哄け鐨勯棶棰? const { OpenAI } = await import('openai') const openai = new OpenAI({ @@ -358,20 +358,38 @@ app.whenReady().then(async () => { ipcMain.handle( 'db:getImage', - async (_, imageMd5?: string, imageDatNameOrThumb?: string | boolean, _sessionId?: string) => { + async ( + _, + imageMd5?: string, + imageDatNameOrThumb?: string | boolean, + _sessionId?: string, + options?: { force?: boolean } + ) => { void _sessionId if (!imageDecryptService) { const { xorKey, aesKey } = getConfiguredImageKeys() if (!aesKey) { return { success: false, error: '未配置图片解密密钥' } } - imageDecryptService = new ImageDecryptService(xorKey, aesKey, chat.getChatDb()?.getWcdb4Client()) + imageDecryptService = new ImageDecryptService( + xorKey, + aesKey, + chat.getChatDb()?.getWcdb4Client() + ) } const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined - const filePath = imageDecryptService.findImageFile(imageMd5, imageDatName) + const force = options?.force === true + let filePath = force + ? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false }) + : null if (!filePath) { - return { success: false, error: '未找到图片文件' } + filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, { + allowThumbnail: true + }) + } + if (!filePath) { + return { success: false, error: force ? '未找到原图或缩略图文件' : '未找到图片文件' } } const base64 = imageDecryptService.decryptImageToBase64(filePath) @@ -379,7 +397,12 @@ app.whenReady().then(async () => { return { success: false, error: '图片解密失败' } } - return { success: true, data: base64 } + return { + success: true, + data: base64, + isThumb: imageDecryptService.isThumbnailFile(filePath), + filePath + } } ) @@ -451,7 +474,7 @@ app.whenReady().then(async () => { createWindow() - // 启动本地 HTTP API(根据 settings.apiEnabled 控制) + // 鍚姩鏈湴 HTTP API(鏍规嵁 settings.apiEnabled 鎺у埗) const settings = loadSettings() if (settings.apiEnabled) { await apiServer.start(settings.apiHost, settings.apiPort) @@ -463,15 +486,15 @@ app.whenReady().then(async () => { } app.on('activate', function () { - // 在 macOS 上,当点击 dock 图标且没有其他窗口打开时, - // 通常会在应用程序中重新创建一个窗口。 + // 鍦?macOS 涓婏紝褰撶偣鍑?dock 鍥炬爣涓旀病鏈夊叾浠栫獥鍙f墦寮€鏃讹紝 + // 閫氬父浼氬湪搴旂敤绋嬪簭涓噸鏂板垱寤轰竴涓獥鍙c€? if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) -// 当所有窗口关闭时退出,除了 macOS。在那里, -// 应用程序及其菜单栏通常会保持活动状态,直到用户 -// 显式使用 Cmd + Q 退出。 +// 褰撴墍鏈夌獥鍙e叧闂椂閫€鍑猴紝闄や簡 macOS銆傚湪閭i噷锛? +// 搴旂敤绋嬪簭鍙婂叾鑿滃崟鏍忛€氬父浼氫繚鎸佹椿鍔ㄧ姸鎬侊紝鐩村埌鐢ㄦ埛 +// 鏄惧紡浣跨敤 Cmd + Q 閫€鍑恒€? app.on('window-all-closed', () => { if (TRAY_MODE) return if (process.platform !== 'darwin') { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index d4001ab..2642c2e 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -76,8 +76,9 @@ declare global { getImage: ( imageMd5?: string, imageDatNameOrThumb?: string | boolean, - sessionId?: string - ) => Promise<{ success: boolean; data?: string; error?: string }> + sessionId?: string, + options?: { force?: boolean } + ) => Promise<{ success: boolean; data?: string; error?: string; isThumb?: boolean; filePath?: string }> getSticker: ( cdnUrl?: string, md5?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 14699cb..fe222f8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -27,8 +27,12 @@ const api = { ipcRenderer.invoke('db:getVoiceData', sessionId, localId, createTime, svrId), parseMessage: (content: string, messageType: number) => ipcRenderer.invoke('db:parseMessage', content, messageType), - getImage: (imageMd5?: string, imageDatNameOrThumb?: string | boolean, sessionId?: string) => - ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId), + getImage: ( + imageMd5?: string, + imageDatNameOrThumb?: string | boolean, + sessionId?: string, + options?: { force?: boolean } + ) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options), getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5), exportGroupReport: (request: GroupReportExportRequest) => ipcRenderer.invoke('report:export', request), diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 3c37d00..b3a85b4 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -456,6 +456,20 @@ body { opacity: 1; } +.image-quality-badge { + position: absolute; + left: 6px; + top: 6px; + max-width: calc(100% - 12px); + padding: 3px 6px; + border-radius: 5px; + background: rgba(0, 0, 0, 0.5); + color: #fff; + font-size: 11px; + line-height: 1.3; + pointer-events: none; +} + .image-action-btn { border: 0; border-radius: 5px; diff --git a/src/renderer/src/components/ImageBubble.tsx b/src/renderer/src/components/ImageBubble.tsx index fa31d31..87991a5 100644 --- a/src/renderer/src/components/ImageBubble.tsx +++ b/src/renderer/src/components/ImageBubble.tsx @@ -18,7 +18,9 @@ export function ImageBubble({ }: ImageBubbleProps): JSX.Element { const [imageUrl, setImageUrl] = useState(null) const [loading, setLoading] = useState(false) + const [upgrading, setUpgrading] = useState(false) const [error, setError] = useState(null) + const [isThumbnail, setIsThumbnail] = useState(false) const containerRef = useRef(null) const loadImage = useCallback(async () => { @@ -31,22 +33,18 @@ export function ImageBubble({ setLoading(true) try { const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId) - if (result.success && result.data) { - // 验证返回的是否是有效的图片 data URL - if (result.data.startsWith('data:image/')) { - setImageUrl(result.data) - setError(null) - } else { - // 解密后不是有效图片格式,显示未解密 - setError('未解密') - } + if (result.success && result.data?.startsWith('data:image/')) { + setImageUrl(result.data) + setIsThumbnail(Boolean(result.isThumb)) + setError(null) } else { setError(result.error || '加载图片失败') } } catch { setError('加载图片失败') + } finally { + setLoading(false) } - setLoading(false) }, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading]) useEffect(() => { @@ -71,16 +69,42 @@ export function ImageBubble({ const handleCopy = async (event: MouseEvent): Promise => { event.stopPropagation() - if (imageUrl) { - await window.api.copyImage(imageUrl) - alert('图片已复制') - } + if (!imageUrl) return + await window.api.copyImage(imageUrl) + alert('图片已复制') } - const handleClick = (): void => { - if (imageUrl) { - onImageClick?.(imageUrl) + const handleClick = async (): Promise => { + if (!imageUrl && !error) return + if (!imageMd5 && !imageDatName) { + if (imageUrl) onImageClick?.(imageUrl) + return } + + if (upgrading) { + if (imageUrl) onImageClick?.(imageUrl) + return + } + + setUpgrading(true) + try { + const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId, { + force: true + }) + if (result.success && result.data?.startsWith('data:image/')) { + setImageUrl(result.data) + setIsThumbnail(Boolean(result.isThumb)) + setError(null) + onImageClick?.(result.data) + return + } + } catch { + // Fall back to the already visible image. + } finally { + setUpgrading(false) + } + + if (imageUrl) onImageClick?.(imageUrl) } if (loading) { @@ -94,7 +118,7 @@ export function ImageBubble({ if (error) { return (
-
图片未加载
+
{error || '图片未加载'}
) } @@ -102,8 +126,8 @@ export function ImageBubble({ if (!imageUrl) { return (
-
🖼
-
加载图片中
+
+
加载图片中...
) } @@ -111,9 +135,12 @@ export function ImageBubble({ return (
图片 + {(upgrading || isThumbnail) && ( +
{upgrading ? '查找原图...' : '缩略图'}
+ )}