You've already forked Nano-Banana-AI-Image-Editor
1929 lines
95 KiB
TypeScript
1929 lines
95 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||
import { useAppStore } from '../store/useAppStore';
|
||
import { Button } from './ui/Button';
|
||
import { History, Download, Image as ImageIcon, Trash2 } from 'lucide-react';
|
||
import { cn } from '../utils/cn';
|
||
import { ImagePreviewModal } from './ImagePreviewModal';
|
||
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,
|
||
selectedGenerationId,
|
||
selectedEditId,
|
||
selectGeneration,
|
||
selectEdit,
|
||
showHistory,
|
||
setShowHistory,
|
||
setCanvasImage,
|
||
removeGeneration,
|
||
removeEdit
|
||
} = useAppStore();
|
||
|
||
const { getBlob } = useAppStore.getState();
|
||
|
||
// 使用自定义hook获取IndexedDB记录
|
||
const { generations: dbGenerations, edits: dbEdits, loading, error, refresh } = useIndexedDBListener();
|
||
|
||
const [previewModal, setPreviewModal] = React.useState<{
|
||
open: boolean;
|
||
imageUrl: string;
|
||
title: string;
|
||
description?: string;
|
||
}>({
|
||
open: false,
|
||
imageUrl: '',
|
||
title: '',
|
||
description: ''
|
||
});
|
||
|
||
// 删除确认对话框状态
|
||
const [deleteConfirm, setDeleteConfirm] = React.useState<{
|
||
open: boolean;
|
||
ids: string[];
|
||
type: 'generation' | 'edit' | 'multiple';
|
||
count: number;
|
||
}>({
|
||
open: false,
|
||
ids: [],
|
||
type: 'generation',
|
||
count: 0
|
||
});
|
||
|
||
// 存储从Blob URL解码的图像数据
|
||
const [decodedImages, setDecodedImages] = useState<Record<string, string>>({});
|
||
|
||
// 跟踪当前悬停的记录
|
||
const [hoveredRecord, setHoveredRecord] = useState<{type: 'generation' | 'edit', id: string} | null>(null);
|
||
|
||
// 筛选和搜索状态
|
||
const [startDate, setStartDate] = useState<string>(() => {
|
||
// 初始化时默认显示今天的记录
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
return today.toISOString().split('T')[0];
|
||
});
|
||
const [endDate, setEndDate] = useState<string>(() => {
|
||
// 初始化时默认显示今天的记录
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
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: (() => {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
return today;
|
||
})(),
|
||
to: (() => {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
return today;
|
||
})()
|
||
});
|
||
|
||
// 分页状态
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const itemsPerPage = 20; // 减少每页显示的项目数
|
||
|
||
// 获取当前图像尺寸
|
||
// const [imageDimensions, setImageDimensions] = React.useState<{ width: number; height: number } | null>(null);
|
||
|
||
// 当currentProject为空时,使用dbGenerations和dbEdits作为备选
|
||
const displayGenerations = currentProject ? currentProject.generations : dbGenerations;
|
||
const displayEdits = currentProject ? currentProject.edits : dbEdits;
|
||
|
||
// 筛选记录的函数
|
||
const filterRecords = useCallback((records: Array<{timestamp: number, prompt?: string, instruction?: string}>, isGeneration: boolean) => {
|
||
return records.filter(record => {
|
||
// 日期筛选 - 检查记录日期是否在筛选范围内
|
||
const recordDate = new Date(record.timestamp);
|
||
recordDate.setHours(0, 0, 0, 0); // 设置为当天的开始
|
||
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()) || false;
|
||
} else {
|
||
// 编辑记录按指令搜索
|
||
return record.instruction?.toLowerCase().includes(searchTerm.toLowerCase()) || false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
});
|
||
}, [startDate, endDate, searchTerm]);
|
||
|
||
// 筛选后的记录
|
||
const filteredGenerations = useMemo(() => filterRecords(displayGenerations, true), [displayGenerations, filterRecords]);
|
||
const filteredEdits = useMemo(() => filterRecords(displayEdits, false), [displayEdits, filterRecords]);
|
||
|
||
// React.useEffect(() => {
|
||
// if (canvasImage) {
|
||
// const img = new Image();
|
||
// img.onload = () => {
|
||
// setImageDimensions({ width: img.width, height: img.height });
|
||
// };
|
||
// img.src = canvasImage;
|
||
// } else {
|
||
// setImageDimensions(null);
|
||
// }
|
||
// }, [canvasImage]);
|
||
|
||
// 当项目变化时,解码Blob图像
|
||
useEffect(() => {
|
||
const decodeBlobImages = async () => {
|
||
const newDecodedImages: Record<string, string> = {};
|
||
|
||
// 解码生成记录的输出图像
|
||
for (const gen of displayGenerations) {
|
||
if (Array.isArray(gen.outputAssetsBlobUrls)) {
|
||
for (const blobUrl of gen.outputAssetsBlobUrls) {
|
||
if (!decodedImages[blobUrl] && blobUrl.startsWith('blob:')) {
|
||
const blob = getBlob(blobUrl);
|
||
if (blob) {
|
||
// 使用Promise来处理FileReader的异步操作
|
||
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 displayEdits) {
|
||
if (Array.isArray(edit.outputAssetsBlobUrls)) {
|
||
for (const blobUrl of edit.outputAssetsBlobUrls) {
|
||
if (!decodedImages[blobUrl] && blobUrl.startsWith('blob:')) {
|
||
const blob = getBlob(blobUrl);
|
||
if (blob) {
|
||
// 使用Promise来处理FileReader的异步操作
|
||
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 }));
|
||
}
|
||
};
|
||
|
||
// 将异步操作包装在立即执行的函数中
|
||
(async () => {
|
||
await decodeBlobImages();
|
||
})();
|
||
}, [displayGenerations, displayEdits, getBlob, decodedImages]);
|
||
|
||
// 获取上传后的图片链接
|
||
const getUploadedImageUrl = (generationOrEdit: Generation | Edit, index: number) => {
|
||
if (generationOrEdit.uploadResults && generationOrEdit.uploadResults[index]) {
|
||
const uploadResult = generationOrEdit.uploadResults[index];
|
||
if (uploadResult.success && uploadResult.url) {
|
||
// 返回原始链接,不添加任何参数
|
||
return uploadResult.url;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// 加载和错误状态显示
|
||
if (loading) {
|
||
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-gray-500">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-yellow-400 mx-auto mb-4"></div>
|
||
<p className="text-sm">正在加载历史记录...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
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">
|
||
<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) {
|
||
// 确保日期时间设置为当天的开始
|
||
if (range.from) {
|
||
range.from.setHours(0, 0, 0, 0);
|
||
}
|
||
if (range.to) {
|
||
range.to.setHours(0, 0, 0, 0);
|
||
}
|
||
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();
|
||
today.setHours(0, 0, 0, 0);
|
||
const todayStr = today.toISOString().split('T')[0];
|
||
setStartDate(todayStr);
|
||
setEndDate(todayStr);
|
||
setSearchTerm('');
|
||
// 重置日期范围
|
||
setDateRange({
|
||
from: today,
|
||
to: today
|
||
});
|
||
// 重置分页到第一页
|
||
setCurrentPage(1);
|
||
}}
|
||
>
|
||
重置
|
||
</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>
|
||
<div className="flex items-center space-x-2">
|
||
<span className="text-xs text-gray-400">
|
||
{filteredGenerations.length + filteredEdits.length}/100
|
||
</span>
|
||
{(filteredGenerations.length > 0 || filteredEdits.length > 0) && (
|
||
<button
|
||
className="text-red-500 hover:text-red-700 text-xs flex items-center"
|
||
onClick={() => {
|
||
const allIds = [
|
||
...filteredGenerations.map(g => g.id),
|
||
...filteredEdits.map(e => e.id)
|
||
];
|
||
setDeleteConfirm({
|
||
open: true,
|
||
ids: allIds,
|
||
type: 'multiple',
|
||
count: allIds.length
|
||
});
|
||
}}
|
||
title="清空所有历史记录"
|
||
>
|
||
<Trash2 className="h-3 w-3 mr-1" />
|
||
清空
|
||
</button>
|
||
)}
|
||
</div>
|
||
</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 allRecords = [...sortedGenerations, ...[...filteredEdits].sort((a, b) => b.timestamp - a.timestamp)]
|
||
.sort((a, b) => b.timestamp - a.timestamp);
|
||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||
const endIndex = startIndex + itemsPerPage;
|
||
const paginatedRecords = allRecords.slice(startIndex, endIndex);
|
||
|
||
// 只显示当前页的生成记录
|
||
const paginatedGenerations = paginatedRecords.filter(record =>
|
||
sortedGenerations.some(gen => gen.id === record.id)
|
||
);
|
||
|
||
return paginatedGenerations.map((generation: {id: string, sourceAssets?: Array<{url: string}>, outputAssets?: Array<{url: string}>}) => {
|
||
// 计算全局索引用于显示编号
|
||
const globalIndex = allRecords.findIndex(record => record.id === generation.id);
|
||
|
||
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);
|
||
// 设置画布图像为生成结果图像,而不是参考图像
|
||
let imageUrl = null;
|
||
|
||
// 优先使用生成结果图像
|
||
if (generation.outputAssets && generation.outputAssets.length > 0) {
|
||
const asset = generation.outputAssets[0];
|
||
if (asset.url) {
|
||
const uploadedUrl = getUploadedImageUrl(generation, 0);
|
||
imageUrl = uploadedUrl || asset.url;
|
||
}
|
||
}
|
||
|
||
// 如果没有生成结果图像,则使用参考图像
|
||
if (!imageUrl && generation.sourceAssets && generation.sourceAssets.length > 0) {
|
||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||
const outputAssetsCount = generation.outputAssets?.length || 0;
|
||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||
const uploadedUrl = getUploadedImageUrl(generation, uploadResultIndex);
|
||
if (uploadedUrl) {
|
||
imageUrl = uploadedUrl;
|
||
} else if (generation.sourceAssets[0].url) {
|
||
imageUrl = generation.sourceAssets[0].url;
|
||
}
|
||
}
|
||
|
||
if (imageUrl) {
|
||
// 检查是否是Blob URL并且可能已经失效
|
||
if (imageUrl.startsWith('blob:')) {
|
||
// 预先检查Blob URL是否有效
|
||
fetch(imageUrl)
|
||
.then(response => {
|
||
if (!response.ok) {
|
||
// Blob URL失效,尝试从AppStore重新获取
|
||
const { getBlob } = useAppStore.getState();
|
||
const blob = getBlob(imageUrl);
|
||
if (blob) {
|
||
console.log('从AppStore找到Blob,重新创建URL...');
|
||
const newUrl = URL.createObjectURL(blob);
|
||
setCanvasImage(newUrl);
|
||
} else {
|
||
// 如果AppStore中也没有,直接设置原URL,让ImageCanvas处理
|
||
setCanvasImage(imageUrl);
|
||
}
|
||
} else {
|
||
// Blob URL有效,直接使用
|
||
setCanvasImage(imageUrl);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// 网络错误,尝试从AppStore重新获取
|
||
const { getBlob } = useAppStore.getState();
|
||
const blob = getBlob(imageUrl);
|
||
if (blob) {
|
||
console.log('从AppStore找到Blob,重新创建URL...');
|
||
const newUrl = URL.createObjectURL(blob);
|
||
setCanvasImage(newUrl);
|
||
} else {
|
||
// 如果AppStore中也没有,直接设置原URL,让ImageCanvas处理
|
||
setCanvasImage(imageUrl);
|
||
}
|
||
});
|
||
} else {
|
||
// 非Blob URL直接设置
|
||
setCanvasImage(imageUrl);
|
||
}
|
||
}
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
// 设置当前悬停的记录
|
||
setHoveredRecord({type: 'generation', id: generation.id});
|
||
|
||
// 优先显示生成结果图像,如果没有生成结果图像则显示参考图像
|
||
let imageUrl = null;
|
||
|
||
// 优先使用生成结果图像
|
||
if (generation.outputAssets && generation.outputAssets.length > 0) {
|
||
const asset = generation.outputAssets[0];
|
||
if (asset.url) {
|
||
const uploadedUrl = getUploadedImageUrl(generation, 0);
|
||
imageUrl = uploadedUrl || asset.url;
|
||
}
|
||
}
|
||
|
||
// 如果没有生成结果图像,则使用参考图像
|
||
if (!imageUrl && generation.sourceAssets && generation.sourceAssets.length > 0) {
|
||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||
const outputAssetsCount = generation.outputAssets?.length || 0;
|
||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||
imageUrl = getUploadedImageUrl(generation, uploadResultIndex) ||
|
||
(generation.sourceAssets[0].url ? generation.sourceAssets[0].url : null);
|
||
}
|
||
|
||
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);
|
||
// 如果是Blob URL失效,尝试重新获取
|
||
if (imageUrl.startsWith('blob:')) {
|
||
import('../store/useAppStore').then((module) => {
|
||
const useAppStore = module.useAppStore;
|
||
const blob = useAppStore.getState().getBlob(imageUrl);
|
||
if (blob) {
|
||
console.log('从AppStore找到Blob,尝试重新创建URL...');
|
||
// 重新创建Blob URL
|
||
const newUrl = URL.createObjectURL(blob);
|
||
// 更新显示
|
||
setHoveredImage({
|
||
url: newUrl,
|
||
title: `生成记录 G${globalIndex + 1}`,
|
||
width: 0,
|
||
height: 0
|
||
});
|
||
// 传递鼠标位置信息给App组件
|
||
if (setPreviewPosition) {
|
||
setPreviewPosition({ x: e.clientX, y: e.clientY });
|
||
}
|
||
// 预加载新URL
|
||
const newImg = new Image();
|
||
newImg.onload = () => {
|
||
setHoveredImage({
|
||
url: newUrl,
|
||
title: `生成记录 G${globalIndex + 1}`,
|
||
width: newImg.width,
|
||
height: newImg.height
|
||
});
|
||
};
|
||
newImg.src = newUrl;
|
||
} else {
|
||
// 即使图像加载失败,也显示预览
|
||
setHoveredImage({
|
||
url: imageUrl,
|
||
title: `生成记录 G${globalIndex + 1}`,
|
||
width: 0,
|
||
height: 0
|
||
});
|
||
// 传递鼠标位置信息给App组件
|
||
if (setPreviewPosition) {
|
||
setPreviewPosition({ x: e.clientX, y: e.clientY });
|
||
}
|
||
}
|
||
}).catch(err => {
|
||
console.error('导入AppStore时出错:', err);
|
||
// 即使图像加载失败,也显示预览
|
||
setHoveredImage({
|
||
url: imageUrl,
|
||
title: `生成记录 G${globalIndex + 1}`,
|
||
width: 0,
|
||
height: 0
|
||
});
|
||
// 传递鼠标位置信息给App组件
|
||
if (setPreviewPosition) {
|
||
setPreviewPosition({ x: e.clientX, y: e.clientY });
|
||
}
|
||
});
|
||
} else {
|
||
// 即使图像加载失败,也显示预览
|
||
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);
|
||
}
|
||
}}
|
||
>
|
||
{(() => {
|
||
// 优先显示生成结果图像,如果没有生成结果图像则显示参考图像
|
||
let imageUrl = null;
|
||
|
||
// 优先使用生成结果图像
|
||
if (generation.outputAssets && generation.outputAssets.length > 0) {
|
||
const asset = generation.outputAssets[0];
|
||
if (asset.url) {
|
||
const uploadedUrl = getUploadedImageUrl(generation, 0);
|
||
imageUrl = uploadedUrl || asset.url;
|
||
}
|
||
}
|
||
|
||
// 如果没有生成结果图像,则使用参考图像
|
||
if (!imageUrl && generation.sourceAssets && generation.sourceAssets.length > 0) {
|
||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||
const outputAssetsCount = generation.outputAssets?.length || 0;
|
||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||
imageUrl = getUploadedImageUrl(generation, uploadResultIndex) ||
|
||
(generation.sourceAssets[0].url ? generation.sourceAssets[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();
|
||
// 下载图像 - 优先下载参考图像,如果没有则下载生成结果图像
|
||
let imageUrl = null;
|
||
|
||
// 首先尝试获取参考图像
|
||
if (generation.sourceAssets && generation.sourceAssets.length > 0) {
|
||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||
const outputAssetsCount = generation.outputAssets?.length || 0;
|
||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||
imageUrl = getUploadedImageUrl(generation, uploadResultIndex) ||
|
||
(generation.sourceAssets[0].url ? generation.sourceAssets[0].url : null);
|
||
}
|
||
|
||
// 如果没有参考图像,则使用生成结果图像
|
||
if (!imageUrl) {
|
||
imageUrl = getUploadedImageUrl(generation, 0) ||
|
||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
|
||
}
|
||
|
||
if (imageUrl) {
|
||
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
|
||
// 添加更多缓存控制头以绕过CDN缓存
|
||
fetch(imageUrl, {
|
||
headers: {
|
||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||
'Pragma': 'no-cache',
|
||
'Expires': '0'
|
||
}
|
||
})
|
||
.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);
|
||
})
|
||
.catch(error => {
|
||
console.error('下载图像失败:', error);
|
||
// 如果fetch失败,回退到直接使用a标签
|
||
const link = document.createElement('a');
|
||
link.href = imageUrl;
|
||
link.download = `nano-banana-${Date.now()}.png`;
|
||
link.target = '_blank';
|
||
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 allRecords = [...[...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp), ...sortedEdits]
|
||
.sort((a, b) => b.timestamp - a.timestamp);
|
||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||
const endIndex = startIndex + itemsPerPage;
|
||
const paginatedRecords = allRecords.slice(startIndex, endIndex);
|
||
|
||
// 只显示当前页的编辑记录
|
||
const paginatedEdits = paginatedRecords.filter(record =>
|
||
sortedEdits.some(edit => edit.id === record.id)
|
||
);
|
||
|
||
return paginatedEdits.map((edit: {id: string, sourceAssets?: Array<{url: string}>, outputAssets?: Array<{url: string}>}) => {
|
||
// 计算全局索引用于显示编号
|
||
const globalIndex = allRecords.findIndex(record => record.id === edit.id);
|
||
|
||
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);
|
||
// 设置画布图像为编辑结果图像,而不是参考图像
|
||
let imageUrl = null;
|
||
|
||
// 优先使用编辑结果图像
|
||
if (edit.outputAssets && edit.outputAssets.length > 0) {
|
||
const asset = edit.outputAssets[0];
|
||
if (asset.url) {
|
||
const uploadedUrl = getUploadedImageUrl(edit, 0);
|
||
imageUrl = uploadedUrl || asset.url;
|
||
}
|
||
}
|
||
|
||
// 如果没有编辑结果图像,则使用参考图像
|
||
if (!imageUrl && edit.sourceAssets && edit.sourceAssets.length > 0) {
|
||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||
const outputAssetsCount = edit.outputAssets?.length || 0;
|
||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||
const uploadedUrl = getUploadedImageUrl(edit, uploadResultIndex);
|
||
if (uploadedUrl) {
|
||
imageUrl = uploadedUrl;
|
||
} else if (edit.sourceAssets[0].url) {
|
||
imageUrl = edit.sourceAssets[0].url;
|
||
}
|
||
}
|
||
|
||
if (imageUrl) {
|
||
// 检查是否是Blob URL并且可能已经失效
|
||
if (imageUrl.startsWith('blob:')) {
|
||
// 预先检查Blob URL是否有效
|
||
fetch(imageUrl)
|
||
.then(response => {
|
||
if (!response.ok) {
|
||
// Blob URL失效,尝试从AppStore重新获取
|
||
const { getBlob } = useAppStore.getState();
|
||
const blob = getBlob(imageUrl);
|
||
if (blob) {
|
||
console.log('从AppStore找到Blob,重新创建URL...');
|
||
const newUrl = URL.createObjectURL(blob);
|
||
setCanvasImage(newUrl);
|
||
} else {
|
||
// 如果AppStore中也没有,直接设置原URL,让ImageCanvas处理
|
||
setCanvasImage(imageUrl);
|
||
}
|
||
} else {
|
||
// Blob URL有效,直接使用
|
||
setCanvasImage(imageUrl);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// 网络错误,尝试从AppStore重新获取
|
||
const { getBlob } = useAppStore.getState();
|
||
const blob = getBlob(imageUrl);
|
||
if (blob) {
|
||
console.log('从AppStore找到Blob,重新创建URL...');
|
||
const newUrl = URL.createObjectURL(blob);
|
||
setCanvasImage(newUrl);
|
||
} else {
|
||
// 如果AppStore中也没有,直接设置原URL,让ImageCanvas处理
|
||
setCanvasImage(imageUrl);
|
||
}
|
||
});
|
||
} else {
|
||
// 非Blob URL直接设置
|
||
setCanvasImage(imageUrl);
|
||
}
|
||
}
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
// 设置当前悬停的记录
|
||
setHoveredRecord({type: 'edit', id: edit.id});
|
||
|
||
// 优先显示编辑结果图像,如果没有编辑结果图像则显示参考图像
|
||
let imageUrl = null;
|
||
|
||
// 优先使用编辑结果图像
|
||
if (edit.outputAssets && edit.outputAssets.length > 0) {
|
||
const asset = edit.outputAssets[0];
|
||
if (asset.url) {
|
||
const uploadedUrl = getUploadedImageUrl(edit, 0);
|
||
imageUrl = uploadedUrl || asset.url;
|
||
}
|
||
}
|
||
|
||
// 如果没有编辑结果图像,则使用参考图像
|
||
if (!imageUrl && edit.sourceAssets && edit.sourceAssets.length > 0) {
|
||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||
const outputAssetsCount = edit.outputAssets?.length || 0;
|
||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||
imageUrl = getUploadedImageUrl(edit, uploadResultIndex) ||
|
||
(edit.sourceAssets[0].url ? edit.sourceAssets[0].url : null);
|
||
}
|
||
|
||
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);
|
||
}
|
||
}}
|
||
>
|
||
{(() => {
|
||
// 优先显示编辑结果图像,如果没有编辑结果图像则显示参考图像
|
||
let imageUrl = null;
|
||
|
||
// 优先使用编辑结果图像
|
||
if (edit.outputAssets && edit.outputAssets.length > 0) {
|
||
const asset = edit.outputAssets[0];
|
||
if (asset.url) {
|
||
const uploadedUrl = getUploadedImageUrl(edit, 0);
|
||
imageUrl = uploadedUrl || asset.url;
|
||
}
|
||
}
|
||
|
||
// 如果没有编辑结果图像,则使用参考图像
|
||
if (!imageUrl && edit.sourceAssets && edit.sourceAssets.length > 0) {
|
||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||
const outputAssetsCount = edit.outputAssets?.length || 0;
|
||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||
imageUrl = getUploadedImageUrl(edit, uploadResultIndex) ||
|
||
(edit.sourceAssets[0].url ? edit.sourceAssets[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();
|
||
// 下载图像 - 优先下载参考图像,如果没有则下载编辑结果图像
|
||
let imageUrl = null;
|
||
|
||
// 首先尝试获取参考图像
|
||
if (edit.sourceAssets && edit.sourceAssets.length > 0) {
|
||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||
const outputAssetsCount = edit.outputAssets?.length || 0;
|
||
const uploadResultIndex = outputAssetsCount; // 第一个参考图像
|
||
imageUrl = getUploadedImageUrl(edit, uploadResultIndex) ||
|
||
(edit.sourceAssets[0].url ? edit.sourceAssets[0].url : null);
|
||
}
|
||
|
||
// 如果没有参考图像,则使用编辑结果图像
|
||
if (!imageUrl) {
|
||
imageUrl = getUploadedImageUrl(edit, 0) ||
|
||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
|
||
}
|
||
|
||
if (imageUrl) {
|
||
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
|
||
// 添加更多缓存控制头以绕过CDN缓存
|
||
fetch(imageUrl, {
|
||
headers: {
|
||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||
'Pragma': 'no-cache',
|
||
'Expires': '0'
|
||
}
|
||
})
|
||
.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);
|
||
})
|
||
.catch(error => {
|
||
console.error('下载图像失败:', error);
|
||
// 如果fetch失败,回退到直接使用a标签
|
||
const link = document.createElement('a');
|
||
link.href = imageUrl;
|
||
link.download = `nano-banana-${Date.now()}.png`;
|
||
link.target = '_blank';
|
||
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 allRecords = [
|
||
...[...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp),
|
||
...[...filteredEdits].sort((a, b) => b.timestamp - a.timestamp)
|
||
].sort((a, b) => b.timestamp - a.timestamp);
|
||
|
||
const totalItems = allRecords.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.outputAssets && gen.outputAssets.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.outputAssets.length} 个生成结果
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{gen.outputAssets.slice(0, 4).map((asset: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
|
||
// 获取上传后的远程链接(如果存在)
|
||
const uploadedUrl = gen.uploadResults && gen.uploadResults[index] && gen.uploadResults[index].success
|
||
? `${gen.uploadResults[index].url}?x-oss-process=image/quality,q_30`
|
||
: null;
|
||
|
||
// 如果没有上传的URL,则使用asset中的URL
|
||
const displayUrl = uploadedUrl || asset.url || asset.blobUrl;
|
||
|
||
// 如果URL是blob:开头但已失效,尝试重新创建
|
||
if (displayUrl && displayUrl.startsWith('blob:')) {
|
||
// 检查blob是否仍然有效
|
||
const img = new Image();
|
||
img.onerror = () => {
|
||
// Blob URL可能已失效,尝试重新创建
|
||
import('../store/useAppStore').then((module) => {
|
||
const useAppStore = module.useAppStore;
|
||
const blob = useAppStore.getState().getBlob(displayUrl);
|
||
if (blob) {
|
||
const newUrl = URL.createObjectURL(blob);
|
||
// 更新显示
|
||
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
|
||
if (imgElement) {
|
||
imgElement.src = newUrl;
|
||
}
|
||
}
|
||
});
|
||
};
|
||
img.src = displayUrl;
|
||
}
|
||
|
||
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}`
|
||
});
|
||
}}
|
||
>
|
||
{displayUrl ? (
|
||
<img
|
||
src={displayUrl}
|
||
alt={`生成结果 ${index + 1}`}
|
||
className="w-full h-full object-cover"
|
||
onError={(e) => {
|
||
// 如果图像加载失败,尝试重新创建Blob URL
|
||
if (displayUrl.startsWith('blob:')) {
|
||
import('../store/useAppStore').then((module) => {
|
||
const useAppStore = module.useAppStore;
|
||
const blob = useAppStore.getState().getBlob(displayUrl);
|
||
if (blob) {
|
||
const newUrl = URL.createObjectURL(blob);
|
||
(e.target as HTMLImageElement).src = newUrl;
|
||
}
|
||
});
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<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>
|
||
);
|
||
})}
|
||
{gen.outputAssets.length > 4 && (
|
||
<div className="w-16 h-16 rounded border flex items-center justify-center bg-gray-100 text-xs text-gray-500">
|
||
+{gen.outputAssets.length - 4}
|
||
</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: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
|
||
// 优先使用上传后的远程链接,如果没有则使用asset中的URL
|
||
// 参考图像在uploadResults中从索引1开始(图像2字段)
|
||
const uploadResultIndex = 1 + index;
|
||
const uploadedUrl = gen.uploadResults && gen.uploadResults[uploadResultIndex] && gen.uploadResults[uploadResultIndex].success
|
||
? gen.uploadResults[uploadResultIndex].url
|
||
: null;
|
||
|
||
const displayUrl = uploadedUrl || asset.url || asset.blobUrl;
|
||
|
||
// 如果URL是blob:开头但已失效,尝试重新创建
|
||
if (displayUrl && displayUrl.startsWith('blob:')) {
|
||
// 检查blob是否仍然有效
|
||
const img = new Image();
|
||
img.onerror = () => {
|
||
// Blob URL可能已失效,尝试重新创建
|
||
import('../store/useAppStore').then((module) => {
|
||
const useAppStore = module.useAppStore;
|
||
const blob = useAppStore.getState().getBlob(displayUrl);
|
||
if (blob) {
|
||
const newUrl = URL.createObjectURL(blob);
|
||
// 更新显示
|
||
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
|
||
if (imgElement) {
|
||
imgElement.src = newUrl;
|
||
}
|
||
}
|
||
});
|
||
};
|
||
img.src = displayUrl;
|
||
}
|
||
|
||
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}`
|
||
});
|
||
}}
|
||
>
|
||
{displayUrl ? (
|
||
<img
|
||
src={displayUrl}
|
||
alt={`参考图像 ${index + 1}`}
|
||
className="w-full h-full object-cover"
|
||
onError={(e) => {
|
||
// 如果图像加载失败,尝试重新创建Blob URL
|
||
if (displayUrl.startsWith('blob:')) {
|
||
import('../store/useAppStore').then((module) => {
|
||
const useAppStore = module.useAppStore;
|
||
const blob = useAppStore.getState().getBlob(displayUrl);
|
||
if (blob) {
|
||
const newUrl = URL.createObjectURL(blob);
|
||
(e.target as HTMLImageElement).src = newUrl;
|
||
}
|
||
});
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<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>
|
||
);
|
||
})}
|
||
{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(item => item.id === selectedEdit.parentGenerationId) || dbGenerations.find(item => item.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>
|
||
)}
|
||
|
||
{/* 编辑结果图像 */}
|
||
{selectedEdit.outputAssets && selectedEdit.outputAssets.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">
|
||
{selectedEdit.outputAssets.length} 个编辑结果
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedEdit.outputAssets.slice(0, 4).map((asset: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
|
||
// 获取上传后的远程链接(如果存在)
|
||
const uploadedUrl = selectedEdit.uploadResults && selectedEdit.uploadResults[index] && selectedEdit.uploadResults[index].success
|
||
? selectedEdit.uploadResults[index].url
|
||
: null;
|
||
|
||
// 如果没有上传的URL,则使用asset中的URL
|
||
const displayUrl = uploadedUrl || asset.url || asset.blobUrl;
|
||
|
||
// 如果URL是blob:开头但已失效,尝试重新创建
|
||
if (displayUrl && displayUrl.startsWith('blob:')) {
|
||
// 检查blob是否仍然有效
|
||
const img = new Image();
|
||
img.onerror = () => {
|
||
// Blob URL可能已失效,尝试重新创建
|
||
import('../store/useAppStore').then((module) => {
|
||
const useAppStore = module.useAppStore;
|
||
const blob = useAppStore.getState().getBlob(displayUrl);
|
||
if (blob) {
|
||
const newUrl = URL.createObjectURL(blob);
|
||
// 更新显示
|
||
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
|
||
if (imgElement) {
|
||
imgElement.src = newUrl;
|
||
}
|
||
}
|
||
});
|
||
};
|
||
img.src = displayUrl;
|
||
}
|
||
|
||
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}`
|
||
});
|
||
}}
|
||
>
|
||
{displayUrl ? (
|
||
<img
|
||
src={displayUrl}
|
||
alt={`编辑结果 ${index + 1}`}
|
||
className="w-full h-full object-cover"
|
||
onError={(e) => {
|
||
// 如果图像加载失败,尝试重新创建Blob URL
|
||
if (displayUrl.startsWith('blob:')) {
|
||
import('../store/useAppStore').then((module) => {
|
||
const useAppStore = module.useAppStore;
|
||
const blob = useAppStore.getState().getBlob(displayUrl);
|
||
if (blob) {
|
||
const newUrl = URL.createObjectURL(blob);
|
||
(e.target as HTMLImageElement).src = newUrl;
|
||
}
|
||
});
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<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>
|
||
);
|
||
})}
|
||
{selectedEdit.outputAssets.length > 4 && (
|
||
<div className="w-16 h-16 rounded border flex items-center justify-center bg-gray-100 text-xs text-gray-500">
|
||
+{selectedEdit.outputAssets.length - 4}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 编辑参考图像 */}
|
||
{selectedEdit.sourceAssets && selectedEdit.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">
|
||
{selectedEdit.sourceAssets.length} 个参考图像
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedEdit.sourceAssets.slice(0, 4).map((asset: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
|
||
// 优先使用上传后的远程链接,如果没有则使用asset中的URL
|
||
// 参考图像在uploadResults中从索引1开始(图像2字段)
|
||
const uploadResultIndex = 1 + index;
|
||
const uploadedUrl = selectedEdit.uploadResults && selectedEdit.uploadResults[uploadResultIndex] && selectedEdit.uploadResults[uploadResultIndex].success
|
||
? selectedEdit.uploadResults[uploadResultIndex].url
|
||
: null;
|
||
|
||
const displayUrl = uploadedUrl || asset.url || asset.blobUrl;
|
||
|
||
// 如果URL是blob:开头但已失效,尝试重新创建
|
||
if (displayUrl && displayUrl.startsWith('blob:')) {
|
||
// 检查blob是否仍然有效
|
||
const img = new Image();
|
||
img.onerror = () => {
|
||
// Blob URL可能已失效,尝试重新创建
|
||
import('../store/useAppStore').then((module) => {
|
||
const useAppStore = module.useAppStore;
|
||
const blob = useAppStore.getState().getBlob(displayUrl);
|
||
if (blob) {
|
||
const newUrl = URL.createObjectURL(blob);
|
||
// 更新显示
|
||
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
|
||
if (imgElement) {
|
||
imgElement.src = newUrl;
|
||
}
|
||
}
|
||
});
|
||
};
|
||
img.src = displayUrl;
|
||
}
|
||
|
||
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}`
|
||
});
|
||
}}
|
||
>
|
||
{displayUrl ? (
|
||
<img
|
||
src={displayUrl}
|
||
alt={`编辑参考图像 ${index + 1}`}
|
||
className="w-full h-full object-cover"
|
||
onError={(e) => {
|
||
// 如果图像加载失败,尝试重新创建Blob URL
|
||
if (displayUrl.startsWith('blob:')) {
|
||
import('../store/useAppStore').then((module) => {
|
||
const useAppStore = module.useAppStore;
|
||
const blob = useAppStore.getState().getBlob(displayUrl);
|
||
if (blob) {
|
||
const newUrl = URL.createObjectURL(blob);
|
||
(e.target as HTMLImageElement).src = newUrl;
|
||
}
|
||
});
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<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>
|
||
);
|
||
})}
|
||
{selectedEdit.sourceAssets.length > 4 && (
|
||
<div className="w-16 h-16 rounded border flex items-center justify-center bg-gray-100 text-xs text-gray-500">
|
||
+{selectedEdit.sourceAssets.length - 4}
|
||
</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(item => item.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: {id: string, url?: string, blobUrl?: string, width: number, height: number}, index: number) => {
|
||
// 优先使用上传后的远程链接,如果没有则使用asset中的URL
|
||
// 参考图像在uploadResults中从索引1开始(图像2字段)
|
||
const uploadResultIndex = 1 + index;
|
||
const uploadedUrl = parentGen.uploadResults && parentGen.uploadResults[uploadResultIndex] && parentGen.uploadResults[uploadResultIndex].success
|
||
? parentGen.uploadResults[uploadResultIndex].url
|
||
: null;
|
||
|
||
const displayUrl = uploadedUrl || asset.url || asset.blobUrl;
|
||
|
||
// 如果URL是blob:开头但已失效,尝试重新创建
|
||
if (displayUrl && displayUrl.startsWith('blob:')) {
|
||
// 检查blob是否仍然有效
|
||
const img = new Image();
|
||
img.onerror = () => {
|
||
// Blob URL可能已失效,尝试重新创建
|
||
import('../store/useAppStore').then((module) => {
|
||
const useAppStore = module.useAppStore;
|
||
const blob = useAppStore.getState().getBlob(displayUrl);
|
||
if (blob) {
|
||
const newUrl = URL.createObjectURL(blob);
|
||
// 更新显示
|
||
const imgElement = document.querySelector(`img[src="${displayUrl}"]`);
|
||
if (imgElement) {
|
||
imgElement.src = newUrl;
|
||
}
|
||
}
|
||
});
|
||
};
|
||
img.src = displayUrl;
|
||
}
|
||
|
||
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}`
|
||
});
|
||
}}
|
||
>
|
||
{displayUrl ? (
|
||
<img
|
||
src={displayUrl}
|
||
alt={`原始参考图像 ${index + 1}`}
|
||
className="w-full h-full object-cover"
|
||
onError={(e) => {
|
||
// 如果图像加载失败,尝试重新创建Blob URL
|
||
if (displayUrl.startsWith('blob:')) {
|
||
import('../store/useAppStore').then((module) => {
|
||
const useAppStore = module.useAppStore;
|
||
const blob = useAppStore.getState().getBlob(displayUrl);
|
||
if (blob) {
|
||
const newUrl = URL.createObjectURL(blob);
|
||
(e.target as HTMLImageElement).src = newUrl;
|
||
}
|
||
});
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<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>
|
||
);
|
||
})}
|
||
{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}
|
||
/>
|
||
|
||
{/* 删除确认对话框 */}
|
||
{deleteConfirm.open && (
|
||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center z-[100] backdrop-blur-sm rounded-lg">
|
||
<div className="bg-white rounded-xl p-6 card-lg max-w-xs w-full mx-4">
|
||
<div className="text-center">
|
||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||
<Trash2 className="h-6 w-6 text-red-600" />
|
||
</div>
|
||
<h3 className="text-lg font-medium text-gray-900 mb-2">确认删除</h3>
|
||
<p className="text-sm text-gray-500 mb-4">
|
||
{deleteConfirm.count > 1
|
||
? `确定要删除这 ${deleteConfirm.count} 条历史记录吗?此操作无法撤销。`
|
||
: '确定要删除这条历史记录吗?此操作无法撤销。'}
|
||
</p>
|
||
<div className="flex justify-center gap-3">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="text-xs px-4 py-2 h-8 border-gray-200 text-gray-600 hover:bg-gray-100 card"
|
||
onClick={() => setDeleteConfirm(prev => ({ ...prev, open: false }))}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
size="sm"
|
||
className="text-xs px-4 py-2 h-8 card"
|
||
onClick={() => {
|
||
// 执行删除操作
|
||
if (deleteConfirm.type === 'generation') {
|
||
deleteConfirm.ids.forEach(id => removeGeneration(id));
|
||
} else if (deleteConfirm.type === 'edit') {
|
||
deleteConfirm.ids.forEach(id => removeEdit(id));
|
||
} else {
|
||
// 多选删除
|
||
deleteConfirm.ids.forEach(id => {
|
||
// 检查是生成记录还是编辑记录
|
||
if (filteredGenerations.some(gen => gen.id === id)) {
|
||
removeGeneration(id);
|
||
} else if (filteredEdits.some(edit => edit.id === id)) {
|
||
removeEdit(id);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 关闭对话框
|
||
setDeleteConfirm(prev => ({ ...prev, open: false }));
|
||
}}
|
||
>
|
||
删除
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
|
||
</div>
|
||
);
|
||
}; |