Files
Nano-Banana-AI-Image-Editor/src/components/HistoryPanel.tsx
2025-09-19 18:40:43 +08:00

1117 lines
50 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import { useAppStore } from '../store/useAppStore';
import { Button } from './ui/Button';
import { History, Download, Trash2, 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';
import { DayPicker } from 'react-day-picker';
import zhCN from 'react-day-picker/dist/locale/zh-CN';
export const HistoryPanel: React.FC<{
setHoveredImage: (image: {url: string, title: string, width?: number, height?: number} | null) => void,
setPreviewPosition?: (position: {x: number, y: number} | null) => void
}> = ({ setHoveredImage, setPreviewPosition }) => {
const {
currentProject,
canvasImage,
selectedGenerationId,
selectedEditId,
selectGeneration,
selectEdit,
showHistory,
setShowHistory,
setCanvasImage,
selectedTool,
removeGeneration,
removeEdit
} = 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 [hoveredRecord, setHoveredRecord] = useState<{type: 'generation' | 'edit', id: string} | null>(null);
// 筛选和搜索状态
const [startDate, setStartDate] = useState<string>(() => {
const today = new Date();
return today.toISOString().split('T')[0]; // 默认为今天
});
const [endDate, setEndDate] = useState<string>(() => {
const today = new Date();
return today.toISOString().split('T')[0]; // 默认为今天
});
const [searchTerm, setSearchTerm] = useState<string>('');
const [showDatePicker, setShowDatePicker] = useState(false); // 控制日期选择器的显示
const [dateRange, setDateRange] = useState<{ from: Date | undefined; to: Date | undefined }>({
from: new Date(new Date().setHours(0, 0, 0, 0)),
to: new Date(new Date().setHours(0, 0, 0, 0))
});
// 分页状态
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20; // 减少每页显示的项目数
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_30`; // 降低质量到30%
}
}
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);
const recordDateStr = recordDate.toISOString().split('T')[0]; // 获取日期部分 YYYY-MM-DD
if (startDate && recordDateStr < startDate) return false;
if (endDate && recordDateStr > 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]);
// 监听鼠标离开窗口事件,确保悬浮预览正确关闭
useEffect(() => {
const handleMouseLeave = (e: MouseEvent) => {
// 当鼠标离开浏览器窗口时,关闭悬浮预览
if (e.relatedTarget === null) {
setHoveredImage(null);
if (setPreviewPosition) {
setPreviewPosition(null);
}
}
};
const handleBlur = () => {
// 当窗口失去焦点时,关闭悬浮预览
setHoveredImage(null);
if (setPreviewPosition) {
setPreviewPosition(null);
}
};
window.addEventListener('mouseleave', handleMouseLeave);
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('mouseleave', handleMouseLeave);
window.removeEventListener('blur', handleBlur);
};
}, [setHoveredImage, setPreviewPosition]);
if (!showHistory) {
return (
<div className="w-8 bg-white flex flex-col items-center justify-center rounded-r-xl overflow-hidden">
<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-all duration-300 ease-in-out 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 transition-colors duration-200"></div>
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></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">
<div className="flex-1 relative">
<Button
variant="outline"
size="sm"
className={`w-full text-xs p-1.5 border-gray-200 text-gray-600 hover:bg-gray-100 card justify-start hover:shadow-sm transition-all ${
showDatePicker ? 'ring-2 ring-yellow-400 border-yellow-400' : ''
}`}
onClick={() => setShowDatePicker(!showDatePicker)}
>
<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" className="mr-2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
{dateRange.from && dateRange.to ? (
dateRange.from.getTime() === dateRange.to.getTime() ? (
`${dateRange.from.toLocaleDateString()}`
) : (
`${dateRange.from.toLocaleDateString()} - ${dateRange.to.toLocaleDateString()}`
)
) : (
"选择日期范围"
)}
</Button>
{showDatePicker && (
<div className="absolute top-full left-0 z-50 bg-white border border-gray-200 rounded-lg shadow-lg p-4 mt-1 card">
<style>{`
.rdp {
--rdp-cell-size: 36px; /* 增加单元格大小 */
--rdp-accent-color: #FDE047; /* 使用项目中的香蕉黄 */
--rdp-background-color: #FDE047;
margin: 0;
font-family: 'Inter', sans-serif;
font-size: 0.875rem;
}
.rdp-caption {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
}
.rdp-caption_label {
font-weight: 600;
font-size: 0.875rem;
color: #212529; /* 使用项目中的文本主色 */
}
.rdp-nav {
display: flex;
gap: 0.25rem;
}
.rdp-nav_button {
width: 24px;
height: 24px;
border-radius: 0.5rem;
border: 1px solid #E9ECEF; /* 使用项目中的边框色 */
background-color: #FFFFFF; /* 使用项目中的背景色 */
color: #6C757D; /* 使用项目中的次级文本色 */
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.rdp-nav_button:hover {
background-color: #F8F9FA; /* 使用项目中的面板背景色 */
color: #212529; /* 使用项目中的文本主色 */
border-color: #DEE2E6; /* 使用项目中的悬停边框色 */
}
.rdp-head_cell {
font-size: 0.75rem;
font-weight: 500;
color: #6C757D; /* 使用项目中的次级文本色 */
text-transform: uppercase;
padding: 0.25rem 0; /* 增加表头单元格的垂直间距 */
}
.rdp-head_row {
border-bottom: 1px solid #E9ECEF; /* 添加表头下边框 */
margin-bottom: 0.5rem; /* 增加表头下边距 */
}
.rdp-day {
border-radius: 0.5rem;
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
margin: 2px; /* 增加每天之间的间距 */
}
.rdp .rdp-day_selected {
background-color: #FDE047; /* 使用项目中的香蕉黄 */
color: #212529; /* 使用项目中的文本主色 */
font-weight: 600;
margin: 2px; /* 保持选中日期的间距 */
box-shadow: 0 0 0 1px #FDE047, 0 0 0 3px rgba(253, 224, 71, 0.3); /* 添加外发光效果 */
}
.rdp .rdp-day_range_middle {
background-color: #FEF9C3; /* 使用项目中的香蕉黄浅色 */
color: #713F12; /* 使用项目中的香蕉黄深色 */
border-radius: 0;
margin: 2px; /* 保持范围中间日期的间距 */
box-shadow: inset 0 0 0 1px rgba(253, 224, 71, 0.5); /* 添加内阴影增强视觉效果 */
}
.rdp .rdp-day_range_start, .rdp .rdp-day_range_end {
background-color: #FDE047; /* 使用项目中的香蕉黄 */
color: #212529; /* 使用项目中的文本主色 */
font-weight: 600;
border-radius: 0.5rem;
margin: 2px; /* 保持范围端点日期的间距 */
box-shadow: 0 0 0 1px #FDE047, 0 0 0 3px rgba(253, 224, 71, 0.3); /* 添加外发光效果 */
}
.rdp .rdp-day:hover:not(.rdp-day_selected):not(.rdp-day_range_middle):not(.rdp-day_range_start):not(.rdp-day_range_end) {
background-color: #F8F9FA; /* 使用项目中的面板背景色 */
color: #212529; /* 使用项目中的文本主色 */
}
.rdp .rdp-day_selected:hover {
background-color: #FDE047; /* 保持选中状态的背景色 */
}
.rdp .rdp-day_range_middle:hover {
background-color: #FEF9C3; /* 保持范围中间的背景色 */
}
.rdp .rdp-day_range_start:hover, .rdp .rdp-day_range_end:hover {
background-color: #FDE047; /* 保持范围端点的背景色 */
}
.rdp .rdp-day_today:not(.rdp-day_selected):not(.rdp-day_range_middle):not(.rdp-day_range_start):not(.rdp-day_range_end) {
background-color: #FFF9DB; /* 今天的背景色 */
color: #713F12; /* 今天的文本色 */
font-weight: 500;
position: relative;
}
.rdp-day_today:not(.rdp-day_selected):not(.rdp-day_range_middle):not(.rdp-day_range_start):not(.rdp-day_range_end)::after {
content: "";
position: absolute;
bottom: 2px;
right: 2px;
width: 4px;
height: 4px;
background-color: #713F12;
border-radius: 50%;
}
/* 添加月份间的分隔线 */
.rdp-months {
display: flex;
gap: 1rem;
}
.rdp-month {
padding: 0.5rem;
}
`}</style>
<DayPicker
mode="range"
selected={dateRange}
onSelect={(range) => {
if (range) {
setDateRange(range);
// 更新字符串格式的日期用于筛选
if (range.from) {
setStartDate(range.from.toISOString().split('T')[0]);
}
if (range.to) {
setEndDate(range.to.toISOString().split('T')[0]);
}
}
}}
numberOfMonths={2}
className="border-0"
locale={zhCN}
/>
<div className="flex justify-end space-x-2 mt-2">
<Button
variant="outline"
size="sm"
className="text-xs p-1.5 h-7 border-gray-200 text-gray-600 hover:bg-gray-100 card"
onClick={() => setShowDatePicker(false)}
>
</Button>
</div>
</div>
)}
</div>
</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={() => {
const today = new Date(new Date().setHours(0, 0, 0, 0));
const todayStr = today.toISOString().split('T')[0];
setStartDate(todayStr);
setEndDate(todayStr);
setSearchTerm('');
// 重置日期范围
setDateRange({
from: today,
to: today
});
}}
>
</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}/100
</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 overflow-y-scroll">
{/* 显示生成记录 */}
{(() => {
const sortedGenerations = [...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedGenerations = sortedGenerations.slice(startIndex, endIndex);
return paginatedGenerations.map((generation, index) => {
// 计算全局索引用于显示编号
const globalIndex = startIndex + index;
return (
<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) => {
// 设置当前悬停的记录
setHoveredRecord({type: 'generation', id: generation.id});
// 优先使用上传后的远程链接,如果没有则使用原始链接
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 = () => {
setHoveredImage({
url: imageUrl,
title: `生成记录 G${globalIndex + 1}`,
width: img.width,
height: img.height
});
// 传递鼠标位置信息给App组件
if (setPreviewPosition) {
setPreviewPosition({ x: e.clientX, y: e.clientY });
}
};
img.onerror = (error) => {
console.error('图像加载失败:', error);
// 即使图像加载失败,也显示预览
setHoveredImage({
url: imageUrl,
title: `生成记录 G${globalIndex + 1}`,
width: 0,
height: 0
});
// 传递鼠标位置信息给App组件
if (setPreviewPosition) {
setPreviewPosition({ x: e.clientX, y: e.clientY });
}
};
img.src = imageUrl;
}
}}
onMouseMove={() => {
// 不需要处理鼠标移动事件,因为预览现在在页面中心显示
}}
onMouseLeave={() => {
// 清除当前悬停的记录
setHoveredRecord(null);
setHoveredImage(null);
if (setPreviewPosition) {
setPreviewPosition(null);
}
}}
>
{(() => {
// 优先使用上传后的远程链接
const imageUrl = getUploadedImageUrl(generation, 0) ||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? 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{globalIndex + 1}
</div>
{/* 悬停时显示的按钮 */}
{hoveredRecord && hoveredRecord.type === 'generation' && hoveredRecord.id === generation.id && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center gap-2 opacity-0 hover:opacity-100 transition-opacity duration-200">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-white/90 hover:bg-white text-gray-700 rounded-full shadow-md"
onClick={(e) => {
e.stopPropagation();
// 下载图像
const imageUrl = getUploadedImageUrl(generation, 0) ||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
if (imageUrl) {
const link = document.createElement('a');
link.href = imageUrl;
link.download = `generation-G${globalIndex + 1}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}}
title="下载图像"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-red-500/90 hover:bg-red-500 text-white rounded-full shadow-md"
onClick={(e) => {
e.stopPropagation();
// 删除记录
removeGeneration(generation.id);
}}
title="删除记录"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
});
})()}
{/* 显示编辑记录 */}
{(() => {
const sortedEdits = [...filteredEdits].sort((a, b) => b.timestamp - a.timestamp);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedEdits = sortedEdits.slice(startIndex, endIndex);
// 计算生成记录的数量,用于编辑记录的编号
const generationCount = [...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp).length;
return paginatedEdits.map((edit, index) => {
// 计算全局索引用于显示编号
const globalIndex = startIndex + index;
return (
<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) => {
// 设置当前悬停的记录
setHoveredRecord({type: 'edit', id: edit.id});
// 优先使用上传后的远程链接,如果没有则使用原始链接
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 = () => {
setHoveredImage({
url: imageUrl,
title: `编辑记录 E${globalIndex + 1}`,
width: img.width,
height: img.height
});
// 传递鼠标位置信息给App组件
if (setPreviewPosition) {
setPreviewPosition({ x: e.clientX, y: e.clientY });
}
};
img.onerror = (error) => {
console.error('图像加载失败:', error);
// 即使图像加载失败,也显示预览
setHoveredImage({
url: imageUrl,
title: `编辑记录 E${globalIndex + 1}`,
width: 0,
height: 0
});
// 传递鼠标位置信息给App组件
if (setPreviewPosition) {
setPreviewPosition({ x: e.clientX, y: e.clientY });
}
};
img.src = imageUrl;
}
}}
onMouseMove={() => {
// 不需要处理鼠标移动事件,因为预览现在在页面中心显示
}}
onMouseLeave={() => {
// 清除当前悬停的记录
setHoveredRecord(null);
setHoveredImage(null);
if (setPreviewPosition) {
setPreviewPosition(null);
}
}}
>
{(() => {
// 优先使用上传后的远程链接
const imageUrl = getUploadedImageUrl(edit, 0) ||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? 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{globalIndex + 1}
</div>
{/* 悬停时显示的按钮 */}
{hoveredRecord && hoveredRecord.type === 'edit' && hoveredRecord.id === edit.id && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center gap-2 opacity-0 hover:opacity-100 transition-opacity duration-200">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-white/90 hover:bg-white text-gray-700 rounded-full shadow-md"
onClick={(e) => {
e.stopPropagation();
// 下载图像
const imageUrl = getUploadedImageUrl(edit, 0) ||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
if (imageUrl) {
const link = document.createElement('a');
link.href = imageUrl;
link.download = `edit-E${globalIndex + 1}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}}
title="下载图像"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-red-500/90 hover:bg-red-500 text-white rounded-full shadow-md"
onClick={(e) => {
e.stopPropagation();
// 删除记录
removeEdit(edit.id);
}}
title="删除记录"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
});
})()}
</div>
)}
</div>
{/* 分页控件 */}
{(() => {
const totalItems = filteredGenerations.length + filteredEdits.length;
const totalPages = Math.ceil(totalItems / itemsPerPage);
// 只在有多页时显示分页控件
if (totalPages > 1) {
return (
<div className="flex items-center justify-between py-2 border-t border-gray-100">
<Button
variant="outline"
size="sm"
className="text-xs p-1.5 h-7 border-gray-200 text-gray-600 hover:bg-gray-100 card"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
>
</Button>
<div className="text-xs text-gray-500">
{currentPage} {totalPages}
</div>
<Button
variant="outline"
size="sm"
className="text-xs p-1.5 h-7 border-gray-200 text-gray-600 hover:bg-gray-100 card"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
);
}
return null;
})()}
{/* 生成详情 */}
<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_30`
: null;
// 对于Blob URL我们需要从decodedImages中获取解码后的图像数据
const displayUrl = uploadedUrl || decodedImages[asset.blobUrl] || asset.blobUrl || 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_30`
: null;
// 对于Blob URL我们需要从decodedImages中获取解码后的图像数据
const displayUrl = uploadedUrl || decodedImages[asset.blobUrl] || asset.blobUrl || 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>
{/* 图像预览模态框 */}
<ImagePreviewModal
open={previewModal.open}
onOpenChange={(open) => setPreviewModal(prev => ({ ...prev, open }))}
imageUrl={previewModal.imageUrl}
title={previewModal.title}
description={previewModal.description}
/>
</div>
);
};