Files
Nano-Banana-AI-Image-Editor/src/components/HistoryPanel.tsx
袁涛 e30e5b4fe2 更新描述文档;
修复了若干错误;
2025-10-05 02:26:50 +08:00

1929 lines
95 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, 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>
);
};