feat: Add image gallery modal for enhanced image preview functionality
This commit is contained in:
@@ -5,7 +5,7 @@ use serde_json;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tauri::{command, State, Manager, AppHandle};
|
use tauri::{command, State, Manager, AppHandle, Emitter};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use crate::business::services::image_generation_service::ImageGenerationService as DbImageGenerationService;
|
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" {
|
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 _ = service.complete_generation_by_task_id(&task_id_clone, status.result_urls);
|
||||||
let _ = app_handle.emit("image-generation-completed", serde_json::json!({
|
let _ = app_handle.emit("image-generation-completed", serde_json::json!({
|
||||||
"record_id": record_id_clone,
|
"record_id": record_id_clone,
|
||||||
"task_id": task_id_clone,
|
"task_id": task_id_clone,
|
||||||
"result_urls": status.result_urls
|
"result_urls": result_urls
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
} else if status.status == "failed" {
|
} else if status.status == "failed" {
|
||||||
|
|||||||
334
apps/desktop/src/components/ImageGalleryModal.tsx
Normal file
334
apps/desktop/src/components/ImageGalleryModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -20,6 +20,7 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { useNotifications } from '../../components/NotificationSystem';
|
import { useNotifications } from '../../components/NotificationSystem';
|
||||||
|
import { ImageGalleryModal } from '../../components/ImageGalleryModal';
|
||||||
import {
|
import {
|
||||||
PromptCheckResponse,
|
PromptCheckResponse,
|
||||||
ImageGenerationRequest,
|
ImageGenerationRequest,
|
||||||
@@ -47,6 +48,19 @@ const ImageGenerationTool: React.FC = () => {
|
|||||||
const [records, setRecords] = useState<ImageGenerationRecord[]>([]);
|
const [records, setRecords] = useState<ImageGenerationRecord[]>([]);
|
||||||
const [isLoadingRecords, setIsLoadingRecords] = useState(false);
|
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();
|
const { addNotification } = useNotifications();
|
||||||
|
|
||||||
@@ -332,16 +346,7 @@ const ImageGenerationTool: React.FC = () => {
|
|||||||
recordId
|
recordId
|
||||||
});
|
});
|
||||||
|
|
||||||
// 停止该记录的轮询
|
// 删除记录时,后台监控会自动停止
|
||||||
const interval = pollingIntervals.get(recordId);
|
|
||||||
if (interval) {
|
|
||||||
clearInterval(interval);
|
|
||||||
setPollingIntervals(prev => {
|
|
||||||
const newMap = new Map(prev);
|
|
||||||
newMap.delete(recordId);
|
|
||||||
return newMap;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重新加载记录列表
|
// 重新加载记录列表
|
||||||
loadRecords();
|
loadRecords();
|
||||||
@@ -359,7 +364,22 @@ const ImageGenerationTool: React.FC = () => {
|
|||||||
message: `无法删除记录: ${error}`
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50">
|
<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}
|
key={record.id}
|
||||||
record={record}
|
record={record}
|
||||||
onDelete={() => deleteRecord(record.id)}
|
onDelete={() => deleteRecord(record.id)}
|
||||||
|
onImagePreview={openImagePreview}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -522,6 +543,15 @@ const ImageGenerationTool: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 图片预览模态框 */}
|
||||||
|
<ImageGalleryModal
|
||||||
|
images={previewModal.images}
|
||||||
|
initialIndex={previewModal.initialIndex}
|
||||||
|
isOpen={previewModal.isOpen}
|
||||||
|
onClose={closeImagePreview}
|
||||||
|
title={previewModal.title}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -530,9 +560,10 @@ const ImageGenerationTool: React.FC = () => {
|
|||||||
interface RecordCardProps {
|
interface RecordCardProps {
|
||||||
record: ImageGenerationRecord;
|
record: ImageGenerationRecord;
|
||||||
onDelete: () => void;
|
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 = () => {
|
const getStatusIcon = () => {
|
||||||
switch (record.status) {
|
switch (record.status) {
|
||||||
case ImageGenerationRecordStatus.PENDING:
|
case ImageGenerationRecordStatus.PENDING:
|
||||||
@@ -651,21 +682,36 @@ const RecordCard: React.FC<RecordCardProps> = ({ record, onDelete }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{record.result_urls.map((url, index) => (
|
{record.result_urls.map((url, index) => (
|
||||||
<div key={index} className="relative group">
|
<div key={index} className="relative group cursor-pointer">
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={`Generated ${index + 1}`}
|
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">
|
<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
|
<button
|
||||||
onClick={() => window.open(url, '_blank')}
|
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"
|
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" />
|
<ExternalLink className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user