diff --git a/electron/main.ts b/electron/main.ts index cace612..c4e8b79 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -370,6 +370,66 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe return win } +/** + * 创建独立的图片查看窗口 + */ +function createImageViewerWindow(imagePath: string) { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + const win = new BrowserWindow({ + width: 900, + height: 700, + minWidth: 400, + minHeight: 300, + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + webSecurity: false // 允许加载本地文件 + }, + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#00000000', + symbolColor: '#ffffff', + height: 40 + }, + show: false, + backgroundColor: '#000000', + autoHideMenuBar: true + }) + + win.once('ready-to-show', () => { + win.show() + }) + + const imageParam = `imagePath=${encodeURIComponent(imagePath)}` + + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`) + + win.webContents.on('before-input-event', (event, input) => { + if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { + if (win.webContents.isDevToolsOpened()) { + win.webContents.closeDevTools() + } else { + win.webContents.openDevTools() + } + event.preventDefault() + } + }) + } else { + win.loadFile(join(__dirname, '../dist/index.html'), { + hash: `/image-viewer-window?${imageParam}` + }) + } + + return win +} + /** * 创建独立的聊天记录窗口 */ @@ -941,6 +1001,11 @@ function registerIpcHandlers() { return true }) + // 打开图片查看窗口 + ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => { + createImageViewerWindow(imagePath) + }) + // 完成引导,关闭引导窗口并显示主窗口 ipcMain.handle('window:completeOnboarding', async () => { try { diff --git a/electron/preload.ts b/electron/preload.ts index 628e4cb..5836625 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -64,6 +64,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), resizeToFitVideo: (videoWidth: number, videoHeight: number) => ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight), + openImageViewerWindow: (imagePath: string) => + ipcRenderer.invoke('window:openImageViewerWindow', imagePath), openChatHistoryWindow: (sessionId: string, messageId: number) => ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId) }, diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 4b1691f..ca02312 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -415,10 +415,16 @@ export class ImageDecryptService { if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) return hardlinkPath } - // hardlink 找到的是缩略图,但要求高清图,直接返回 null,不再搜索 - if (!allowThumbnail && isThumb) { - return null + // hardlink 找到的是缩略图,但要求高清图 + // 尝试在同一目录下查找高清图变体(快速查找,不遍历) + const hdPath = this.findHdVariantInSameDir(hardlinkPath) + if (hdPath) { + this.cacheDatPath(accountDir, imageMd5, hdPath) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) + return hdPath } + // 没找到高清图,返回 null(不进行全局搜索) + return null } this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 }) if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) { @@ -431,9 +437,13 @@ export class ImageDecryptService { this.cacheDatPath(accountDir, imageDatName, fallbackPath) return fallbackPath } - if (!allowThumbnail && isThumb) { - return null + // 找到缩略图但要求高清图,尝试同目录查找高清图变体 + const hdPath = this.findHdVariantInSameDir(fallbackPath) + if (hdPath) { + this.cacheDatPath(accountDir, imageDatName, hdPath) + return hdPath } + return null } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) } @@ -449,10 +459,13 @@ export class ImageDecryptService { this.cacheDatPath(accountDir, imageDatName, hardlinkPath) return hardlinkPath } - // hardlink 找到的是缩略图,但要求高清图,直接返回 null - if (!allowThumbnail && isThumb) { - return null + // hardlink 找到的是缩略图,但要求高清图 + const hdPath = this.findHdVariantInSameDir(hardlinkPath) + if (hdPath) { + this.cacheDatPath(accountDir, imageDatName, hdPath) + return hdPath } + return null } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) } @@ -467,6 +480,9 @@ export class ImageDecryptService { const cached = this.resolvedCache.get(imageDatName) if (cached && existsSync(cached)) { if (allowThumbnail || !this.isThumbnailPath(cached)) return cached + // 缓存的是缩略图,尝试找高清图 + const hdPath = this.findHdVariantInSameDir(cached) + if (hdPath) return hdPath } } @@ -761,6 +777,17 @@ export class ImageDecryptService { const root = join(accountDir, 'msg', 'attach') if (!existsSync(root)) return null + + // 优化1:快速概率性查找 + // 包含:1. 基于文件名的前缀猜测 (旧版) + // 2. 基于日期的最近月份扫描 (新版无索引时) + const fastHit = await this.fastProbabilisticSearch(root, datName) + if (fastHit) { + this.resolvedCache.set(key, fastHit) + return fastHit + } + + // 优化2:兜底扫描 (异步非阻塞) const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly) if (found) { this.resolvedCache.set(key, found) @@ -769,6 +796,134 @@ export class ImageDecryptService { return null } + /** + * 基于文件名的哈希特征猜测可能的路径 + * 包含:1. 微信旧版结构 filename.substr(0, 2)/... + * 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename + */ + private async fastProbabilisticSearch(root: string, datName: string): 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 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 path of candidates) { + try { + await fs.access(path) + return path + } catch { } + } + + // --- 策略 B: 新版 Session 哈希路径猜测 --- + try { + const entries = await fs.readdir(root, { withFileTypes: true }) + const sessionDirs = entries + .filter((e: any) => e.isDirectory() && e.name.length === 32 && /^[a-f0-9]+$/i.test(e.name)) + .map((e: any) => e.name) + + if (sessionDirs.length === 0) return null + + const now = new Date() + const months: string[] = [] + for (let i = 0; i < 2; i++) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1) + const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + 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) + const tasks = batch.map(async (sessDir: string) => { + for (const month of months) { + const subDirs = ['Img', 'Image'] + for (const sub of subDirs) { + const dirPath = join(root, sessDir, month, sub) + try { await fs.access(dirPath) } catch { continue } + for (const name of targetNames) { + const p = join(dirPath, name) + try { await fs.access(p); return p } catch { } + } + } + } + return null + }) + const results = await Promise.all(tasks) + const hit = results.find(r => r !== null) + if (hit) return hit + } + } catch { } + + } catch { } + return null + } + + /** + * 在同一目录下查找高清图变体 + * 缩略图: xxx_t.dat -> 高清图: xxx_h.dat 或 xxx.dat + */ + 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 + } + } + } catch { } + return null + } + private async searchDatFileInDir( dirPath: string, datName: string, diff --git a/src/App.tsx b/src/App.tsx index a5121c8..0cf6661 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage' import SettingsPage from './pages/SettingsPage' import ExportPage from './pages/ExportPage' import VideoWindow from './pages/VideoWindow' +import ImageWindow from './pages/ImageWindow' import SnsPage from './pages/SnsPage' import ContactsPage from './pages/ContactsPage' import ChatHistoryPage from './pages/ChatHistoryPage' @@ -318,6 +319,12 @@ function App() { return } + // 独立图片查看窗口 + const isImageViewerWindow = location.pathname === '/image-viewer-window' + if (isImageViewerWindow) { + return + } + // 独立聊天记录窗口 if (isChatHistoryWindow) { return diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index f5e99e2..1b17182 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -4,7 +4,6 @@ import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' import type { ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' -import { ImagePreview } from '../components/ImagePreview' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { AnimatedStreamingText } from '../components/AnimatedStreamingText' import JumpToDateDialog from '../components/JumpToDateDialog' @@ -1630,7 +1629,6 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false) const [voiceTranscriptError, setVoiceTranscriptError] = useState(false) const voiceTranscriptRequestedRef = useRef(false) - const [showImagePreview, setShowImagePreview] = useState(false) const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(true) const [voiceCurrentTime, setVoiceCurrentTime] = useState(0) const [voiceDuration, setVoiceDuration] = useState(0) @@ -1996,11 +1994,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o }, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd]) useEffect(() => { - if (!isImage || !showImagePreview || !imageHasUpdate) return + if (!isImage || !imageHasUpdate) return if (imageAutoHdTriggered.current === imageCacheKey) return imageAutoHdTriggered.current = imageCacheKey triggerForceHd() - }, [isImage, showImagePreview, imageHasUpdate, imageCacheKey, triggerForceHd]) + }, [isImage, imageHasUpdate, imageCacheKey, triggerForceHd]) // 更激进:进入视野/打开预览时,无论 hasUpdate 与否都尝试强制高清 useEffect(() => { @@ -2008,11 +2006,6 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o triggerForceHd() }, [isImage, imageInView, triggerForceHd]) - useEffect(() => { - if (!isImage || !showImagePreview) return - triggerForceHd() - }, [isImage, showImagePreview, triggerForceHd]) - useEffect(() => { if (!isVoice) return @@ -2363,15 +2356,12 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o if (imageHasUpdate) { void requestImageDecrypt(true, true) } - setShowImagePreview(true) + void window.electronAPI.window.openImageViewerWindow(imageLocalPath) }} onLoad={() => setImageError(false)} onError={() => setImageError(true)} /> - {showImagePreview && ( - setShowImagePreview(false)} /> - )} )} diff --git a/src/pages/ImageWindow.scss b/src/pages/ImageWindow.scss new file mode 100644 index 0000000..a1acf6c --- /dev/null +++ b/src/pages/ImageWindow.scss @@ -0,0 +1,99 @@ +.image-window-container { + width: 100vw; + height: 100vh; + background-color: var(--bg-primary); + display: flex; + flex-direction: column; + overflow: hidden; + user-select: none; + + .title-bar { + height: 40px; + min-height: 40px; + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + padding-right: 140px; // 为原生窗口控件留出空间 + + .window-drag-area { + flex: 1; + height: 100%; + -webkit-app-region: drag; + } + + .title-bar-controls { + display: flex; + align-items: center; + gap: 8px; + -webkit-app-region: no-drag; + margin-right: 16px; + + button { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 6px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + } + + .scale-text { + min-width: 50px; + text-align: center; + color: var(--text-secondary); + font-size: 12px; + font-variant-numeric: tabular-nums; + } + + .divider { + width: 1px; + height: 14px; + background: var(--border-color); + margin: 0 4px; + } + } + } + + .image-viewport { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; + cursor: grab; + + &:active { + cursor: grabbing; + } + + img { + max-width: none; + max-height: none; + object-fit: contain; + will-change: transform; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + pointer-events: auto; + } + } +} + +.image-window-empty { + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + background-color: var(--bg-primary); +} diff --git a/src/pages/ImageWindow.tsx b/src/pages/ImageWindow.tsx new file mode 100644 index 0000000..90ac068 --- /dev/null +++ b/src/pages/ImageWindow.tsx @@ -0,0 +1,162 @@ + +import { useState, useEffect, useRef, useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' +import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react' +import './ImageWindow.scss' + +export default function ImageWindow() { + const [searchParams] = useSearchParams() + const imagePath = searchParams.get('imagePath') + const [scale, setScale] = useState(1) + const [rotation, setRotation] = useState(0) + const [position, setPosition] = useState({ x: 0, y: 0 }) + const [initialScale, setInitialScale] = useState(1) + const viewportRef = useRef(null) + + // 使用 ref 存储拖动状态,避免闭包问题 + const dragStateRef = useRef({ + isDragging: false, + startX: 0, + startY: 0, + startPosX: 0, + startPosY: 0 + }) + + const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10)) + const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1)) + const handleRotate = () => setRotation(prev => (prev + 90) % 360) + const handleRotateCcw = () => setRotation(prev => (prev - 90 + 360) % 360) + + // 重置视图 + const handleReset = useCallback(() => { + setScale(1) + setRotation(0) + setPosition({ x: 0, y: 0 }) + }, []) + + // 图片加载完成后计算初始缩放 + const handleImageLoad = useCallback((e: React.SyntheticEvent) => { + const img = e.currentTarget + const naturalWidth = img.naturalWidth + const naturalHeight = img.naturalHeight + + if (viewportRef.current) { + const viewportWidth = viewportRef.current.clientWidth * 0.9 + const viewportHeight = viewportRef.current.clientHeight * 0.9 + const scaleX = viewportWidth / naturalWidth + const scaleY = viewportHeight / naturalHeight + const fitScale = Math.min(scaleX, scaleY, 1) + setInitialScale(fitScale) + setScale(1) + } + }, []) + + // 使用原生事件监听器处理拖动 + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!dragStateRef.current.isDragging) return + + const dx = e.clientX - dragStateRef.current.startX + const dy = e.clientY - dragStateRef.current.startY + + setPosition({ + x: dragStateRef.current.startPosX + dx, + y: dragStateRef.current.startPosY + dy + }) + } + + const handleMouseUp = () => { + dragStateRef.current.isDragging = false + document.body.style.cursor = '' + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + }, []) + + const handleMouseDown = (e: React.MouseEvent) => { + if (e.button !== 0) return + e.preventDefault() + + dragStateRef.current = { + isDragging: true, + startX: e.clientX, + startY: e.clientY, + startPosX: position.x, + startPosY: position.y + } + document.body.style.cursor = 'grabbing' + } + + const handleWheel = useCallback((e: React.WheelEvent) => { + const delta = -Math.sign(e.deltaY) * 0.15 + setScale(prev => Math.min(Math.max(prev + delta, 0.1), 10)) + }, []) + + // 双击重置 + const handleDoubleClick = useCallback(() => { + handleReset() + }, [handleReset]) + + // 快捷键支持 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') window.electronAPI.window.close() + if (e.key === '=' || e.key === '+') handleZoomIn() + if (e.key === '-') handleZoomOut() + if (e.key === 'r' || e.key === 'R') handleRotate() + if (e.key === '0') handleReset() + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [handleReset]) + + if (!imagePath) { + return ( +
+ 无效的图片路径 +
+ ) + } + + const displayScale = initialScale * scale + + return ( +
+
+
+
+ + {Math.round(displayScale * 100)}% + +
+ + +
+
+ +
+ Preview +
+
+ ) +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index e00eeeb..3211b84 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -11,6 +11,7 @@ export interface ElectronAPI { setTitleBarOverlay: (options: { symbolColor: string }) => void openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise + openImageViewerWindow: (imagePath: string) => Promise openChatHistoryWindow: (sessionId: string, messageId: number) => Promise } config: {