feat: 增强图片查看器功能,支持高清图升级及相关参数传递

This commit is contained in:
ILoveBingLu
2026-03-04 02:25:44 +08:00
parent 79af4bfaa3
commit 3ca7dfbe10
6 changed files with 310 additions and 22 deletions
+153 -7
View File
@@ -2621,6 +2621,8 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
const [imageClicked, setImageClicked] = useState(false)
const imageUpdateCheckedRef = useRef<string | null>(null)
const imageClickTimerRef = useRef<number | null>(null)
const imageRecoveringRef = useRef(false)
const lastRecoverTriedPathRef = useRef<string | null>(null)
const [isVisible, setIsVisible] = useState(false)
const imageContainerRef = useRef<HTMLDivElement>(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 && (
<button
type="button"
className="image-update-button"
title="检测到高清图,点击更新"
onClick={(e) => {
e.stopPropagation()
void requestImageDecrypt(true)
}}
>
<RefreshCw size={14} />
</button>
)}
{imageLiveVideoPath && (
<div className="media-badge live">
<LivePhotoIcon size={14} />
+55 -8
View File
@@ -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<Array<{ imagePath: string; liveVideoPath?: string }>>([])
@@ -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<string | null>(null)
const [hdLiveVideoPath, setHdLiveVideoPath] = useState<string | undefined>(undefined)
const upgradeTriedRef = useRef<string | null>(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 (
<div className="image-window-empty">
<span></span>
@@ -431,7 +478,7 @@ export default function ImageWindow() {
}}
>
<img
src={currentImagePath}
src={effectiveImagePath}
alt="Preview"
className={isPannable ? 'pannable' : ''}
onLoad={handleImageLoad}
@@ -441,7 +488,7 @@ export default function ImageWindow() {
{hasLiveVideo && isPlayingLive && (
<video
ref={videoRef}
src={currentLiveVideoPath || ''}
src={effectiveLiveVideoPath || ''}
className={`live-video ${isVideoVisible ? 'visible' : ''}`}
autoPlay
// muted={false} // Default is unmuted, explicit false for clarity
+12 -1
View File
@@ -6,6 +6,12 @@ export interface ImageListItem {
liveVideoPath?: string
}
export interface ImageViewerOpenOptions {
sessionId?: string
imageMd5?: string
imageDatName?: string
}
export interface ElectronAPI {
window: {
minimize: () => void
@@ -25,7 +31,12 @@ export interface ElectronAPI {
isChatWindowOpen: () => Promise<boolean>
closeChatWindow: () => Promise<boolean>
setTitleBarOverlay: (options: { symbolColor: string }) => void
openImageViewerWindow: (imagePath: string, liveVideoPath?: string, imageList?: ImageListItem[]) => Promise<void>
openImageViewerWindow: (
imagePath: string,
liveVideoPath?: string,
imageList?: ImageListItem[],
options?: ImageViewerOpenOptions
) => Promise<void>
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
openBrowserWindow: (url: string, title?: string) => Promise<void>
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>