Files
Nano-Banana-AI-Image-Editor/src/components/HistoryPanel.tsx
袁涛 7a5e5d77b0 新增 历史记录搜索、筛选功能;
新增 历史记录悬浮大图功能;
优化 现在历史记录最多可以存储1000条;
优化 历史记录的存储形式改为了使用IndexedDB;
2025-09-15 22:19:24 +08:00

770 lines
30 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

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

import React, { useState, useEffect } from 'react';
import { useAppStore } from '../store/useAppStore';
import { Button } from './ui/Button';
import { History, Download, Image as ImageIcon } from 'lucide-react';
import { cn } from '../utils/cn';
import { ImagePreviewModal } from './ImagePreviewModal';
import * as indexedDBService from '../services/indexedDBService';
export const HistoryPanel: React.FC = () => {
const {
currentProject,
canvasImage,
selectedGenerationId,
selectedEditId,
selectGeneration,
selectEdit,
showHistory,
setShowHistory,
setCanvasImage,
selectedTool
} = useAppStore();
const { getBlob } = useAppStore.getState();
const [previewModal, setPreviewModal] = React.useState<{
open: boolean;
imageUrl: string;
title: string;
description?: string;
}>({
open: false,
imageUrl: '',
title: '',
description: ''
});
// 存储从Blob URL解码的图像数据
const [decodedImages, setDecodedImages] = useState<Record<string, string>>({});
// 存储从IndexedDB获取的完整记录
const [dbGenerations, setDbGenerations] = useState<any[]>([]);
const [dbEdits, setDbEdits] = useState<any[]>([]);
// 筛选和搜索状态
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [searchTerm, setSearchTerm] = useState<string>('');
// 悬浮预览状态
const [hoveredImage, setHoveredImage] = useState<{url: string, title: string} | null>(null);
const [previewPosition, setPreviewPosition] = useState<{x: number, y: number}>({x: 0, y: 0});
const generations = currentProject?.generations || [];
const edits = currentProject?.edits || [];
// 获取上传后的图片链接
const getUploadedImageUrl = (generationOrEdit: any, index: number) => {
if (generationOrEdit.uploadResults && generationOrEdit.uploadResults[index]) {
const uploadResult = generationOrEdit.uploadResults[index];
if (uploadResult.success && uploadResult.url) {
// 添加参数以降低图片质量
return `${uploadResult.url}?x-oss-process=image/quality,q_50`;
}
}
return null;
};
// 获取当前图像尺寸
const [imageDimensions, setImageDimensions] = React.useState<{ width: number; height: number } | null>(null);
React.useEffect(() => {
if (canvasImage) {
const img = new Image();
img.onload = () => {
setImageDimensions({ width: img.width, height: img.height });
};
img.src = canvasImage;
} else {
setImageDimensions(null);
}
}, [canvasImage]);
// 当组件挂载时从IndexedDB获取记录
useEffect(() => {
const loadDBRecords = async () => {
try {
// 初始化数据库
await indexedDBService.initDB();
// 获取所有生成记录和编辑记录
const allGenerations = await indexedDBService.getAllGenerations();
const allEdits = await indexedDBService.getAllEdits();
setDbGenerations(allGenerations);
setDbEdits(allEdits);
} catch (err) {
console.error('从IndexedDB加载记录失败:', err);
}
};
loadDBRecords();
}, []);
// 当有新记录添加时,重新加载记录
useEffect(() => {
// 监听store中的记录变化如果有新记录则重新加载
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'nano-banana-store') {
// 重新加载记录
const loadDBRecords = async () => {
try {
const allGenerations = await indexedDBService.getAllGenerations();
const allEdits = await indexedDBService.getAllEdits();
setDbGenerations(allGenerations);
setDbEdits(allEdits);
} catch (err) {
console.error('从IndexedDB重新加载记录失败:', err);
}
};
loadDBRecords();
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
// 筛选记录的函数
const filterRecords = (records: any[], isGeneration: boolean) => {
return records.filter(record => {
// 日期筛选
const recordDate = new Date(record.timestamp);
if (startDate && recordDate < new Date(startDate)) return false;
if (endDate && recordDate > new Date(endDate)) return false;
// 搜索词筛选
if (searchTerm) {
if (isGeneration) {
// 生成记录按提示词搜索
return record.prompt.toLowerCase().includes(searchTerm.toLowerCase());
} else {
// 编辑记录按指令搜索
return record.instruction.toLowerCase().includes(searchTerm.toLowerCase());
}
}
return true;
});
};
// 筛选后的记录
const filteredGenerations = filterRecords(dbGenerations, true);
const filteredEdits = filterRecords(dbEdits, false);
// 当项目变化时解码Blob图像
useEffect(() => {
const decodeBlobImages = async () => {
const newDecodedImages: Record<string, string> = {};
// 解码生成记录的输出图像
for (const gen of generations) {
if (Array.isArray(gen.outputAssetsBlobUrls)) {
for (const blobUrl of gen.outputAssetsBlobUrls) {
if (!decodedImages[blobUrl] && blobUrl.startsWith('blob:')) {
const blob = getBlob(blobUrl);
if (blob) {
const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
newDecodedImages[blobUrl] = dataUrl;
}
}
}
}
}
// 解码编辑记录的输出图像
for (const edit of edits) {
if (Array.isArray(edit.outputAssetsBlobUrls)) {
for (const blobUrl of edit.outputAssetsBlobUrls) {
if (!decodedImages[blobUrl] && blobUrl.startsWith('blob:')) {
const blob = getBlob(blobUrl);
if (blob) {
const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
newDecodedImages[blobUrl] = dataUrl;
}
}
}
}
}
if (Object.keys(newDecodedImages).length > 0) {
setDecodedImages(prev => ({ ...prev, ...newDecodedImages }));
}
};
decodeBlobImages();
}, [generations, edits, getBlob, decodedImages]);
if (!showHistory) {
return (
<div className="w-8 bg-white border-l border-gray-200 flex flex-col items-center justify-center">
<button
onClick={() => setShowHistory(true)}
className="w-6 h-16 bg-gray-100 hover:bg-gray-200 rounded-l-lg border border-r-0 border-gray-300 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-80 bg-white border-l border-gray-200 p-6 flex flex-col h-full">
{/* 头部 */}
<div className="flex items-center justify-between mb-4">
<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>
<div className="flex space-x-1">
<Button
variant="ghost"
size="icon"
onClick={async () => {
try {
const allGenerations = await indexedDBService.getAllGenerations();
const allEdits = await indexedDBService.getAllEdits();
setDbGenerations(allGenerations);
setDbEdits(allEdits);
} catch (err) {
console.error('刷新历史记录失败:', err);
}
}}
className="h-6 w-6"
title="刷新历史记录"
>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowHistory(!showHistory)}
className="h-6 w-6"
title="隐藏历史面板"
>
×
</Button>
</div>
</div>
{/* 筛选和搜索控件 */}
<div className="mb-4 space-y-3">
<div className="flex space-x-2">
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="flex-1 text-xs p-1 border rounded"
placeholder="开始日期"
/>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="flex-1 text-xs p-1 border rounded"
placeholder="结束日期"
/>
</div>
<div className="flex">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 text-xs p-1 border rounded-l"
placeholder="搜索提示词或编辑指令..."
/>
<Button
variant="outline"
size="sm"
className="text-xs p-1 rounded-l-none"
onClick={() => {
setStartDate('');
setEndDate('');
setSearchTerm('');
}}
>
</Button>
</div>
</div>
{/* 变体网格 */}
<div className="mb-6 flex-shrink-0">
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-medium text-gray-400"></h4>
<span className="text-xs text-gray-500">
{filteredGenerations.length + filteredEdits.length}/1000
</span>
</div>
{filteredGenerations.length === 0 && filteredEdits.length === 0 ? (
<div className="text-center py-8">
<div className="text-4xl mb-2">🖼</div>
<p className="text-sm text-gray-500"></p>
</div>
) : (
<div className="grid grid-cols-3 gap-2 max-h-80 overflow-y-auto">
{/* 显示生成记录 */}
{[...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp).slice(0, 50).map((generation, index) => (
<div
key={generation.id}
className={cn(
'relative aspect-square rounded-lg border-2 cursor-pointer transition-all duration-200 overflow-hidden',
selectedGenerationId === generation.id
? 'border-yellow-400'
: 'border-gray-300 hover:border-gray-400'
)}
onClick={() => {
selectGeneration(generation.id);
// 设置画布图像为第一个输出资产
if (generation.outputAssets && generation.outputAssets.length > 0) {
const asset = generation.outputAssets[0];
if (asset.url) {
setCanvasImage(asset.url);
}
}
}}
onMouseEnter={(e) => {
if (generation.outputAssets && generation.outputAssets.length > 0) {
const asset = generation.outputAssets[0];
if (asset.url) {
setHoveredImage({
url: asset.url,
title: `生成记录 G${index + 1}: ${generation.prompt.substring(0, 50)}${generation.prompt.length > 50 ? '...' : ''}`
});
setPreviewPosition({x: e.clientX, y: e.clientY});
}
}
}}
onMouseMove={(e) => {
// 调整预览位置以避免被遮挡
const previewWidth = 300;
const previewHeight = 300;
const offsetX = 10;
const offsetY = 10;
let x = e.clientX + offsetX;
let y = e.clientY + offsetY;
// 检查是否超出右边界
if (x + previewWidth > window.innerWidth) {
x = window.innerWidth - previewWidth - 10;
}
// 检查是否超出下边界
if (y + previewHeight > window.innerHeight) {
y = window.innerHeight - previewHeight - 10;
}
// 检查是否超出左边界
if (x < 0) {
x = 10;
}
// 检查是否超出上边界
if (y < 0) {
y = 10;
}
setPreviewPosition({x, y});
}}
onMouseLeave={() => {
setHoveredImage(null);
}}
>
{generation.outputAssets && generation.outputAssets.length > 0 ? (
(() => {
const asset = generation.outputAssets[0];
if (asset.url) {
// 如果是base64数据URL直接显示
if (asset.url.startsWith('data:')) {
return <img src={asset.url} alt="生成的变体" className="w-full h-full object-cover" />;
}
// 如果是普通URL直接显示
return <img src={asset.url} 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="w-full h-full bg-gray-100 flex items-center justify-center">
<ImageIcon className="h-4 w-4 text-gray-400" />
</div>
)}
{/* 变体编号 */}
<div className="absolute top-1 left-1 bg-gray-900/80 text-xs px-1 py-0.5 rounded text-white">
G{index + 1}
</div>
</div>
))}
{/* 显示编辑记录 */}
{[...filteredEdits].sort((a, b) => b.timestamp - a.timestamp).slice(0, 50).map((edit, index) => (
<div
key={edit.id}
className={cn(
'relative aspect-square rounded-lg border-2 cursor-pointer transition-all duration-200 overflow-hidden',
selectedEditId === edit.id
? 'border-purple-400'
: 'border-gray-300 hover:border-gray-400'
)}
onClick={() => {
selectEdit(edit.id);
selectGeneration(null);
// 设置画布图像为第一个输出资产
if (edit.outputAssets && edit.outputAssets.length > 0) {
const asset = edit.outputAssets[0];
if (asset.url) {
setCanvasImage(asset.url);
}
}
}}
onMouseEnter={(e) => {
if (edit.outputAssets && edit.outputAssets.length > 0) {
const asset = edit.outputAssets[0];
if (asset.url) {
setHoveredImage({
url: asset.url,
title: `编辑记录 E${index + 1}: ${edit.instruction.substring(0, 50)}${edit.instruction.length > 50 ? '...' : ''}`
});
setPreviewPosition({x: e.clientX, y: e.clientY});
}
}
}}
onMouseMove={(e) => {
// 调整预览位置以避免被遮挡
const previewWidth = 300;
const previewHeight = 300;
const offsetX = 10;
const offsetY = 10;
let x = e.clientX + offsetX;
let y = e.clientY + offsetY;
// 检查是否超出右边界
if (x + previewWidth > window.innerWidth) {
x = window.innerWidth - previewWidth - 10;
}
// 检查是否超出下边界
if (y + previewHeight > window.innerHeight) {
y = window.innerHeight - previewHeight - 10;
}
// 检查是否超出左边界
if (x < 0) {
x = 10;
}
// 检查是否超出上边界
if (y < 0) {
y = 10;
}
setPreviewPosition({x, y});
}}
onMouseLeave={() => {
setHoveredImage(null);
}}
>
{edit.outputAssets && edit.outputAssets.length > 0 ? (
(() => {
const asset = edit.outputAssets[0];
if (asset.url) {
// 如果是base64数据URL直接显示
if (asset.url.startsWith('data:')) {
return <img src={asset.url} alt="编辑的变体" className="w-full h-full object-cover" />;
}
// 如果是普通URL直接显示
return <img src={asset.url} 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="w-full h-full bg-gray-100 flex items-center justify-center">
<ImageIcon className="h-4 w-4 text-gray-400" />
</div>
)}
{/* 编辑标签 */}
<div className="absolute top-1 left-1 bg-purple-900/80 text-xs px-1 py-0.5 rounded text-white">
E{index + 1}
</div>
</div>
))}
</div>
)}
</div>
{/* 当前图像信息 */}
{(canvasImage || imageDimensions) && (
<div className="mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<h4 className="text-xs font-medium text-gray-500 mb-2"></h4>
<div className="space-y-1 text-xs text-gray-600">
{imageDimensions && (
<div className="flex justify-between">
<span>:</span>
<span className="text-gray-800">{imageDimensions.width} × {imageDimensions.height}</span>
</div>
)}
<div className="flex justify-between">
<span>:</span>
<span className="text-gray-800 capitalize">{selectedTool}</span>
</div>
</div>
</div>
)}
{/* 生成详情 */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg border border-gray-200 flex-1 overflow-y-auto min-h-0">
<h4 className="text-xs font-medium text-gray-500 mb-2"></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">
<div className="space-y-2 text-xs text-gray-600">
<div>
<span className="text-gray-500">:</span>
<p className="text-gray-800 mt-1">{gen.prompt}</p>
</div>
<div className="flex justify-between">
<span>:</span>
<span>{gen.modelVersion}</span>
</div>
{gen.parameters.seed && (
<div className="flex justify-between">
<span>:</span>
<span>{gen.parameters.seed}</span>
</div>
)}
<div className="flex justify-between">
<span>:</span>
<span>{new Date(gen.timestamp).toLocaleString()}</span>
</div>
</div>
{/* 上传结果 */}
{gen.uploadResults && gen.uploadResults.length > 0 && (
<div>
<h5 className="text-xs font-medium text-gray-500 mb-2"></h5>
<div className="space-y-1">
{gen.uploadResults.map((result, index) => (
<div key={index} className="text-xs">
<div className="flex justify-between">
<span> {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">
{result.url.split('/').pop()}
</div>
)}
{result.error && (
<div className="text-red-600 truncate">
{result.error}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* 参考图像信息 */}
{gen.sourceAssets && gen.sourceAssets.length > 0 && (
<div>
<h5 className="text-xs font-medium text-gray-500 mb-2"></h5>
<div className="text-xs text-gray-600">
{gen.sourceAssets.length}
</div>
</div>
)}
</div>
);
} else if (selectedEdit) {
const parentGen = dbGenerations.find(g => g.id === selectedEdit.parentGenerationId);
return (
<div className="space-y-3">
<div className="space-y-2 text-xs text-gray-600">
<div>
<span className="text-gray-500">:</span>
<p className="text-gray-800 mt-1">{selectedEdit.instruction}</p>
</div>
<div className="flex justify-between">
<span>:</span>
<span></span>
</div>
<div className="flex justify-between">
<span>:</span>
<span>{new Date(selectedEdit.timestamp).toLocaleString()}</span>
</div>
{selectedEdit.maskAssetId && (
<div className="flex justify-between">
<span>:</span>
<span className="text-purple-600"></span>
</div>
)}
</div>
{/* 上传结果 */}
{selectedEdit.uploadResults && selectedEdit.uploadResults.length > 0 && (
<div>
<h5 className="text-xs font-medium text-gray-500 mb-2"></h5>
<div className="space-y-1">
{selectedEdit.uploadResults.map((result, index) => (
<div key={index} className="text-xs">
<div className="flex justify-between">
<span> {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">
{result.url.split('/').pop()}
</div>
)}
{result.error && (
<div className="text-red-600 truncate">
{result.error}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* 原始生成参考 */}
{parentGen && (
<div>
<h5 className="text-xs font-medium text-gray-500 mb-2"></h5>
<div className="text-xs text-gray-600">
基于: G{dbGenerations.findIndex(g => g.id === parentGen.id) + 1}
</div>
</div>
)}
</div>
);
} else {
return (
<div className="space-y-2 text-xs text-gray-500">
<p className="text-gray-500"></p>
</div>
);
}
})()}
</div>
{/* 操作 */}
<div className="space-y-3 flex-shrink-0">
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
// 查找当前显示的图像(生成记录或编辑记录)
let imageUrl: string | null = null;
if (selectedGenerationId) {
const { canvasImage } = useAppStore.getState();
imageUrl = canvasImage;
} else {
// 如果没有选择生成记录,尝试获取当前画布图像
const { canvasImage } = useAppStore.getState();
imageUrl = canvasImage;
}
if (imageUrl) {
// 处理数据URL和常规URL
if (imageUrl.startsWith('data:')) {
const link = document.createElement('a');
link.href = imageUrl;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
// 对于外部URL我们需要获取并转换为blob
fetch(imageUrl)
.then(response => response.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nano-banana-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
});
}
}
}}
disabled={!selectedGenerationId && !useAppStore.getState().canvasImage}
>
<Download className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 图像预览模态框 */}
<ImagePreviewModal
open={previewModal.open}
onOpenChange={(open) => setPreviewModal(prev => ({ ...prev, open }))}
imageUrl={previewModal.imageUrl}
title={previewModal.title}
description={previewModal.description}
/>
{/* 悬浮预览 */}
{hoveredImage && (
<div
className="fixed z-50 shadow-2xl border-2 border-gray-300 rounded-lg overflow-hidden"
style={{
left: Math.min(previewPosition.x + 10, window.innerWidth - 320),
top: Math.min(previewPosition.y + 10, window.innerHeight - 320),
maxWidth: '300px',
maxHeight: '300px'
}}
>
<div className="bg-black text-white text-xs p-1 truncate">
{hoveredImage.title}
</div>
<img
src={hoveredImage.url}
alt="预览"
className="w-full h-full object-contain max-w-[300px] max-h-[300px]"
/>
</div>
)}
</div>
);
};