mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
@@ -402,7 +402,7 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe
|
|||||||
/**
|
/**
|
||||||
* 创建独立的图片查看窗口
|
* 创建独立的图片查看窗口
|
||||||
*/
|
*/
|
||||||
function createImageViewerWindow(imagePath: string) {
|
function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
|
||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? join(__dirname, '../public/icon.ico')
|
? join(__dirname, '../public/icon.ico')
|
||||||
@@ -435,7 +435,8 @@ function createImageViewerWindow(imagePath: string) {
|
|||||||
win.show()
|
win.show()
|
||||||
})
|
})
|
||||||
|
|
||||||
const imageParam = `imagePath=${encodeURIComponent(imagePath)}`
|
let imageParam = `imagePath=${encodeURIComponent(imagePath)}`
|
||||||
|
if (liveVideoPath) imageParam += `&liveVideoPath=${encodeURIComponent(liveVideoPath)}`
|
||||||
|
|
||||||
if (process.env.VITE_DEV_SERVER_URL) {
|
if (process.env.VITE_DEV_SERVER_URL) {
|
||||||
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`)
|
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`)
|
||||||
@@ -1163,8 +1164,18 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 打开图片查看窗口
|
// 打开图片查看窗口
|
||||||
ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => {
|
ipcMain.handle('window:openImageViewerWindow', async (_, imagePath: string, liveVideoPath?: string) => {
|
||||||
createImageViewerWindow(imagePath)
|
// 如果是 dataUrl,写入临时文件
|
||||||
|
if (imagePath.startsWith('data:')) {
|
||||||
|
const commaIdx = imagePath.indexOf(',')
|
||||||
|
const meta = imagePath.slice(5, commaIdx) // e.g. "image/jpeg;base64"
|
||||||
|
const ext = meta.split('/')[1]?.split(';')[0] || 'jpg'
|
||||||
|
const tmpPath = join(app.getPath('temp'), `weflow_preview_${Date.now()}.${ext}`)
|
||||||
|
await writeFile(tmpPath, Buffer.from(imagePath.slice(commaIdx + 1), 'base64'))
|
||||||
|
createImageViewerWindow(`file://${tmpPath.replace(/\\/g, '/')}`, liveVideoPath)
|
||||||
|
} else {
|
||||||
|
createImageViewerWindow(imagePath, liveVideoPath)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 完成引导,关闭引导窗口并显示主窗口
|
// 完成引导,关闭引导窗口并显示主窗口
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
|
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
|
||||||
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
|
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
|
||||||
openImageViewerWindow: (imagePath: string) =>
|
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
|
||||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath),
|
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
|
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type DecryptResult = {
|
|||||||
localPath?: string
|
localPath?: string
|
||||||
error?: string
|
error?: string
|
||||||
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
||||||
|
liveVideoPath?: string // 实况照片的视频路径
|
||||||
}
|
}
|
||||||
|
|
||||||
type HardlinkState = {
|
type HardlinkState = {
|
||||||
@@ -61,6 +62,7 @@ export class ImageDecryptService {
|
|||||||
private cacheIndexed = false
|
private cacheIndexed = false
|
||||||
private cacheIndexing: Promise<void> | null = null
|
private cacheIndexing: Promise<void> | null = null
|
||||||
private updateFlags = new Map<string, boolean>()
|
private updateFlags = new Map<string, boolean>()
|
||||||
|
private noLiveSet = new Set<string>() // 已确认无 live 视频的图片路径
|
||||||
|
|
||||||
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||||
if (!this.configService.get('logEnabled')) return
|
if (!this.configService.get('logEnabled')) return
|
||||||
@@ -116,8 +118,9 @@ export class ImageDecryptService {
|
|||||||
} else {
|
} else {
|
||||||
this.updateFlags.delete(key)
|
this.updateFlags.delete(key)
|
||||||
}
|
}
|
||||||
|
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(cached)
|
||||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
|
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
|
||||||
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate }
|
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate, liveVideoPath }
|
||||||
}
|
}
|
||||||
if (cached && !this.isImageFile(cached)) {
|
if (cached && !this.isImageFile(cached)) {
|
||||||
this.resolvedCache.delete(key)
|
this.resolvedCache.delete(key)
|
||||||
@@ -136,8 +139,9 @@ export class ImageDecryptService {
|
|||||||
} else {
|
} else {
|
||||||
this.updateFlags.delete(key)
|
this.updateFlags.delete(key)
|
||||||
}
|
}
|
||||||
|
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
|
||||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
|
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
|
||||||
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
|
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate, liveVideoPath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||||
@@ -156,8 +160,9 @@ export class ImageDecryptService {
|
|||||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||||
const dataUrl = this.fileToDataUrl(cached)
|
const dataUrl = this.fileToDataUrl(cached)
|
||||||
const localPath = dataUrl || this.filePathToUrl(cached)
|
const localPath = dataUrl || this.filePathToUrl(cached)
|
||||||
|
const liveVideoPath = this.isThumbnailPath(cached) ? undefined : this.checkLiveVideoCache(cached)
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||||
return { success: true, localPath }
|
return { success: true, localPath, liveVideoPath }
|
||||||
}
|
}
|
||||||
if (cached && !this.isImageFile(cached)) {
|
if (cached && !this.isImageFile(cached)) {
|
||||||
this.resolvedCache.delete(cacheKey)
|
this.resolvedCache.delete(cacheKey)
|
||||||
@@ -235,8 +240,9 @@ export class ImageDecryptService {
|
|||||||
const dataUrl = this.fileToDataUrl(existing)
|
const dataUrl = this.fileToDataUrl(existing)
|
||||||
const localPath = dataUrl || this.filePathToUrl(existing)
|
const localPath = dataUrl || this.filePathToUrl(existing)
|
||||||
const isThumb = this.isThumbnailPath(existing)
|
const isThumb = this.isThumbnailPath(existing)
|
||||||
|
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||||
return { success: true, localPath, isThumb }
|
return { success: true, localPath, isThumb, liveVideoPath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +302,15 @@ export class ImageDecryptService {
|
|||||||
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
|
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
|
||||||
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
||||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||||
return { success: true, localPath, isThumb }
|
|
||||||
|
// 检测实况照片(Motion Photo)
|
||||||
|
let liveVideoPath: string | undefined
|
||||||
|
if (!isThumb && (finalExt === '.jpg' || finalExt === '.jpeg')) {
|
||||||
|
const videoPath = await this.extractMotionPhotoVideo(outputPath, decrypted)
|
||||||
|
if (videoPath) liveVideoPath = this.filePathToUrl(videoPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, localPath, isThumb, liveVideoPath }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
@@ -1681,6 +1695,76 @@ export class ImageDecryptService {
|
|||||||
return mostCommonKey
|
return mostCommonKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查图片对应的 live 视频缓存,返回 file:// URL 或 undefined
|
||||||
|
* 已确认无 live 的路径会被记录,下次直接跳过
|
||||||
|
*/
|
||||||
|
private checkLiveVideoCache(imagePath: string): string | undefined {
|
||||||
|
if (this.noLiveSet.has(imagePath)) return undefined
|
||||||
|
const livePath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4')
|
||||||
|
if (existsSync(livePath)) return this.filePathToUrl(livePath)
|
||||||
|
this.noLiveSet.add(imagePath)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测并分离 Motion Photo(实况照片)
|
||||||
|
* Google Motion Photo = JPEG + MP4 拼接在一起
|
||||||
|
* 返回视频文件路径,如果不是实况照片则返回 null
|
||||||
|
*/
|
||||||
|
private async extractMotionPhotoVideo(imagePath: string, imageBuffer: Buffer): Promise<string | null> {
|
||||||
|
// 只处理 JPEG 文件
|
||||||
|
if (imageBuffer.length < 8) return null
|
||||||
|
if (imageBuffer[0] !== 0xff || imageBuffer[1] !== 0xd8) return null
|
||||||
|
|
||||||
|
// 从末尾向前搜索 MP4 ftyp 原子签名
|
||||||
|
// ftyp 原子结构: [4字节大小][ftyp(66 74 79 70)][品牌...]
|
||||||
|
// 实际起始位置在 ftyp 前4字节(大小字段)
|
||||||
|
const ftypSig = [0x66, 0x74, 0x79, 0x70] // 'ftyp'
|
||||||
|
let videoOffset: number | null = null
|
||||||
|
|
||||||
|
const searchEnd = Math.max(0, imageBuffer.length - 8)
|
||||||
|
for (let i = searchEnd; i > 0; i--) {
|
||||||
|
if (imageBuffer[i] === ftypSig[0] &&
|
||||||
|
imageBuffer[i + 1] === ftypSig[1] &&
|
||||||
|
imageBuffer[i + 2] === ftypSig[2] &&
|
||||||
|
imageBuffer[i + 3] === ftypSig[3]) {
|
||||||
|
// ftyp 前4字节是 box size,实际 MP4 从这里开始
|
||||||
|
videoOffset = i - 4
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备用:从 XMP 元数据中读取偏移量
|
||||||
|
if (videoOffset === null || videoOffset <= 0) {
|
||||||
|
try {
|
||||||
|
const text = imageBuffer.toString('latin1')
|
||||||
|
const match = text.match(/MediaDataOffset="(\d+)"/i) || text.match(/MicroVideoOffset="(\d+)"/i)
|
||||||
|
if (match) {
|
||||||
|
const offset = parseInt(match[1], 10)
|
||||||
|
if (offset > 0 && offset < imageBuffer.length) {
|
||||||
|
videoOffset = imageBuffer.length - offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoOffset === null || videoOffset <= 100) return null
|
||||||
|
|
||||||
|
// 验证视频部分确实以有效 MP4 数据开头
|
||||||
|
const videoStart = imageBuffer[videoOffset + 4] === 0x66 &&
|
||||||
|
imageBuffer[videoOffset + 5] === 0x74 &&
|
||||||
|
imageBuffer[videoOffset + 6] === 0x79 &&
|
||||||
|
imageBuffer[videoOffset + 7] === 0x70
|
||||||
|
if (!videoStart) return null
|
||||||
|
|
||||||
|
// 写出视频文件
|
||||||
|
const videoPath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4')
|
||||||
|
const videoBuffer = imageBuffer.slice(videoOffset)
|
||||||
|
await writeFile(videoPath, videoBuffer)
|
||||||
|
return videoPath
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解包 wxgf 格式
|
* 解包 wxgf 格式
|
||||||
* wxgf 是微信的图片格式,内部使用 HEVC 编码
|
* wxgf 是微信的图片格式,内部使用 HEVC 编码
|
||||||
|
|||||||
@@ -1581,6 +1581,23 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
|
||||||
|
.media-badge.live {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
left: auto;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border-radius: 50%;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-update-button {
|
.image-update-button {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
|||||||
import type { ChatSession, Message } from '../types/models'
|
import type { ChatSession, Message } from '../types/models'
|
||||||
import { getEmojiPath } from 'wechat-emojis'
|
import { getEmojiPath } from 'wechat-emojis'
|
||||||
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||||
|
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
||||||
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
||||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
@@ -2625,6 +2626,7 @@ function MessageBubble({
|
|||||||
const [imageInView, setImageInView] = useState(false)
|
const [imageInView, setImageInView] = useState(false)
|
||||||
const imageForceHdAttempted = useRef<string | null>(null)
|
const imageForceHdAttempted = useRef<string | null>(null)
|
||||||
const imageForceHdPending = useRef(false)
|
const imageForceHdPending = useRef(false)
|
||||||
|
const [imageLiveVideoPath, setImageLiveVideoPath] = useState<string | undefined>(undefined)
|
||||||
const [voiceError, setVoiceError] = useState(false)
|
const [voiceError, setVoiceError] = useState(false)
|
||||||
const [voiceLoading, setVoiceLoading] = useState(false)
|
const [voiceLoading, setVoiceLoading] = useState(false)
|
||||||
const [isVoicePlaying, setIsVoicePlaying] = useState(false)
|
const [isVoicePlaying, setIsVoicePlaying] = useState(false)
|
||||||
@@ -2853,6 +2855,7 @@ function MessageBubble({
|
|||||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||||
setImageLocalPath(result.localPath)
|
setImageLocalPath(result.localPath)
|
||||||
setImageHasUpdate(false)
|
setImageHasUpdate(false)
|
||||||
|
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2920,7 +2923,7 @@ function MessageBubble({
|
|||||||
sessionId: session.username,
|
sessionId: session.username,
|
||||||
imageMd5: message.imageMd5 || undefined,
|
imageMd5: message.imageMd5 || undefined,
|
||||||
imageDatName: message.imageDatName
|
imageDatName: message.imageDatName
|
||||||
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }) => {
|
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (result.success && result.localPath) {
|
if (result.success && result.localPath) {
|
||||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||||
@@ -2928,6 +2931,7 @@ function MessageBubble({
|
|||||||
setImageLocalPath(result.localPath)
|
setImageLocalPath(result.localPath)
|
||||||
setImageError(false)
|
setImageError(false)
|
||||||
}
|
}
|
||||||
|
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
||||||
setImageHasUpdate(Boolean(result.hasUpdate))
|
setImageHasUpdate(Boolean(result.hasUpdate))
|
||||||
}
|
}
|
||||||
}).catch(() => { })
|
}).catch(() => { })
|
||||||
@@ -3423,14 +3427,17 @@ function MessageBubble({
|
|||||||
alt="图片"
|
alt="图片"
|
||||||
className="image-message"
|
className="image-message"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (imageHasUpdate) {
|
if (imageHasUpdate) void requestImageDecrypt(true, true)
|
||||||
void requestImageDecrypt(true, true)
|
void window.electronAPI.window.openImageViewerWindow(imageLocalPath!, imageLiveVideoPath || undefined)
|
||||||
}
|
|
||||||
void window.electronAPI.window.openImageViewerWindow(imageLocalPath)
|
|
||||||
}}
|
}}
|
||||||
onLoad={() => setImageError(false)}
|
onLoad={() => setImageError(false)}
|
||||||
onError={() => setImageError(true)}
|
onError={() => setImageError(true)}
|
||||||
/>
|
/>
|
||||||
|
{imageLiveVideoPath && (
|
||||||
|
<div className="media-badge live">
|
||||||
|
<LivePhotoIcon size={14} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img, video {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
|||||||
@@ -2,15 +2,20 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
|
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
|
||||||
|
import { LivePhotoIcon } from '../components/LivePhotoIcon'
|
||||||
import './ImageWindow.scss'
|
import './ImageWindow.scss'
|
||||||
|
|
||||||
export default function ImageWindow() {
|
export default function ImageWindow() {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const imagePath = searchParams.get('imagePath')
|
const imagePath = searchParams.get('imagePath')
|
||||||
|
const liveVideoPath = searchParams.get('liveVideoPath')
|
||||||
|
const [showLive, setShowLive] = useState(false)
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
const [scale, setScale] = useState(1)
|
const [scale, setScale] = useState(1)
|
||||||
const [rotation, setRotation] = useState(0)
|
const [rotation, setRotation] = useState(0)
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||||
const [initialScale, setInitialScale] = useState(1)
|
const [initialScale, setInitialScale] = useState(1)
|
||||||
|
const [imgNatural, setImgNatural] = useState({ w: 0, h: 0 })
|
||||||
const viewportRef = useRef<HTMLDivElement>(null)
|
const viewportRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// 使用 ref 存储拖动状态,避免闭包问题
|
// 使用 ref 存储拖动状态,避免闭包问题
|
||||||
@@ -39,6 +44,7 @@ export default function ImageWindow() {
|
|||||||
const img = e.currentTarget
|
const img = e.currentTarget
|
||||||
const naturalWidth = img.naturalWidth
|
const naturalWidth = img.naturalWidth
|
||||||
const naturalHeight = img.naturalHeight
|
const naturalHeight = img.naturalHeight
|
||||||
|
setImgNatural({ w: naturalWidth, h: naturalHeight })
|
||||||
|
|
||||||
if (viewportRef.current) {
|
if (viewportRef.current) {
|
||||||
const viewportWidth = viewportRef.current.clientWidth * 0.9
|
const viewportWidth = viewportRef.current.clientWidth * 0.9
|
||||||
@@ -131,6 +137,23 @@ export default function ImageWindow() {
|
|||||||
<div className="title-bar">
|
<div className="title-bar">
|
||||||
<div className="window-drag-area"></div>
|
<div className="window-drag-area"></div>
|
||||||
<div className="title-bar-controls">
|
<div className="title-bar-controls">
|
||||||
|
{liveVideoPath && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const next = !showLive
|
||||||
|
setShowLive(next)
|
||||||
|
if (next && videoRef.current) {
|
||||||
|
videoRef.current.currentTime = 0
|
||||||
|
videoRef.current.play()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={showLive ? '显示照片' : '播放实况'}
|
||||||
|
className={showLive ? 'active' : ''}
|
||||||
|
>
|
||||||
|
<LivePhotoIcon size={16} />
|
||||||
|
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
|
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
|
||||||
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
|
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
|
||||||
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
|
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
|
||||||
@@ -147,11 +170,28 @@ export default function ImageWindow() {
|
|||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
>
|
>
|
||||||
|
{liveVideoPath && (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={liveVideoPath}
|
||||||
|
width={imgNatural.w || undefined}
|
||||||
|
height={imgNatural.h || undefined}
|
||||||
|
style={{
|
||||||
|
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`,
|
||||||
|
position: showLive ? 'relative' : 'absolute',
|
||||||
|
opacity: showLive ? 1 : 0,
|
||||||
|
pointerEvents: showLive ? 'auto' : 'none'
|
||||||
|
}}
|
||||||
|
onEnded={() => setShowLive(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<img
|
<img
|
||||||
src={imagePath}
|
src={imagePath}
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`
|
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`,
|
||||||
|
opacity: showLive ? 0 : 1,
|
||||||
|
position: showLive ? 'absolute' : 'relative'
|
||||||
}}
|
}}
|
||||||
onLoad={handleImageLoad}
|
onLoad={handleImageLoad}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
|||||||
@@ -414,7 +414,8 @@
|
|||||||
|
|
||||||
&.live {
|
&.live {
|
||||||
top: 8px;
|
top: 8px;
|
||||||
left: 8px;
|
right: 8px;
|
||||||
|
left: auto;
|
||||||
transform: none;
|
transform: none;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'
|
import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'
|
||||||
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import { ImagePreview } from '../components/ImagePreview'
|
|
||||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||||
import './SnsPage.scss'
|
import './SnsPage.scss'
|
||||||
import { SnsPost } from '../types/sns'
|
import { SnsPost } from '../types/sns'
|
||||||
@@ -32,7 +31,6 @@ export default function SnsPage() {
|
|||||||
|
|
||||||
// UI states
|
// UI states
|
||||||
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
||||||
const [previewImage, setPreviewImage] = useState<{ src: string, isVideo?: boolean, liveVideoPath?: string } | null>(null)
|
|
||||||
const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
|
const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
|
||||||
|
|
||||||
// 导出相关状态
|
// 导出相关状态
|
||||||
@@ -332,7 +330,13 @@ export default function SnsPage() {
|
|||||||
<SnsPostItem
|
<SnsPostItem
|
||||||
key={post.id}
|
key={post.id}
|
||||||
post={post}
|
post={post}
|
||||||
onPreview={(src, isVideo, liveVideoPath) => setPreviewImage({ src, isVideo, liveVideoPath })}
|
onPreview={(src, isVideo, liveVideoPath) => {
|
||||||
|
if (isVideo) {
|
||||||
|
void window.electronAPI.window.openVideoPlayerWindow(src)
|
||||||
|
} else {
|
||||||
|
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDebug={(p) => setDebugPost(p)}
|
onDebug={(p) => setDebugPost(p)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -394,15 +398,6 @@ export default function SnsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dialogs and Overlays */}
|
{/* Dialogs and Overlays */}
|
||||||
{previewImage && (
|
|
||||||
<ImagePreview
|
|
||||||
src={previewImage.src}
|
|
||||||
isVideo={previewImage.isVideo}
|
|
||||||
liveVideoPath={previewImage.liveVideoPath}
|
|
||||||
onClose={() => setPreviewImage(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<JumpToDateDialog
|
<JumpToDateDialog
|
||||||
isOpen={showJumpDialog}
|
isOpen={showJumpDialog}
|
||||||
onClose={() => setShowJumpDialog(false)}
|
onClose={() => setShowJumpDialog(false)}
|
||||||
|
|||||||
4
src/types/electron.d.ts
vendored
4
src/types/electron.d.ts
vendored
@@ -11,7 +11,7 @@ export interface ElectronAPI {
|
|||||||
setTitleBarOverlay: (options: { symbolColor: string }) => void
|
setTitleBarOverlay: (options: { symbolColor: string }) => void
|
||||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
|
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
|
||||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
||||||
openImageViewerWindow: (imagePath: string) => Promise<void>
|
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
|
||||||
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
|
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
|
||||||
}
|
}
|
||||||
config: {
|
config: {
|
||||||
@@ -125,7 +125,7 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
image: {
|
image: {
|
||||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
|
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
|
||||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean>
|
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean>
|
||||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
||||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
|
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
|
||||||
|
|||||||
Reference in New Issue
Block a user