diff --git a/electron/main.ts b/electron/main.ts index f5fd036..e0b475d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -728,7 +728,11 @@ function createPurchaseWindow() { /** * 创建独立的图片查看窗口 */ -function createImageViewerWindow(imagePath: string, liveVideoPath?: string) { +function createImageViewerWindow( + imagePath: string, + liveVideoPath?: string, + options?: { sessionId?: string; imageMd5?: string; imageDatName?: string } +) { const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') @@ -764,7 +768,10 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) { const themeParams = getThemeQueryParams() const imageParam = `imagePath=${encodeURIComponent(imagePath)}` const liveVideoParam = liveVideoPath ? `&liveVideoPath=${encodeURIComponent(liveVideoPath)}` : '' - const queryParams = `${themeParams}&${imageParam}${liveVideoParam}` + const sessionParam = options?.sessionId ? `&sessionId=${encodeURIComponent(options.sessionId)}` : '' + const imageMd5Param = options?.imageMd5 ? `&imageMd5=${encodeURIComponent(options.imageMd5)}` : '' + const imageDatNameParam = options?.imageDatName ? `&imageDatName=${encodeURIComponent(options.imageDatName)}` : '' + const queryParams = `${themeParams}&${imageParam}${liveVideoParam}${sessionParam}${imageMd5Param}${imageDatNameParam}` if (process.env.VITE_DEV_SERVER_URL) { win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${queryParams}`) @@ -1288,8 +1295,16 @@ function registerIpcHandlers() { }) // 打开图片查看窗口 - ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string, liveVideoPath?: string, imageList?: Array<{ imagePath: string; liveVideoPath?: string }>) => { - const win = createImageViewerWindow(imagePath, liveVideoPath) + ipcMain.handle( + 'window:openImageViewerWindow', + ( + _, + imagePath: string, + liveVideoPath?: string, + imageList?: Array<{ imagePath: string; liveVideoPath?: string }>, + options?: { sessionId?: string; imageMd5?: string; imageDatName?: string } + ) => { + const win = createImageViewerWindow(imagePath, liveVideoPath, options) if (imageList && imageList.length > 1) { const currentIndex = imageList.findIndex(item => item.imagePath === imagePath) win.webContents.once('did-finish-load', () => { @@ -1301,7 +1316,8 @@ function registerIpcHandlers() { } }) } - }) + } + ) // 打开视频播放窗口 ipcMain.handle('window:openVideoPlayerWindow', (_, videoPath: string, videoWidth?: number, videoHeight?: number) => { diff --git a/electron/preload.ts b/electron/preload.ts index 7d1c848..d8ca35a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -88,7 +88,12 @@ contextBridge.exposeInMainWorld('electronAPI', { isChatWindowOpen: () => ipcRenderer.invoke('window:isChatWindowOpen'), closeChatWindow: () => ipcRenderer.invoke('window:closeChatWindow'), setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options), - openImageViewerWindow: (imagePath: string, liveVideoPath?: string, imageList?: Array<{ imagePath: string; liveVideoPath?: string }>) => ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath, imageList), + openImageViewerWindow: ( + imagePath: string, + liveVideoPath?: string, + imageList?: Array<{ imagePath: string; liveVideoPath?: string }>, + options?: { sessionId?: string; imageMd5?: string; imageDatName?: string } + ) => ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath, imageList, options), openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), openBrowserWindow: (url: string, title?: string) => ipcRenderer.invoke('window:openBrowserWindow', url, title), openAISummaryWindow: (sessionId: string, sessionName: string) => ipcRenderer.invoke('window:openAISummaryWindow', sessionId, sessionName), diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 6f99945..b1f8864 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -197,6 +197,7 @@ export class ImageDecryptService { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) const localPath = this.filePathToUrl(datPath) const isThumb = this.isThumbnailPath(datPath) + this.emitCacheResolved(payload, cacheKey, localPath) return { success: true, localPath, isThumb, liveVideoPath: !isThumb ? this.checkLiveVideoCache(datPath) : undefined } } @@ -209,6 +210,7 @@ export class ImageDecryptService { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing) const localPath = this.filePathToUrl(existing) const isThumb = this.isThumbnailPath(existing) + this.emitCacheResolved(payload, cacheKey, localPath) return { success: true, localPath, isThumb, liveVideoPath: !isThumb ? this.checkLiveVideoCache(existing) : undefined } } } @@ -276,6 +278,7 @@ export class ImageDecryptService { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) if (!isThumb) { this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) + this.deleteThumbnailByKeys(this.getCacheKeys(payload)) } } @@ -290,6 +293,7 @@ export class ImageDecryptService { } const localPath = this.filePathToUrl(outputPath) + this.emitCacheResolved(payload, cacheKey, localPath) return { success: true, localPath, isThumb, liveVideoPath } } catch (e) { @@ -1328,6 +1332,65 @@ export class ImageDecryptService { if (imageDatName) this.updateFlags.delete(imageDatName) } + private deleteThumbnailByKeys(keys: string[]): number { + if (keys.length === 0) return 0 + + const normalizedKeys = Array.from(new Set( + keys + .map(k => this.normalizeDatBase(k.toLowerCase())) + .filter(Boolean) + )) + if (normalizedKeys.length === 0) return 0 + + let deleted = 0 + const roots = this.getAllCacheRoots() + + const isMatchThumbFile = (filePath: string): boolean => { + const lower = filePath.toLowerCase() + if (!this.isThumbnailPath(lower)) return false + const baseName = basename(lower) + return normalizedKeys.some(key => baseName.startsWith(`${key}_thumb.`)) + } + + const walk = (dir: string) => { + let entries: any[] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + + for (const entry of entries) { + const full = join(dir, entry.name) + if (entry.isDirectory()) { + walk(full) + } else if (isMatchThumbFile(full)) { + try { + unlinkSync(full) + deleted++ + } catch { } + } + } + } + + for (const root of roots) { + if (existsSync(root)) { + walk(root) + } + } + + for (const [key, resolvedPath] of this.resolvedCache.entries()) { + if (!isMatchThumbFile(resolvedPath)) continue + const lowerKey = key.toLowerCase() + const normalizedKey = this.normalizeDatBase(lowerKey) + if (normalizedKeys.includes(normalizedKey) || normalizedKeys.includes(lowerKey)) { + this.resolvedCache.delete(key) + } + } + + return deleted + } + private getCachedDatDir(accountDir: string, imageDatName?: string, imageMd5?: string): string | null { const keys = [ imageDatName ? `${accountDir}|${imageDatName}` : null, diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 6c1f87b..2de552c 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -2621,6 +2621,8 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h const [imageClicked, setImageClicked] = useState(false) const imageUpdateCheckedRef = useRef(null) const imageClickTimerRef = useRef(null) + const imageRecoveringRef = useRef(false) + const lastRecoverTriedPathRef = useRef(null) const [isVisible, setIsVisible] = useState(false) const imageContainerRef = useRef(null) @@ -2784,7 +2786,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h }) }, { - rootMargin: '200px 0px', // 提前 200px 开始加载 + rootMargin: '1200px 0px', // 提前加载,减少滚动到位后的等待 threshold: 0 } ) @@ -3230,6 +3232,137 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h } }, [isImage, message.imageMd5, message.imageDatName, isVisible, imageCacheKey, imageLocalPath, session.username, syncVersion]) + // 若已显示缩略图且检测到高清图可用,循环尝试升级(防止首轮时机过早) + useEffect(() => { + if (!isImage) return + if (!isVisible) return + if (!imageLocalPath) return + if (!imageLocalPath.toLowerCase().includes('_thumb')) return + if (!imageHasUpdate) return + const timer = window.setInterval(() => { + if (!imageLoading) { + void requestImageDecrypt(true) + } + }, 6000) + + if (!imageLoading) { + void requestImageDecrypt(true) + } + + return () => { + window.clearInterval(timer) + } + }, [isImage, isVisible, imageLocalPath, imageHasUpdate, imageLoading, requestImageDecrypt]) + + const handleOpenImage = useCallback(async () => { + if (!imageLocalPath) return + + let openPath = imageLocalPath + let openLiveVideoPath = imageLiveVideoPath + + if (imageHasUpdate && !imageLoading) { + try { + const result = await window.electronAPI.image.decrypt({ + sessionId: session.username, + imageMd5: message.imageMd5 || undefined, + imageDatName: message.imageDatName, + force: true + }) + if (result.success && result.localPath) { + imageDataUrlCache.set(imageCacheKey, result.localPath) + setImageLocalPath(result.localPath) + setImageHasUpdate(Boolean((result as { isThumb?: boolean }).isThumb)) + openPath = result.localPath + if ((result as any).liveVideoPath) { + setImageLiveVideoPath((result as any).liveVideoPath) + openLiveVideoPath = (result as any).liveVideoPath + } + } + } catch { + // ignore and fallback to current path + } + } + + window.electronAPI.window.openImageViewerWindow(openPath, openLiveVideoPath, undefined, { + sessionId: session.username, + imageMd5: message.imageMd5 || undefined, + imageDatName: message.imageDatName + }) + }, [ + imageLocalPath, + imageLiveVideoPath, + imageHasUpdate, + imageLoading, + session.username, + message.imageMd5, + message.imageDatName, + imageCacheKey, + ]) + + const recoverBrokenImagePath = useCallback(async () => { + if (!isImage) return + if ((!message.imageMd5 && !message.imageDatName) || !session.username) return + if (imageRecoveringRef.current) return + + const failedPath = imageLocalPath || '__empty__' + if (lastRecoverTriedPathRef.current === failedPath && !imageHasUpdate) { + return + } + lastRecoverTriedPathRef.current = failedPath + imageRecoveringRef.current = true + setImageLoading(true) + + try { + const payload = { + sessionId: session.username, + imageMd5: message.imageMd5 || undefined, + imageDatName: message.imageDatName + } + + try { + const cached = await window.electronAPI.image.resolveCache(payload) + if (cached.success && cached.localPath && cached.localPath !== imageLocalPath) { + imageDataUrlCache.set(imageCacheKey, cached.localPath) + setImageLocalPath(cached.localPath) + setImageHasUpdate(cached.localPath.toLowerCase().includes('_thumb')) + setImageError(false) + return + } + } catch { + // continue to force decrypt + } + + try { + const refreshed = await window.electronAPI.image.decrypt({ ...payload, force: true }) + if (refreshed.success && refreshed.localPath) { + imageDataUrlCache.set(imageCacheKey, refreshed.localPath) + setImageLocalPath(refreshed.localPath) + setImageHasUpdate(Boolean((refreshed as { isThumb?: boolean }).isThumb)) + if ((refreshed as any).liveVideoPath) { + setImageLiveVideoPath((refreshed as any).liveVideoPath) + } + setImageError(false) + return + } + } catch { + // keep error state + } + + setImageError(true) + } finally { + setImageLoading(false) + imageRecoveringRef.current = false + } + }, [ + isImage, + message.imageMd5, + message.imageDatName, + session.username, + imageLocalPath, + imageHasUpdate, + imageCacheKey + ]) + // 自动检查转写缓存 useEffect(() => { if (!isVoice || sttTranscript || sttLoading) return @@ -3277,6 +3410,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h if (matchesCacheKey) { imageDataUrlCache.set(imageCacheKey, payload.localPath) setImageLocalPath(payload.localPath) + setImageHasUpdate(payload.localPath.toLowerCase().includes('_thumb')) setImageError(false) } }) @@ -3436,14 +3570,26 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h src={imageLocalPath} alt="图片" className="image-message" - onClick={() => { - if (imageLocalPath) { - window.electronAPI.window.openImageViewerWindow(imageLocalPath, imageLiveVideoPath) - } - }} + onClick={() => { void handleOpenImage() }} onLoad={() => setImageError(false)} - onError={() => setImageError(true)} + onError={() => { + setImageError(true) + void recoverBrokenImagePath() + }} /> + {imageHasUpdate && ( + + )} {imageLiveVideoPath && (
diff --git a/src/pages/ImageWindow.tsx b/src/pages/ImageWindow.tsx index 7eae644..18b8000 100644 --- a/src/pages/ImageWindow.tsx +++ b/src/pages/ImageWindow.tsx @@ -9,6 +9,9 @@ export default function ImageWindow() { const [searchParams] = useSearchParams() const imagePath = searchParams.get('imagePath') const liveVideoPath = searchParams.get('liveVideoPath') + const sessionId = searchParams.get('sessionId') || undefined + const imageMd5 = searchParams.get('imageMd5') || undefined + const imageDatName = searchParams.get('imageDatName') || undefined // 图片列表导航状态 const [imageList, setImageList] = useState>([]) @@ -18,6 +21,11 @@ export default function ImageWindow() { const currentImagePath = activeImage?.imagePath || imagePath // 多图模式下只用列表中的 liveVideoPath,不回退到 URL 参数,避免非实况图也显示实况按钮 const currentLiveVideoPath = imageList.length > 0 ? activeImage?.liveVideoPath : liveVideoPath + const [hdImagePath, setHdImagePath] = useState(null) + const [hdLiveVideoPath, setHdLiveVideoPath] = useState(undefined) + const upgradeTriedRef = useRef(null) + const effectiveImagePath = hdImagePath || currentImagePath + const effectiveLiveVideoPath = hdLiveVideoPath ?? currentLiveVideoPath const [scale, setScale] = useState(1) const [rotation, setRotation] = useState(0) @@ -42,6 +50,45 @@ export default function ImageWindow() { const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 }) const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 }) + useEffect(() => { + setHdImagePath(null) + setHdLiveVideoPath(undefined) + upgradeTriedRef.current = null + }, [currentImagePath]) + + // 在图片查看器中再次尝试强制升级高清图 + useEffect(() => { + if (!currentImagePath) return + if (!sessionId) return + if (!imageMd5 && !imageDatName) return + + const upgradeKey = `${sessionId}|${imageMd5 || ''}|${imageDatName || ''}` + if (upgradeTriedRef.current === upgradeKey) return + upgradeTriedRef.current = upgradeKey + + let cancelled = false + window.electronAPI.image.decrypt({ + sessionId, + imageMd5, + imageDatName, + force: true + }).then((result) => { + if (cancelled) return + if (result.success && result.localPath) { + setHdImagePath(result.localPath) + if ((result as any).liveVideoPath) { + setHdLiveVideoPath((result as any).liveVideoPath) + } + } + }).catch(() => { + // ignore + }) + + return () => { + cancelled = true + } + }, [currentImagePath, sessionId, imageMd5, imageDatName]) + 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) @@ -58,7 +105,7 @@ export default function ImageWindow() { // 播放 Live Photo const handlePlayLiveVideo = useCallback(() => { - if (currentLiveVideoPath && !isPlayingLive) { + if (effectiveLiveVideoPath && !isPlayingLive) { setIsPlayingLive(true) // 播放视频 if (videoRef.current) { @@ -66,7 +113,7 @@ export default function ImageWindow() { videoRef.current.play() } } - }, [currentLiveVideoPath, isPlayingLive]) + }, [effectiveLiveVideoPath, isPlayingLive]) // 视频真正开始播放(画面就绪) const handleVideoPlaying = useCallback(() => { @@ -353,7 +400,7 @@ export default function ImageWindow() { if (e.key === '-') handleZoomOut() if (e.key === 'r' || e.key === 'R') handleRotate() if (e.key === '0') handleReset() - if (e.key === ' ' && currentLiveVideoPath) { + if (e.key === ' ' && effectiveLiveVideoPath) { e.preventDefault() handlePlayLiveVideo() } @@ -362,11 +409,11 @@ export default function ImageWindow() { } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [handleReset, currentLiveVideoPath, isPlayingLive, handlePlayLiveVideo, goPrev, goNext]) + }, [handleReset, effectiveLiveVideoPath, isPlayingLive, handlePlayLiveVideo, goPrev, goNext]) - const hasLiveVideo = !!currentLiveVideoPath + const hasLiveVideo = !!effectiveLiveVideoPath - if (!currentImagePath) { + if (!effectiveImagePath) { return (
无效的图片路径 @@ -431,7 +478,7 @@ export default function ImageWindow() { }} > Preview void @@ -25,7 +31,12 @@ export interface ElectronAPI { isChatWindowOpen: () => Promise closeChatWindow: () => Promise setTitleBarOverlay: (options: { symbolColor: string }) => void - openImageViewerWindow: (imagePath: string, liveVideoPath?: string, imageList?: ImageListItem[]) => Promise + openImageViewerWindow: ( + imagePath: string, + liveVideoPath?: string, + imageList?: ImageListItem[], + options?: ImageViewerOpenOptions + ) => Promise openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise openBrowserWindow: (url: string, title?: string) => Promise resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise