You've already forked Nano-Banana-AI-Image-Editor
1067 lines
46 KiB
TypeScript
1067 lines
46 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import { useAppStore } from '../store/useAppStore';
|
||
import { Button } from './ui/Button';
|
||
import { History, Download, Image as ImageIcon } from 'lucide-react';
|
||
import { cn } from '../utils/cn';
|
||
import { ImagePreviewModal } from './ImagePreviewModal';
|
||
import * as indexedDBService from '../services/indexedDBService';
|
||
import { useIndexedDBListener } from '../hooks/useIndexedDBListener';
|
||
|
||
export const HistoryPanel: React.FC = () => {
|
||
const {
|
||
currentProject,
|
||
canvasImage,
|
||
selectedGenerationId,
|
||
selectedEditId,
|
||
selectGeneration,
|
||
selectEdit,
|
||
showHistory,
|
||
setShowHistory,
|
||
setCanvasImage,
|
||
selectedTool
|
||
} = useAppStore();
|
||
|
||
const { getBlob } = useAppStore.getState();
|
||
|
||
const [previewModal, setPreviewModal] = React.useState<{
|
||
open: boolean;
|
||
imageUrl: string;
|
||
title: string;
|
||
description?: string;
|
||
}>({
|
||
open: false,
|
||
imageUrl: '',
|
||
title: '',
|
||
description: ''
|
||
});
|
||
|
||
// 存储从Blob URL解码的图像数据
|
||
const [decodedImages, setDecodedImages] = useState<Record<string, string>>({});
|
||
|
||
// 使用自定义hook获取IndexedDB记录
|
||
const { generations: dbGenerations, edits: dbEdits, loading, error, refresh } = useIndexedDBListener();
|
||
|
||
// 筛选和搜索状态
|
||
const [startDate, setStartDate] = useState<string>('');
|
||
const [endDate, setEndDate] = useState<string>('');
|
||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||
|
||
// 悬浮预览状态
|
||
const [hoveredImage, setHoveredImage] = useState<{url: string, title: string, width?: number, height?: number, size?: number} | null>(null);
|
||
const [previewPosition, setPreviewPosition] = useState<{x: number, y: number}>({x: 0, y: 0});
|
||
|
||
const generations = currentProject?.generations || [];
|
||
const edits = currentProject?.edits || [];
|
||
|
||
// 获取上传后的图片链接
|
||
const getUploadedImageUrl = (generationOrEdit: any, index: number) => {
|
||
if (generationOrEdit.uploadResults && generationOrEdit.uploadResults[index]) {
|
||
const uploadResult = generationOrEdit.uploadResults[index];
|
||
if (uploadResult.success && uploadResult.url) {
|
||
// 添加参数以降低图片质量
|
||
return `${uploadResult.url}?x-oss-process=image/quality,q_50`;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// 获取当前图像尺寸
|
||
const [imageDimensions, setImageDimensions] = React.useState<{ width: number; height: number } | null>(null);
|
||
|
||
React.useEffect(() => {
|
||
if (canvasImage) {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
setImageDimensions({ width: img.width, height: img.height });
|
||
};
|
||
img.src = canvasImage;
|
||
} else {
|
||
setImageDimensions(null);
|
||
}
|
||
}, [canvasImage]);
|
||
|
||
// 错误处理显示
|
||
if (error) {
|
||
return (
|
||
<div className="w-80 bg-white border-l border-gray-200 p-6 flex flex-col h-full">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div className="flex items-center space-x-2">
|
||
<History className="h-5 w-5 text-gray-400" />
|
||
<h3 className="text-sm font-medium text-gray-300">历史记录和变体</h3>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => setShowHistory(!showHistory)}
|
||
className="h-6 w-6 rounded-full card"
|
||
title="隐藏历史面板"
|
||
>
|
||
×
|
||
</Button>
|
||
</div>
|
||
<div className="text-center py-8 text-red-500">
|
||
<p className="text-sm">加载历史记录时出错: {error}</p>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="mt-2 card"
|
||
onClick={refresh}
|
||
>
|
||
重新加载
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 筛选记录的函数
|
||
const filterRecords = (records: any[], isGeneration: boolean) => {
|
||
return records.filter(record => {
|
||
// 日期筛选
|
||
const recordDate = new Date(record.timestamp);
|
||
if (startDate && recordDate < new Date(startDate)) return false;
|
||
if (endDate && recordDate > new Date(endDate)) return false;
|
||
|
||
// 搜索词筛选
|
||
if (searchTerm) {
|
||
if (isGeneration) {
|
||
// 生成记录按提示词搜索
|
||
return record.prompt.toLowerCase().includes(searchTerm.toLowerCase());
|
||
} else {
|
||
// 编辑记录按指令搜索
|
||
return record.instruction.toLowerCase().includes(searchTerm.toLowerCase());
|
||
}
|
||
}
|
||
|
||
return true;
|
||
});
|
||
};
|
||
|
||
// 筛选后的记录
|
||
const filteredGenerations = filterRecords(dbGenerations, true);
|
||
const filteredEdits = filterRecords(dbEdits, false);
|
||
|
||
// 当项目变化时,解码Blob图像
|
||
useEffect(() => {
|
||
const decodeBlobImages = async () => {
|
||
const newDecodedImages: Record<string, string> = {};
|
||
|
||
// 解码生成记录的输出图像
|
||
for (const gen of generations) {
|
||
if (Array.isArray(gen.outputAssetsBlobUrls)) {
|
||
for (const blobUrl of gen.outputAssetsBlobUrls) {
|
||
if (!decodedImages[blobUrl] && blobUrl.startsWith('blob:')) {
|
||
const blob = getBlob(blobUrl);
|
||
if (blob) {
|
||
const dataUrl = await new Promise<string>((resolve) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result as string);
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
newDecodedImages[blobUrl] = dataUrl;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 解码编辑记录的输出图像
|
||
for (const edit of edits) {
|
||
if (Array.isArray(edit.outputAssetsBlobUrls)) {
|
||
for (const blobUrl of edit.outputAssetsBlobUrls) {
|
||
if (!decodedImages[blobUrl] && blobUrl.startsWith('blob:')) {
|
||
const blob = getBlob(blobUrl);
|
||
if (blob) {
|
||
const dataUrl = await new Promise<string>((resolve) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result as string);
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
newDecodedImages[blobUrl] = dataUrl;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (Object.keys(newDecodedImages).length > 0) {
|
||
setDecodedImages(prev => ({ ...prev, ...newDecodedImages }));
|
||
}
|
||
};
|
||
|
||
decodeBlobImages();
|
||
}, [generations, edits, getBlob, decodedImages]);
|
||
|
||
if (!showHistory) {
|
||
return (
|
||
<div className="w-8 bg-white flex flex-col items-center justify-center rounded-r-xl">
|
||
<button
|
||
onClick={() => setShowHistory(true)}
|
||
className="w-6 h-16 bg-gray-100 hover:bg-gray-200 rounded-l-lg flex items-center justify-center transition-colors group"
|
||
title="显示历史面板"
|
||
>
|
||
<div className="flex flex-col space-y-1">
|
||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full"></div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="w-72 bg-white p-4 flex flex-col h-full relative">
|
||
{/* 头部 */}
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center space-x-2">
|
||
<History className="h-4 w-4 text-gray-500" />
|
||
<h3 className="text-sm font-medium text-gray-700">历史记录</h3>
|
||
</div>
|
||
<div className="flex space-x-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={refresh}
|
||
className="h-6 w-6 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full card"
|
||
title="刷新历史记录"
|
||
>
|
||
⟳
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => setShowHistory(!showHistory)}
|
||
className="h-6 w-6 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full card"
|
||
title="隐藏历史面板"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||
</svg>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 筛选和搜索控件 */}
|
||
<div className="mb-3 space-y-2">
|
||
<div className="flex space-x-1">
|
||
<input
|
||
type="date"
|
||
value={startDate}
|
||
onChange={(e) => setStartDate(e.target.value)}
|
||
className="flex-1 text-xs p-1.5 border border-gray-200 rounded-lg text-gray-600 bg-gray-50 focus:outline-none focus:ring-1 focus:ring-yellow-400 card"
|
||
placeholder="开始日期"
|
||
/>
|
||
<input
|
||
type="date"
|
||
value={endDate}
|
||
onChange={(e) => setEndDate(e.target.value)}
|
||
className="flex-1 text-xs p-1.5 border border-gray-200 rounded-lg text-gray-600 bg-gray-50 focus:outline-none focus:ring-1 focus:ring-yellow-400 card"
|
||
placeholder="结束日期"
|
||
/>
|
||
</div>
|
||
<div className="flex">
|
||
<input
|
||
type="text"
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="flex-1 text-xs p-1.5 border border-gray-200 rounded-l-lg bg-gray-50 focus:outline-none focus:ring-1 focus:ring-yellow-400 card"
|
||
placeholder="搜索提示词..."
|
||
/>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="text-xs p-1.5 rounded-l-none h-7 border-gray-200 text-gray-600 hover:bg-gray-100 card"
|
||
onClick={() => {
|
||
setStartDate('');
|
||
setEndDate('');
|
||
setSearchTerm('');
|
||
}}
|
||
>
|
||
重置
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 变体网格 */}
|
||
<div className="mb-6 flex-shrink-0">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide">变体</h4>
|
||
<span className="text-xs text-gray-400">
|
||
{filteredGenerations.length + filteredEdits.length}/1000
|
||
</span>
|
||
</div>
|
||
{filteredGenerations.length === 0 && filteredEdits.length === 0 ? (
|
||
<div className="text-center py-8">
|
||
<div className="text-2xl mb-2 text-gray-300">🖼️</div>
|
||
<p className="text-xs text-gray-400">暂无历史记录</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-3 gap-1.5 max-h-72 relative">
|
||
{/* 显示生成记录 */}
|
||
{[...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp).slice(0, 50).map((generation, index) => (
|
||
<div
|
||
key={generation.id}
|
||
className={cn(
|
||
'relative aspect-square rounded border cursor-pointer transition-all duration-200 overflow-hidden',
|
||
selectedGenerationId === generation.id
|
||
? 'border-yellow-400 ring-2 ring-yellow-400/30'
|
||
: 'border-gray-200 hover:border-gray-300'
|
||
)}
|
||
onClick={() => {
|
||
selectGeneration(generation.id);
|
||
// 设置画布图像为第一个输出资产
|
||
if (generation.outputAssets && generation.outputAssets.length > 0) {
|
||
const asset = generation.outputAssets[0];
|
||
if (asset.url) {
|
||
setCanvasImage(asset.url);
|
||
}
|
||
}
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
// 优先使用上传后的远程链接,如果没有则使用原始链接
|
||
let imageUrl = getUploadedImageUrl(generation, 0);
|
||
if (!imageUrl && generation.outputAssets && generation.outputAssets.length > 0) {
|
||
imageUrl = generation.outputAssets[0].url;
|
||
}
|
||
if (imageUrl) {
|
||
// 创建图像对象以获取尺寸
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
// 计算文件大小(仅对base64数据)
|
||
let size = 0;
|
||
if (imageUrl.startsWith('data:')) {
|
||
// 估算base64数据大小
|
||
const base64Data = imageUrl.split(',')[1];
|
||
size = Math.round((base64Data.length * 3) / 4);
|
||
}
|
||
|
||
setHoveredImage({
|
||
url: imageUrl,
|
||
title: `生成记录 G${index + 1}`,
|
||
width: img.width,
|
||
height: img.height,
|
||
size: size
|
||
});
|
||
|
||
// 计算预览位置,确保不超出屏幕边界
|
||
const previewWidth = 300;
|
||
const previewHeight = 300;
|
||
const offsetX = 10;
|
||
const offsetY = 10;
|
||
|
||
|
||
// 获取HistoryPanel的位置
|
||
const historyPanel = document.querySelector('.w-72.bg-white.p-4');
|
||
const panelRect = historyPanel ? historyPanel.getBoundingClientRect() : { left: 0, top: 0 };
|
||
|
||
// 计算相对于HistoryPanel的位置
|
||
let x = e.clientX - panelRect.left + offsetX;
|
||
let y = e.clientY - panelRect.top + offsetY;
|
||
|
||
|
||
|
||
// 确保预览窗口不会超出右边界
|
||
if (x + previewWidth > window.innerWidth) {
|
||
x = window.innerWidth - previewWidth - 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出下边界
|
||
if (y + previewHeight > window.innerHeight) {
|
||
y = window.innerHeight - previewHeight - 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出左边界
|
||
if (x < 0) {
|
||
x = 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出上边界
|
||
if (y < 0) {
|
||
y = 10;
|
||
}
|
||
|
||
// 添加额外的安全边界检查
|
||
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
|
||
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
|
||
|
||
setPreviewPosition({x, y});
|
||
};
|
||
img.onerror = (error) => {
|
||
console.error('图像加载失败:', error);
|
||
// 即使图像加载失败,也显示预览
|
||
setHoveredImage({
|
||
url: imageUrl,
|
||
title: `生成记录 G${index + 1}`,
|
||
width: 0,
|
||
height: 0,
|
||
size: 0
|
||
});
|
||
|
||
// 计算预览位置
|
||
const previewWidth = 300;
|
||
const previewHeight = 300;
|
||
const offsetX = 10;
|
||
const offsetY = 10;
|
||
|
||
|
||
let x = e.clientX + offsetX;
|
||
let y = e.clientY + offsetY;
|
||
|
||
// 确保预览窗口不会超出右边界
|
||
if (x + previewWidth > window.innerWidth) {
|
||
x = window.innerWidth - previewWidth - 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出下边界
|
||
if (y + previewHeight > window.innerHeight) {
|
||
y = window.innerHeight - previewHeight - 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出左边界
|
||
if (x < 0) {
|
||
x = 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出上边界
|
||
if (y < 0) {
|
||
y = 10;
|
||
}
|
||
|
||
// 添加额外的安全边界检查
|
||
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
|
||
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
|
||
|
||
setPreviewPosition({x, y});
|
||
};
|
||
img.src = imageUrl;
|
||
}
|
||
}}
|
||
onMouseMove={(e) => {
|
||
// 调整预览位置以避免被遮挡
|
||
const previewWidth = 300;
|
||
const previewHeight = 300;
|
||
const offsetX = 10;
|
||
const offsetY = 10;
|
||
|
||
|
||
// 获取HistoryPanel的位置
|
||
const historyPanel = document.querySelector('.w-72.bg-white.p-4');
|
||
const panelRect = historyPanel ? historyPanel.getBoundingClientRect() : { left: 0, top: 0 };
|
||
|
||
// 计算相对于HistoryPanel的位置
|
||
let x = e.clientX - panelRect.left + offsetX;
|
||
let y = e.clientY - panelRect.top + offsetY;
|
||
|
||
// 确保预览窗口不会超出右边界
|
||
if (x + previewWidth > (historyPanel ? historyPanel.clientWidth : window.innerWidth)) {
|
||
x = (historyPanel ? historyPanel.clientWidth : window.innerWidth) - previewWidth - 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出下边界
|
||
if (y + previewHeight > (historyPanel ? historyPanel.clientHeight : window.innerHeight)) {
|
||
y = (historyPanel ? historyPanel.clientHeight : window.innerHeight) - previewHeight - 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出左边界
|
||
if (x < 0) {
|
||
x = 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出上边界
|
||
if (y < 0) {
|
||
y = 10;
|
||
}
|
||
|
||
// 添加额外的安全边界检查
|
||
const maxWidth = historyPanel ? historyPanel.clientWidth : window.innerWidth;
|
||
const maxHeight = historyPanel ? historyPanel.clientHeight : window.innerHeight;
|
||
x = Math.max(10, Math.min(x, maxWidth - previewWidth - 10));
|
||
y = Math.max(10, Math.min(y, maxHeight - previewHeight - 10));
|
||
|
||
setPreviewPosition({x, y});
|
||
}}
|
||
onMouseLeave={() => {
|
||
setHoveredImage(null);
|
||
}}
|
||
>
|
||
{(() => {
|
||
// 优先使用上传后的远程链接,如果没有则使用原始链接
|
||
const imageUrl = getUploadedImageUrl(generation, 0) ||
|
||
(generation.outputAssets && generation.outputAssets.length > 0 ? generation.outputAssets[0].url : null);
|
||
|
||
if (imageUrl) {
|
||
return <img src={imageUrl} alt="生成的变体" className="w-full h-full object-cover" />;
|
||
} else {
|
||
return (
|
||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||
<ImageIcon className="h-4 w-4 text-gray-400" />
|
||
</div>
|
||
);
|
||
}
|
||
})()}
|
||
|
||
{/* 变体编号 */}
|
||
<div className="absolute top-1 left-1 bg-gray-900/80 text-xs px-1 py-0.5 rounded text-white">
|
||
G{index + 1}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* 显示编辑记录 */}
|
||
{[...filteredEdits].sort((a, b) => b.timestamp - a.timestamp).slice(0, 50).map((edit, index) => (
|
||
<div
|
||
key={edit.id}
|
||
className={cn(
|
||
'relative aspect-square rounded border cursor-pointer transition-all duration-200 overflow-hidden',
|
||
selectedEditId === edit.id
|
||
? 'border-purple-400 ring-2 ring-purple-400/30'
|
||
: 'border-gray-200 hover:border-gray-300'
|
||
)}
|
||
onClick={() => {
|
||
selectEdit(edit.id);
|
||
selectGeneration(null);
|
||
// 设置画布图像为第一个输出资产
|
||
if (edit.outputAssets && edit.outputAssets.length > 0) {
|
||
const asset = edit.outputAssets[0];
|
||
if (asset.url) {
|
||
setCanvasImage(asset.url);
|
||
}
|
||
}
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
// 优先使用上传后的远程链接,如果没有则使用原始链接
|
||
let imageUrl = getUploadedImageUrl(edit, 0);
|
||
if (!imageUrl && edit.outputAssets && edit.outputAssets.length > 0) {
|
||
imageUrl = edit.outputAssets[0].url;
|
||
}
|
||
|
||
if (imageUrl) {
|
||
// 创建图像对象以获取尺寸
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
// 计算文件大小(仅对base64数据)
|
||
let size = 0;
|
||
if (imageUrl.startsWith('data:')) {
|
||
// 估算base64数据大小
|
||
const base64Data = imageUrl.split(',')[1];
|
||
size = Math.round((base64Data.length * 3) / 4);
|
||
}
|
||
|
||
setHoveredImage({
|
||
url: imageUrl,
|
||
title: `编辑记录 E${index + 1}`,
|
||
width: img.width,
|
||
height: img.height,
|
||
size: size
|
||
});
|
||
|
||
// 计算预览位置,确保不超出屏幕边界
|
||
const previewWidth = 300;
|
||
const previewHeight = 300;
|
||
const offsetX = 10;
|
||
const offsetY = 10;
|
||
|
||
|
||
let x = e.clientX + offsetX;
|
||
let y = e.clientY + offsetY;
|
||
|
||
// 确保预览窗口不会超出右边界
|
||
if (x + previewWidth > window.innerWidth) {
|
||
x = window.innerWidth - previewWidth - 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出下边界
|
||
if (y + previewHeight > window.innerHeight) {
|
||
y = window.innerHeight - previewHeight - 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出左边界
|
||
if (x < 0) {
|
||
x = 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出上边界
|
||
if (y < 0) {
|
||
y = 10;
|
||
}
|
||
|
||
// 添加额外的安全边界检查
|
||
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
|
||
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
|
||
|
||
setPreviewPosition({x, y});
|
||
};
|
||
img.onerror = (error) => {
|
||
console.error('图像加载失败:', error);
|
||
// 即使图像加载失败,也显示预览
|
||
setHoveredImage({
|
||
url: imageUrl,
|
||
title: `编辑记录 E${index + 1}`,
|
||
width: 0,
|
||
height: 0,
|
||
size: 0
|
||
});
|
||
|
||
// 计算预览位置
|
||
const previewWidth = 300;
|
||
const previewHeight = 300;
|
||
const offsetX = 10;
|
||
const offsetY = 10;
|
||
|
||
// 获取HistoryPanel的位置信息
|
||
const historyPanel = e.currentTarget.closest('.w-72');
|
||
const panelRect = historyPanel ? historyPanel.getBoundingClientRect() : { left: 0, top: 0 };
|
||
|
||
// 计算相对于整个视窗的位置
|
||
let x = e.clientX + offsetX;
|
||
let y = e.clientY + offsetY;
|
||
|
||
// 确保预览窗口不会超出右边界
|
||
if (x + previewWidth > window.innerWidth) {
|
||
x = window.innerWidth - previewWidth - 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出下边界
|
||
if (y + previewHeight > window.innerHeight) {
|
||
y = window.innerHeight - previewHeight - 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出左边界
|
||
if (x < 0) {
|
||
x = 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出上边界
|
||
if (y < 0) {
|
||
y = 10;
|
||
}
|
||
|
||
// 添加额外的安全边界检查
|
||
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
|
||
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
|
||
|
||
setPreviewPosition({x, y});
|
||
};
|
||
img.src = imageUrl;
|
||
}
|
||
}}
|
||
onMouseMove={(e) => {
|
||
// 调整预览位置以避免被遮挡
|
||
const previewWidth = 300;
|
||
const previewHeight = 300;
|
||
const offsetX = 10;
|
||
const offsetY = 10;
|
||
|
||
let x = e.clientX + offsetX;
|
||
let y = e.clientY + offsetY;
|
||
|
||
// 确保预览窗口不会超出右边界
|
||
if (x + previewWidth > window.innerWidth) {
|
||
x = window.innerWidth - previewWidth - 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出下边界
|
||
if (y + previewHeight > window.innerHeight) {
|
||
y = window.innerHeight - previewHeight - 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出左边界
|
||
if (x < 0) {
|
||
x = 10;
|
||
}
|
||
|
||
// 确保预览窗口不会超出上边界
|
||
if (y < 0) {
|
||
y = 10;
|
||
}
|
||
|
||
// 添加额外的安全边界检查
|
||
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
|
||
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
|
||
|
||
setPreviewPosition({x, y});
|
||
}}
|
||
onMouseLeave={() => {
|
||
setHoveredImage(null);
|
||
}}
|
||
>
|
||
{(() => {
|
||
// 优先使用上传后的远程链接,如果没有则使用原始链接
|
||
const imageUrl = getUploadedImageUrl(edit, 0) ||
|
||
(edit.outputAssets && edit.outputAssets.length > 0 ? edit.outputAssets[0].url : null);
|
||
|
||
if (imageUrl) {
|
||
return <img src={imageUrl} alt="编辑的变体" className="w-full h-full object-cover" />;
|
||
} else {
|
||
return (
|
||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||
<ImageIcon className="h-4 w-4 text-gray-400" />
|
||
</div>
|
||
);
|
||
}
|
||
})()}
|
||
|
||
{/* 编辑标签 */}
|
||
<div className="absolute top-1 left-1 bg-purple-900/80 text-xs px-1 py-0.5 rounded text-white">
|
||
E{index + 1}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
|
||
|
||
{/* 生成详情 */}
|
||
<div className="flex-1 overflow-y-auto min-h-0">
|
||
<h4 className="text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide">详情</h4>
|
||
{(() => {
|
||
const gen = filteredGenerations.find(g => g.id === selectedGenerationId) || dbGenerations.find(g => g.id === selectedGenerationId);
|
||
const selectedEdit = filteredEdits.find(e => e.id === selectedEditId) || dbEdits.find(e => e.id === selectedEditId);
|
||
|
||
if (gen) {
|
||
return (
|
||
<div className="space-y-3 p-3 bg-gray-50 rounded-lg">
|
||
<div className="space-y-2.5 text-xs text-gray-700">
|
||
<div>
|
||
<span className="text-gray-500">提示:</span>
|
||
<p className="text-gray-800 mt-1 leading-relaxed">{gen.prompt}</p>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">模型:</span>
|
||
<span className="text-gray-700">{gen.modelVersion}</span>
|
||
</div>
|
||
{gen.parameters.seed && (
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">种子:</span>
|
||
<span className="text-gray-700 font-mono">{gen.parameters.seed}</span>
|
||
</div>
|
||
)}
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">时间:</span>
|
||
<span className="text-gray-700">{new Date(gen.timestamp).toLocaleString()}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 上传结果 */}
|
||
{gen.uploadResults && gen.uploadResults.length > 0 && (
|
||
<div className="pt-2 border-t border-gray-200">
|
||
<h5 className="text-xs font-medium text-gray-500 mb-2">上传结果</h5>
|
||
<div className="space-y-1.5">
|
||
{gen.uploadResults.map((result, index) => (
|
||
<div key={index} className="text-xs">
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">图像 {index + 1}:</span>
|
||
<span className={result.success ? 'text-green-600' : 'text-red-600'}>
|
||
{result.success ? '成功' : '失败'}
|
||
</span>
|
||
</div>
|
||
{result.success && result.url && (
|
||
<div className="text-blue-600 truncate text-xs mt-0.5">
|
||
{result.url.split('/').pop()}
|
||
</div>
|
||
)}
|
||
{result.error && (
|
||
<div className="text-red-600 truncate text-xs mt-0.5">
|
||
{result.error}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 参考图像信息 */}
|
||
{gen.sourceAssets && gen.sourceAssets.length > 0 && (
|
||
<div className="pt-2 border-t border-gray-200">
|
||
<h5 className="text-xs font-medium text-gray-500 mb-2">参考图像</h5>
|
||
<div className="text-xs text-gray-600 mb-2">
|
||
{gen.sourceAssets.length} 个参考图像
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{gen.sourceAssets.slice(0, 4).map((asset: any, index: number) => {
|
||
// 获取上传后的远程链接(如果存在)
|
||
// 参考图像在uploadResults中从索引1开始(索引0是生成的图像)
|
||
const uploadedUrl = gen.uploadResults && gen.uploadResults[index + 1] && gen.uploadResults[index + 1].success
|
||
? `${gen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_50`
|
||
: null;
|
||
const displayUrl = uploadedUrl || asset.url;
|
||
|
||
return (
|
||
<div
|
||
key={asset.id}
|
||
className="relative w-16 h-16 rounded border overflow-hidden cursor-pointer hover:ring-2 hover:ring-yellow-400"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setPreviewModal({
|
||
open: true,
|
||
imageUrl: displayUrl,
|
||
title: `参考图像 ${index + 1}`,
|
||
description: `${asset.width} × ${asset.height}`
|
||
});
|
||
}}
|
||
>
|
||
<img
|
||
src={displayUrl}
|
||
alt={`参考图像 ${index + 1}`}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
{gen.sourceAssets.length > 4 && (
|
||
<div className="w-16 h-16 rounded border flex items-center justify-center bg-gray-100 text-xs text-gray-500">
|
||
+{gen.sourceAssets.length - 4}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
} else if (selectedEdit) {
|
||
const parentGen = filteredGenerations.find(g => g.id === selectedEdit.parentGenerationId) || dbGenerations.find(g => g.id === selectedEdit.parentGenerationId);
|
||
return (
|
||
<div className="space-y-3 p-3 bg-gray-50 rounded-lg">
|
||
<div className="space-y-2.5 text-xs text-gray-700">
|
||
<div>
|
||
<span className="text-gray-500">编辑指令:</span>
|
||
<p className="text-gray-800 mt-1 leading-relaxed">{selectedEdit.instruction}</p>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">类型:</span>
|
||
<span className="text-gray-700">图像编辑</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">创建时间:</span>
|
||
<span className="text-gray-700">{new Date(selectedEdit.timestamp).toLocaleString()}</span>
|
||
</div>
|
||
{selectedEdit.maskAssetId && (
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">遮罩:</span>
|
||
<span className="text-purple-600">已应用</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 上传结果 */}
|
||
{selectedEdit.uploadResults && selectedEdit.uploadResults.length > 0 && (
|
||
<div className="pt-2 border-t border-gray-200">
|
||
<h5 className="text-xs font-medium text-gray-500 mb-2">上传结果</h5>
|
||
<div className="space-y-1.5">
|
||
{selectedEdit.uploadResults.map((result, index) => (
|
||
<div key={index} className="text-xs">
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-500">图像 {index + 1}:</span>
|
||
<span className={result.success ? 'text-green-600' : 'text-red-600'}>
|
||
{result.success ? '成功' : '失败'}
|
||
</span>
|
||
</div>
|
||
{result.success && result.url && (
|
||
<div className="text-blue-600 truncate text-xs mt-0.5">
|
||
{result.url.split('/').pop()}
|
||
</div>
|
||
)}
|
||
{result.error && (
|
||
<div className="text-red-600 truncate text-xs mt-0.5">
|
||
{result.error}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 原始生成参考 */}
|
||
{parentGen && (
|
||
<div className="pt-2 border-t border-gray-200">
|
||
<h5 className="text-xs font-medium text-gray-500 mb-2">原始生成</h5>
|
||
<div className="text-xs text-gray-600">
|
||
基于: G{dbGenerations.findIndex(g => g.id === parentGen.id) + 1}
|
||
</div>
|
||
{/* 显示原始生成的参考图像 */}
|
||
{parentGen.sourceAssets && parentGen.sourceAssets.length > 0 && (
|
||
<div className="mt-2">
|
||
<div className="text-xs text-gray-600 mb-2">
|
||
原始参考图像:
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{parentGen.sourceAssets.slice(0, 4).map((asset: any, index: number) => {
|
||
// 获取上传后的远程链接(如果存在)
|
||
// 参考图像在uploadResults中从索引1开始(索引0是生成的图像)
|
||
const uploadedUrl = parentGen.uploadResults && parentGen.uploadResults[index + 1] && parentGen.uploadResults[index + 1].success
|
||
? `${parentGen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_50`
|
||
: null;
|
||
const displayUrl = uploadedUrl || asset.url;
|
||
|
||
return (
|
||
<div
|
||
key={asset.id}
|
||
className="relative w-16 h-16 rounded border overflow-hidden cursor-pointer hover:ring-2 hover:ring-yellow-400"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setPreviewModal({
|
||
open: true,
|
||
imageUrl: displayUrl,
|
||
title: `原始参考图像 ${index + 1}`,
|
||
description: `${asset.width} × ${asset.height}`
|
||
});
|
||
}}
|
||
>
|
||
<img
|
||
src={displayUrl}
|
||
alt={`原始参考图像 ${index + 1}`}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
{parentGen.sourceAssets.length > 4 && (
|
||
<div className="w-16 h-16 rounded border flex items-center justify-center bg-gray-100 text-xs text-gray-500">
|
||
+{parentGen.sourceAssets.length - 4}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
} else {
|
||
return (
|
||
<div className="space-y-2 text-xs text-gray-500 p-3 text-center">
|
||
<p className="text-gray-400">选择一个记录以查看详细信息</p>
|
||
</div>
|
||
);
|
||
}
|
||
})()}
|
||
</div>
|
||
|
||
{/* 测试按钮 - 用于调试 */}
|
||
<div className="mb-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-full h-8 text-xs card"
|
||
onClick={() => {
|
||
// 测试悬浮预览功能
|
||
setHoveredImage({
|
||
url: 'https://images.unsplash.com/photo-1501854140801-50d01698950b?w=200',
|
||
title: '测试图像',
|
||
width: 200,
|
||
height: 200,
|
||
size: 102400
|
||
});
|
||
setPreviewPosition({x: 100, y: 100});
|
||
}}
|
||
>
|
||
测试悬浮预览
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 操作 */}
|
||
<div className="space-y-2 flex-shrink-0 pt-2 border-t border-gray-100">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="w-full h-9 text-sm card"
|
||
onClick={() => {
|
||
// 查找当前显示的图像(生成记录或编辑记录)
|
||
let imageUrl: string | null = null;
|
||
|
||
if (selectedGenerationId) {
|
||
const { canvasImage } = useAppStore.getState();
|
||
imageUrl = canvasImage;
|
||
} else {
|
||
// 如果没有选择生成记录,尝试获取当前画布图像
|
||
const { canvasImage } = useAppStore.getState();
|
||
imageUrl = canvasImage;
|
||
}
|
||
|
||
if (imageUrl) {
|
||
// 处理数据URL和常规URL
|
||
if (imageUrl.startsWith('data:')) {
|
||
const link = document.createElement('a');
|
||
link.href = imageUrl;
|
||
link.download = `nano-banana-${Date.now()}.png`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
} else {
|
||
// 对于外部URL,我们需要获取并转换为blob
|
||
fetch(imageUrl)
|
||
.then(response => response.blob())
|
||
.then(blob => {
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `nano-banana-${Date.now()}.png`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
});
|
||
}
|
||
}
|
||
}}
|
||
disabled={!selectedGenerationId && !useAppStore.getState().canvasImage}
|
||
>
|
||
<Download className="h-4 w-4 mr-2" />
|
||
下载图像
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 图像预览模态框 */}
|
||
<ImagePreviewModal
|
||
open={previewModal.open}
|
||
onOpenChange={(open) => setPreviewModal(prev => ({ ...prev, open }))}
|
||
imageUrl={previewModal.imageUrl}
|
||
title={previewModal.title}
|
||
description={previewModal.description}
|
||
/>
|
||
|
||
{/* 悬浮预览 */}
|
||
{hoveredImage && (
|
||
<div
|
||
className="absolute z-[9999] shadow-2xl border border-gray-300 rounded-lg overflow-hidden bg-white backdrop-blur-sm pointer-events-none"
|
||
style={{
|
||
left: `${previewPosition.x}px`,
|
||
top: `${previewPosition.y}px`,
|
||
maxWidth: '300px',
|
||
maxHeight: '300px'
|
||
}}
|
||
>
|
||
<div className="bg-gray-900 text-white text-xs p-2 truncate font-medium">
|
||
{hoveredImage.title}
|
||
</div>
|
||
<img
|
||
src={hoveredImage.url}
|
||
alt="预览"
|
||
className="w-full h-auto max-h-[200px] object-contain"
|
||
/>
|
||
{/* 图像信息 */}
|
||
<div className="p-2 bg-gray-50 border-t border-gray-200 text-xs">
|
||
{hoveredImage.width && hoveredImage.height && (
|
||
<div className="flex justify-between text-gray-600">
|
||
<span>尺寸:</span>
|
||
<span className="text-gray-800">{hoveredImage.width} × {hoveredImage.height}</span>
|
||
</div>
|
||
)}
|
||
{hoveredImage.size && (
|
||
<div className="flex justify-between text-gray-600 mt-1">
|
||
<span>大小:</span>
|
||
<span className="text-gray-800">{Math.round(hoveredImage.size / 1024)} KB</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}; |