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
+21 -5
View File
@@ -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) => {
+6 -1
View File
@@ -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),
+63
View File
@@ -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,
+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>