From fdfd59fbdff8d139ae2bf7bef3e8548986424622 Mon Sep 17 00:00:00 2001
From: xuncha <1658671838@qq.com>
Date: Mon, 2 Feb 2026 18:20:26 +0800
Subject: [PATCH] =?UTF-8?q?=E7=BB=99=E5=AF=86=E8=AF=AD=E7=9A=84=E5=9B=BE?=
=?UTF-8?q?=E7=89=87=E6=9F=A5=E7=9C=8B=E5=99=A8=E6=90=AC=E8=BF=87=E6=9D=A5?=
=?UTF-8?q?=E4=BA=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
electron/main.ts | 65 +++++++++++++++
electron/preload.ts | 2 +
src/App.tsx | 7 ++
src/pages/ChatPage.tsx | 16 +---
src/pages/ImageWindow.scss | 99 +++++++++++++++++++++++
src/pages/ImageWindow.tsx | 162 +++++++++++++++++++++++++++++++++++++
src/types/electron.d.ts | 1 +
7 files changed, 339 insertions(+), 13 deletions(-)
create mode 100644 src/pages/ImageWindow.scss
create mode 100644 src/pages/ImageWindow.tsx
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/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)}%
+
+
+
+
+
+
+
+
+

+
+
+ )
+}
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: {