From 1008eb6c72da9a0fd6ce0ab65fd7ce020dd369a1 Mon Sep 17 00:00:00 2001 From: imeepos Date: Tue, 29 Jul 2025 17:29:57 +0800 Subject: [PATCH] feat: Add image gallery modal for enhanced image preview functionality --- .../commands/image_generation_commands.rs | 5 +- .../src/components/ImageGalleryModal.tsx | 334 ++++++++++++++++++ .../src/pages/tools/ImageGenerationTool.tsx | 88 +++-- 3 files changed, 404 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/src/components/ImageGalleryModal.tsx diff --git a/apps/desktop/src-tauri/src/presentation/commands/image_generation_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/image_generation_commands.rs index 0f7081e..b88b083 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/image_generation_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/image_generation_commands.rs @@ -5,7 +5,7 @@ use serde_json; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use tauri::{command, State, Manager, AppHandle}; +use tauri::{command, State, Manager, AppHandle, Emitter}; use tracing::{error, info}; use crate::business::services::image_generation_service::ImageGenerationService as DbImageGenerationService; @@ -613,11 +613,12 @@ pub async fn start_background_task_monitoring( // 如果任务完成或失败,停止监控 if status.status == "completed" { + let result_urls = status.result_urls.clone(); let _ = service.complete_generation_by_task_id(&task_id_clone, status.result_urls); let _ = app_handle.emit("image-generation-completed", serde_json::json!({ "record_id": record_id_clone, "task_id": task_id_clone, - "result_urls": status.result_urls + "result_urls": result_urls })); break; } else if status.status == "failed" { diff --git a/apps/desktop/src/components/ImageGalleryModal.tsx b/apps/desktop/src/components/ImageGalleryModal.tsx new file mode 100644 index 0000000..40c218d --- /dev/null +++ b/apps/desktop/src/components/ImageGalleryModal.tsx @@ -0,0 +1,334 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + X, + ChevronLeft, + ChevronRight, + Download, + ZoomIn, + ZoomOut, + RotateCw, + Maximize2, + Minimize2, + Copy, + ExternalLink +} from 'lucide-react'; + +interface ImageGalleryModalProps { + images: string[]; + initialIndex: number; + isOpen: boolean; + onClose: () => void; + title?: string; +} + +export const ImageGalleryModal: React.FC = ({ + images, + initialIndex, + isOpen, + onClose, + title = "图片预览" +}) => { + const [currentIndex, setCurrentIndex] = useState(initialIndex); + const [zoom, setZoom] = useState(1); + const [rotation, setRotation] = useState(0); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + // 重置状态当模态框打开时 + useEffect(() => { + if (isOpen) { + setCurrentIndex(initialIndex); + setZoom(1); + setRotation(0); + setIsFullscreen(false); + } + }, [isOpen, initialIndex]); + + // 键盘快捷键 + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Escape': + onClose(); + break; + case 'ArrowLeft': + goToPrevious(); + break; + case 'ArrowRight': + goToNext(); + break; + case '+': + case '=': + handleZoomIn(); + break; + case '-': + handleZoomOut(); + break; + case '0': + setZoom(1); + break; + case 'r': + case 'R': + handleRotate(); + break; + case 'f': + case 'F': + setIsFullscreen(!isFullscreen); + break; + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, currentIndex, zoom, isFullscreen]); + + const goToPrevious = useCallback(() => { + setCurrentIndex(prev => prev > 0 ? prev - 1 : images.length - 1); + setZoom(1); + setRotation(0); + }, [images.length]); + + const goToNext = useCallback(() => { + setCurrentIndex(prev => prev < images.length - 1 ? prev + 1 : 0); + setZoom(1); + setRotation(0); + }, [images.length]); + + const handleZoomIn = () => { + setZoom(prev => Math.min(prev + 0.25, 3)); + }; + + const handleZoomOut = () => { + setZoom(prev => Math.max(prev - 0.25, 0.25)); + }; + + const handleRotate = () => { + setRotation(prev => (prev + 90) % 360); + }; + + const handleDownload = async () => { + if (!images[currentIndex]) return; + + setIsLoading(true); + try { + const response = await fetch(images[currentIndex]); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = `generated-image-${currentIndex + 1}.jpg`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + window.URL.revokeObjectURL(url); + } catch (error) { + console.error('下载失败:', error); + } finally { + setIsLoading(false); + } + }; + + const handleCopyUrl = async () => { + if (!images[currentIndex]) return; + + try { + await navigator.clipboard.writeText(images[currentIndex]); + // 这里可以添加一个临时的成功提示 + } catch (error) { + console.error('复制失败:', error); + } + }; + + const handleOpenInNewTab = () => { + if (!images[currentIndex]) return; + window.open(images[currentIndex], '_blank'); + }; + + if (!isOpen || images.length === 0) return null; + + const currentImage = images[currentIndex]; + + return ( +
+ {/* 背景遮罩 */} +
+ + {/* 主容器 */} +
+ + {/* 顶部工具栏 */} +
+
+

{title}

+ + {currentIndex + 1} / {images.length} + +
+ +
+ {/* 缩放控制 */} + + + + {Math.round(zoom * 100)}% + + + + + {/* 旋转 */} + + + {/* 全屏切换 */} + + + {/* 复制链接 */} + + + {/* 新标签页打开 */} + + + {/* 下载 */} + + + {/* 关闭 */} + +
+
+ + {/* 图片容器 */} +
+ {/* 左箭头 */} + {images.length > 1 && ( + + )} + + {/* 图片 */} +
+ {`预览图片 +
+ + {/* 右箭头 */} + {images.length > 1 && ( + + )} +
+ + {/* 底部缩略图导航 */} + {images.length > 1 && ( +
+
+ {images.map((image, index) => ( + + ))} +
+
+ )} + + {/* 快捷键提示 */} +
+
快捷键:
+
← → 切换图片
+
+ - 缩放
+
R 旋转
+
F 全屏
+
Esc 关闭
+
+
+
+ ); +}; diff --git a/apps/desktop/src/pages/tools/ImageGenerationTool.tsx b/apps/desktop/src/pages/tools/ImageGenerationTool.tsx index 10e93b4..572adf0 100644 --- a/apps/desktop/src/pages/tools/ImageGenerationTool.tsx +++ b/apps/desktop/src/pages/tools/ImageGenerationTool.tsx @@ -20,6 +20,7 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { open } from '@tauri-apps/plugin-dialog'; import { useNotifications } from '../../components/NotificationSystem'; +import { ImageGalleryModal } from '../../components/ImageGalleryModal'; import { PromptCheckResponse, ImageGenerationRequest, @@ -46,6 +47,19 @@ const ImageGenerationTool: React.FC = () => { // 生成记录列表 const [records, setRecords] = useState([]); const [isLoadingRecords, setIsLoadingRecords] = useState(false); + + // 图片预览模态框 + const [previewModal, setPreviewModal] = useState<{ + isOpen: boolean; + images: string[]; + initialIndex: number; + title: string; + }>({ + isOpen: false, + images: [], + initialIndex: 0, + title: '' + }); // 通知系统 const { addNotification } = useNotifications(); @@ -332,16 +346,7 @@ const ImageGenerationTool: React.FC = () => { recordId }); - // 停止该记录的轮询 - const interval = pollingIntervals.get(recordId); - if (interval) { - clearInterval(interval); - setPollingIntervals(prev => { - const newMap = new Map(prev); - newMap.delete(recordId); - return newMap; - }); - } + // 删除记录时,后台监控会自动停止 // 重新加载记录列表 loadRecords(); @@ -359,7 +364,22 @@ const ImageGenerationTool: React.FC = () => { message: `无法删除记录: ${error}` }); } - }, [pollingIntervals, loadRecords, addNotification]); + }, [loadRecords, addNotification]); + + // 打开图片预览 + const openImagePreview = useCallback((images: string[], initialIndex: number, title: string) => { + setPreviewModal({ + isOpen: true, + images, + initialIndex, + title + }); + }, []); + + // 关闭图片预览 + const closeImagePreview = useCallback(() => { + setPreviewModal(prev => ({ ...prev, isOpen: false })); + }, []); return (
@@ -514,6 +534,7 @@ const ImageGenerationTool: React.FC = () => { key={record.id} record={record} onDelete={() => deleteRecord(record.id)} + onImagePreview={openImagePreview} /> )) )} @@ -522,6 +543,15 @@ const ImageGenerationTool: React.FC = () => {
+ + {/* 图片预览模态框 */} + ); }; @@ -530,9 +560,10 @@ const ImageGenerationTool: React.FC = () => { interface RecordCardProps { record: ImageGenerationRecord; onDelete: () => void; + onImagePreview: (images: string[], initialIndex: number, title: string) => void; } -const RecordCard: React.FC = ({ record, onDelete }) => { +const RecordCard: React.FC = ({ record, onDelete, onImagePreview }) => { const getStatusIcon = () => { switch (record.status) { case ImageGenerationRecordStatus.PENDING: @@ -651,19 +682,34 @@ const RecordCard: React.FC = ({ record, onDelete }) => {
{record.result_urls.map((url, index) => ( -
+
{`Generated onImagePreview( + record.result_urls, + index, + `生成结果 - ${record.prompt.slice(0, 30)}${record.prompt.length > 30 ? '...' : ''}` + )} /> -
- +
+
+ +
))}