feat: Add image gallery modal for enhanced image preview functionality

This commit is contained in:
imeepos 2025-07-29 17:29:57 +08:00
parent 2f6a4dc1be
commit 1008eb6c72
3 changed files with 404 additions and 23 deletions

View File

@ -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" {

View File

@ -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<ImageGalleryModalProps> = ({
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 (
<div className="fixed inset-0 z-50 bg-black bg-opacity-95 flex items-center justify-center">
{/* 背景遮罩 */}
<div
className="absolute inset-0 cursor-pointer"
onClick={onClose}
/>
{/* 主容器 */}
<div className={`relative ${isFullscreen ? 'w-full h-full' : 'max-w-7xl max-h-[95vh] w-full h-full'} flex flex-col`}>
{/* 顶部工具栏 */}
<div className="relative z-10 bg-black bg-opacity-60 backdrop-blur-sm p-4 flex items-center justify-between">
<div className="flex items-center space-x-4">
<h3 className="text-white text-lg font-medium">{title}</h3>
<span className="text-gray-300 text-sm">
{currentIndex + 1} / {images.length}
</span>
</div>
<div className="flex items-center space-x-2">
{/* 缩放控制 */}
<button
onClick={handleZoomOut}
className="p-2 text-white hover:bg-white hover:bg-opacity-20 rounded-lg transition-colors"
title="缩小 (-)"
>
<ZoomOut className="w-5 h-5" />
</button>
<span className="text-white text-sm min-w-[60px] text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="p-2 text-white hover:bg-white hover:bg-opacity-20 rounded-lg transition-colors"
title="放大 (+)"
>
<ZoomIn className="w-5 h-5" />
</button>
{/* 旋转 */}
<button
onClick={handleRotate}
className="p-2 text-white hover:bg-white hover:bg-opacity-20 rounded-lg transition-colors"
title="旋转 (R)"
>
<RotateCw className="w-5 h-5" />
</button>
{/* 全屏切换 */}
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="p-2 text-white hover:bg-white hover:bg-opacity-20 rounded-lg transition-colors"
title="全屏 (F)"
>
{isFullscreen ? <Minimize2 className="w-5 h-5" /> : <Maximize2 className="w-5 h-5" />}
</button>
{/* 复制链接 */}
<button
onClick={handleCopyUrl}
className="p-2 text-white hover:bg-white hover:bg-opacity-20 rounded-lg transition-colors"
title="复制图片链接"
>
<Copy className="w-5 h-5" />
</button>
{/* 新标签页打开 */}
<button
onClick={handleOpenInNewTab}
className="p-2 text-white hover:bg-white hover:bg-opacity-20 rounded-lg transition-colors"
title="在新标签页打开"
>
<ExternalLink className="w-5 h-5" />
</button>
{/* 下载 */}
<button
onClick={handleDownload}
disabled={isLoading}
className="p-2 text-white hover:bg-white hover:bg-opacity-20 rounded-lg transition-colors disabled:opacity-50"
title="下载图片"
>
<Download className={`w-5 h-5 ${isLoading ? 'animate-pulse' : ''}`} />
</button>
{/* 关闭 */}
<button
onClick={onClose}
className="p-2 text-white hover:bg-white hover:bg-opacity-20 rounded-lg transition-colors"
title="关闭 (Esc)"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* 图片容器 */}
<div className="flex-1 relative overflow-hidden flex items-center justify-center">
{/* 左箭头 */}
{images.length > 1 && (
<button
onClick={goToPrevious}
className="absolute left-4 z-10 p-3 bg-black bg-opacity-60 text-white rounded-full hover:bg-opacity-80 transition-all shadow-lg"
title="上一张 (←)"
>
<ChevronLeft className="w-6 h-6" />
</button>
)}
{/* 图片 */}
<div className="relative max-w-full max-h-full flex items-center justify-center p-4">
<img
src={currentImage}
alt={`预览图片 ${currentIndex + 1}`}
className="max-w-full max-h-full object-contain transition-transform duration-200 cursor-grab active:cursor-grabbing shadow-2xl"
style={{
transform: `scale(${zoom}) rotate(${rotation}deg)`,
transformOrigin: 'center'
}}
draggable={false}
/>
</div>
{/* 右箭头 */}
{images.length > 1 && (
<button
onClick={goToNext}
className="absolute right-4 z-10 p-3 bg-black bg-opacity-60 text-white rounded-full hover:bg-opacity-80 transition-all shadow-lg"
title="下一张 (→)"
>
<ChevronRight className="w-6 h-6" />
</button>
)}
</div>
{/* 底部缩略图导航 */}
{images.length > 1 && (
<div className="relative z-10 bg-black bg-opacity-60 backdrop-blur-sm p-4">
<div className="flex justify-center space-x-2 overflow-x-auto max-w-full">
{images.map((image, index) => (
<button
key={index}
onClick={() => {
setCurrentIndex(index);
setZoom(1);
setRotation(0);
}}
className={`flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 transition-all ${
index === currentIndex
? 'border-blue-500 ring-2 ring-blue-500 ring-opacity-50 scale-110'
: 'border-gray-600 hover:border-gray-400 hover:scale-105'
}`}
>
<img
src={image}
alt={`缩略图 ${index + 1}`}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
</div>
)}
{/* 快捷键提示 */}
<div className="absolute bottom-4 left-4 text-gray-300 text-xs space-y-1 bg-black bg-opacity-60 p-3 rounded-lg backdrop-blur-sm">
<div className="font-medium text-gray-200 mb-1">:</div>
<div> </div>
<div>+ - </div>
<div>R </div>
<div>F </div>
<div>Esc </div>
</div>
</div>
</div>
);
};

View File

@ -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<ImageGenerationRecord[]>([]);
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 (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50">
@ -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 = () => {
</div>
</div>
</div>
{/* 图片预览模态框 */}
<ImageGalleryModal
images={previewModal.images}
initialIndex={previewModal.initialIndex}
isOpen={previewModal.isOpen}
onClose={closeImagePreview}
title={previewModal.title}
/>
</div>
);
};
@ -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<RecordCardProps> = ({ record, onDelete }) => {
const RecordCard: React.FC<RecordCardProps> = ({ record, onDelete, onImagePreview }) => {
const getStatusIcon = () => {
switch (record.status) {
case ImageGenerationRecordStatus.PENDING:
@ -651,19 +682,34 @@ const RecordCard: React.FC<RecordCardProps> = ({ record, onDelete }) => {
</div>
<div className="grid grid-cols-4 gap-2">
{record.result_urls.map((url, index) => (
<div key={index} className="relative group">
<div key={index} className="relative group cursor-pointer">
<img
src={url}
alt={`Generated ${index + 1}`}
className="w-full h-16 object-cover rounded border border-gray-200"
className="w-full h-16 object-cover rounded border border-gray-200 transition-transform group-hover:scale-105"
onClick={() => onImagePreview(
record.result_urls,
index,
`生成结果 - ${record.prompt.slice(0, 30)}${record.prompt.length > 30 ? '...' : ''}`
)}
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200 rounded flex items-center justify-center opacity-0 group-hover:opacity-100">
<button
onClick={() => window.open(url, '_blank')}
className="p-1 bg-white bg-opacity-90 text-gray-800 rounded hover:bg-opacity-100 transition-all"
>
<ExternalLink className="w-3 h-3" />
</button>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 rounded flex items-center justify-center opacity-0 group-hover:opacity-100">
<div className="flex space-x-1">
<button
onClick={(e) => {
e.stopPropagation();
onImagePreview(
record.result_urls,
index,
`生成结果 - ${record.prompt.slice(0, 30)}${record.prompt.length > 30 ? '...' : ''}`
);
}}
className="p-1 bg-white bg-opacity-90 text-gray-800 rounded hover:bg-opacity-100 transition-all"
title="预览图片"
>
<ExternalLink className="w-3 h-3" />
</button>
</div>
</div>
</div>
))}