feat: Add image gallery modal for enhanced image preview functionality
This commit is contained in:
parent
2f6a4dc1be
commit
1008eb6c72
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Reference in New Issue