You've already forked Nano-Banana-AI-Image-Editor
新增 历史记录持久化功能;
新增 增加最大历史记录条数为10条;
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { Button } from './ui/Button';
|
||||
import { History, Download, Image as ImageIcon, Layers } from 'lucide-react';
|
||||
import { History, Download, Image as ImageIcon } from 'lucide-react';
|
||||
import { cn } from '../utils/cn';
|
||||
import { ImagePreviewModal } from './ImagePreviewModal';
|
||||
|
||||
@@ -19,6 +19,8 @@ export const HistoryPanel: React.FC = () => {
|
||||
selectedTool
|
||||
} = useAppStore();
|
||||
|
||||
const { getBlob } = useAppStore.getState();
|
||||
|
||||
const [previewModal, setPreviewModal] = React.useState<{
|
||||
open: boolean;
|
||||
imageUrl: string;
|
||||
@@ -31,6 +33,9 @@ export const HistoryPanel: React.FC = () => {
|
||||
description: ''
|
||||
});
|
||||
|
||||
// 存储从Blob URL解码的图像数据
|
||||
const [decodedImages, setDecodedImages] = useState<Record<string, string>>({});
|
||||
|
||||
const generations = currentProject?.generations || [];
|
||||
const edits = currentProject?.edits || [];
|
||||
|
||||
@@ -49,6 +54,57 @@ export const HistoryPanel: React.FC = () => {
|
||||
}
|
||||
}, [canvasImage]);
|
||||
|
||||
// 当项目变化时,解码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">
|
||||
@@ -88,85 +144,124 @@ export const HistoryPanel: React.FC = () => {
|
||||
|
||||
{/* 变体网格 */}
|
||||
<div className="mb-6 flex-shrink-0">
|
||||
<h4 className="text-xs font-medium text-gray-400 mb-3">当前变体</h4>
|
||||
<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">
|
||||
{generations.length + edits.length}/10
|
||||
</span>
|
||||
</div>
|
||||
{generations.length === 0 && edits.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-2 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 max-h-80 overflow-y-auto">
|
||||
{/* 显示生成记录 */}
|
||||
{generations.slice(-2).map((generation, index) => (
|
||||
{[...generations].sort((a, b) => b.timestamp - a.timestamp).slice(0, 10).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-700 hover:border-gray-600'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
)}
|
||||
onClick={() => {
|
||||
selectGeneration(generation.id);
|
||||
if (generation.outputAssets[0]) {
|
||||
setCanvasImage(generation.outputAssets[0].url);
|
||||
// 设置画布图像为第一个输出资产
|
||||
if (generation.outputAssetsBlobUrls && generation.outputAssetsBlobUrls.length > 0) {
|
||||
const blobUrl = generation.outputAssetsBlobUrls[0];
|
||||
const decodedUrl = decodedImages[blobUrl];
|
||||
if (decodedUrl) {
|
||||
setCanvasImage(decodedUrl);
|
||||
} else if (!blobUrl.startsWith('blob:')) {
|
||||
// 如果不是Blob URL,直接使用
|
||||
setCanvasImage(blobUrl);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{generation.outputAssets[0] ? (
|
||||
<>
|
||||
<img
|
||||
src={generation.outputAssets[0].url}
|
||||
alt="生成的变体"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</>
|
||||
{generation.outputAssetsBlobUrls && generation.outputAssetsBlobUrls.length > 0 ? (
|
||||
(() => {
|
||||
const blobUrl = generation.outputAssetsBlobUrls[0];
|
||||
const decodedUrl = decodedImages[blobUrl];
|
||||
if (decodedUrl) {
|
||||
return <img src={decodedUrl} alt="生成的变体" className="w-full h-full object-cover" />;
|
||||
} else if (!blobUrl.startsWith('blob:')) {
|
||||
return <img src={blobUrl} alt="生成的变体" className="w-full h-full object-cover" />;
|
||||
} else {
|
||||
return (
|
||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-yellow-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-800 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-yellow-400" />
|
||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||
<ImageIcon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 变体编号 */}
|
||||
<div className="absolute top-2 left-2 bg-gray-900/80 text-xs px-2 py-1 rounded">
|
||||
#{index + 1}
|
||||
<div className="absolute top-2 left-2 bg-gray-900/80 text-xs px-2 py-1 rounded text-white">
|
||||
G{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 显示编辑记录 */}
|
||||
{edits.slice(-2).map((edit, index) => (
|
||||
{[...edits].sort((a, b) => b.timestamp - a.timestamp).slice(0, 10).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-yellow-400'
|
||||
: 'border-gray-700 hover:border-gray-600'
|
||||
? 'border-purple-400'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (edit.outputAssets[0]) {
|
||||
setCanvasImage(edit.outputAssets[0].url);
|
||||
selectEdit(edit.id);
|
||||
selectGeneration(null);
|
||||
selectEdit(edit.id);
|
||||
selectGeneration(null);
|
||||
// 设置画布图像为第一个输出资产
|
||||
if (edit.outputAssetsBlobUrls && edit.outputAssetsBlobUrls.length > 0) {
|
||||
const blobUrl = edit.outputAssetsBlobUrls[0];
|
||||
const decodedUrl = decodedImages[blobUrl];
|
||||
if (decodedUrl) {
|
||||
setCanvasImage(decodedUrl);
|
||||
} else if (!blobUrl.startsWith('blob:')) {
|
||||
// 如果不是Blob URL,直接使用
|
||||
setCanvasImage(blobUrl);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{edit.outputAssets[0] ? (
|
||||
<img
|
||||
src={edit.outputAssets[0].url}
|
||||
alt="编辑的变体"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{edit.outputAssetsBlobUrls && edit.outputAssetsBlobUrls.length > 0 ? (
|
||||
(() => {
|
||||
const blobUrl = edit.outputAssetsBlobUrls[0];
|
||||
const decodedUrl = decodedImages[blobUrl];
|
||||
if (decodedUrl) {
|
||||
return <img src={decodedUrl} alt="编辑的变体" className="w-full h-full object-cover" />;
|
||||
} else if (!blobUrl.startsWith('blob:')) {
|
||||
return <img src={blobUrl} alt="编辑的变体" className="w-full h-full object-cover" />;
|
||||
} else {
|
||||
return (
|
||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-800 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-yellow-400" />
|
||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||
<ImageIcon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑标签 */}
|
||||
<div className="absolute top-2 left-2 bg-purple-900/80 text-xs px-2 py-1 rounded">
|
||||
编辑 #{index + 1}
|
||||
<div className="absolute top-2 left-2 bg-purple-900/80 text-xs px-2 py-1 rounded text-white">
|
||||
E{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -176,26 +271,26 @@ export const HistoryPanel: React.FC = () => {
|
||||
|
||||
{/* 当前图像信息 */}
|
||||
{(canvasImage || imageDimensions) && (
|
||||
<div className="mb-4 p-3 bg-gray-100 rounded-lg border border-gray-300">
|
||||
<h4 className="text-xs font-medium text-gray-400 mb-2">当前图像</h4>
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
<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-300">{imageDimensions.width} × {imageDimensions.height}</span>
|
||||
<span className="text-gray-800">{imageDimensions.width} × {imageDimensions.height}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span>模式:</span>
|
||||
<span className="text-gray-300 capitalize">{selectedTool}</span>
|
||||
<span className="text-gray-800 capitalize">{selectedTool}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成详情 */}
|
||||
<div className="mb-6 p-4 bg-gray-100 rounded-lg border border-gray-300 flex-1 overflow-y-auto min-h-0">
|
||||
<h4 className="text-xs font-medium text-gray-400 mb-2">生成详情</h4>
|
||||
<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 = generations.find(g => g.id === selectedGenerationId);
|
||||
const selectedEdit = edits.find(e => e.id === selectedEditId);
|
||||
@@ -203,10 +298,10 @@ export const HistoryPanel: React.FC = () => {
|
||||
if (gen) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 text-xs text-gray-500">
|
||||
<div className="space-y-2 text-xs text-gray-600">
|
||||
<div>
|
||||
<span className="text-gray-400">提示:</span>
|
||||
<p className="text-gray-300 mt-1">{gen.prompt}</p>
|
||||
<span className="text-gray-500">提示:</span>
|
||||
<p className="text-gray-800 mt-1">{gen.prompt}</p>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>模型:</span>
|
||||
@@ -218,37 +313,18 @@ export const HistoryPanel: React.FC = () => {
|
||||
<span>{gen.parameters.seed}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span>时间:</span>
|
||||
<span>{new Date(gen.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 参考图像 */}
|
||||
{/* 参考图像信息 */}
|
||||
{gen.sourceAssets.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-gray-400 mb-2">参考图像</h5>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{gen.sourceAssets.map((asset, index) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
onClick={() => setPreviewModal({
|
||||
open: true,
|
||||
imageUrl: asset.url,
|
||||
title: `参考图像 ${index + 1}`,
|
||||
description: '此参考图像用于指导生成'
|
||||
})}
|
||||
className="relative aspect-square rounded border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
src={asset.url}
|
||||
alt={`参考 ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<ImageIcon className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="absolute bottom-1 left-1 bg-gray-900/80 text-xs px-1 py-0.5 rounded text-gray-300">
|
||||
参考 {index + 1}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<h5 className="text-xs font-medium text-gray-500 mb-2">参考图像</h5>
|
||||
<div className="text-xs text-gray-600">
|
||||
{gen.sourceAssets.length} 个参考图像
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -258,10 +334,10 @@ export const HistoryPanel: React.FC = () => {
|
||||
const parentGen = generations.find(g => g.id === selectedEdit.parentGenerationId);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 text-xs text-gray-500">
|
||||
<div className="space-y-2 text-xs text-gray-600">
|
||||
<div>
|
||||
<span className="text-gray-400">编辑指令:</span>
|
||||
<p className="text-gray-300 mt-1">{selectedEdit.instruction}</p>
|
||||
<span className="text-gray-500">编辑指令:</span>
|
||||
<p className="text-gray-800 mt-1">{selectedEdit.instruction}</p>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>类型:</span>
|
||||
@@ -269,12 +345,12 @@ export const HistoryPanel: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>创建时间:</span>
|
||||
<span>{new Date(selectedEdit.timestamp).toLocaleTimeString()}</span>
|
||||
<span>{new Date(selectedEdit.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
{selectedEdit.maskAssetId && (
|
||||
<div className="flex justify-between">
|
||||
<span>遮罩:</span>
|
||||
<span className="text-purple-400">已应用</span>
|
||||
<span className="text-purple-600">已应用</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -282,53 +358,10 @@ export const HistoryPanel: React.FC = () => {
|
||||
{/* 原始生成参考 */}
|
||||
{parentGen && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-gray-400 mb-2">原始图像</h5>
|
||||
<button
|
||||
onClick={() => setPreviewModal({
|
||||
open: true,
|
||||
imageUrl: parentGen.outputAssets[0]?.url || '',
|
||||
title: '原始图像',
|
||||
description: '被编辑的基础图像'
|
||||
})}
|
||||
className="relative aspect-square w-16 rounded border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
src={parentGen.outputAssets[0]?.url}
|
||||
alt="原始"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<ImageIcon className="h-3 w-3 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 遮罩可视化 */}
|
||||
{selectedEdit.maskReferenceAsset && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-gray-400 mb-2">遮罩参考</h5>
|
||||
<button
|
||||
onClick={() => setPreviewModal({
|
||||
open: true,
|
||||
imageUrl: selectedEdit.maskReferenceAsset!.url,
|
||||
title: '遮罩参考图像',
|
||||
description: '带有遮罩叠加的图像被发送到AI模型以指导编辑'
|
||||
})}
|
||||
className="relative aspect-square w-16 rounded border border-gray-700 hover:border-gray-600 transition-colors overflow-hidden group"
|
||||
>
|
||||
<img
|
||||
src={selectedEdit.maskReferenceAsset.url}
|
||||
alt="遮罩参考"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
||||
<ImageIcon className="h-3 w-3 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="absolute bottom-1 left-1 bg-purple-900/80 text-xs px-1 py-0.5 rounded text-purple-300">
|
||||
遮罩
|
||||
</div>
|
||||
</button>
|
||||
<h5 className="text-xs font-medium text-gray-500 mb-2">原始生成</h5>
|
||||
<div className="text-xs text-gray-600">
|
||||
基于: G{generations.length - generations.indexOf(parentGen)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -336,7 +369,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
} else {
|
||||
return (
|
||||
<div className="space-y-2 text-xs text-gray-500">
|
||||
<p className="text-gray-400">选择一个生成记录或编辑记录以查看详细信息</p>
|
||||
<p className="text-gray-500">选择一个生成记录或编辑记录以查看详细信息</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -354,8 +387,8 @@ export const HistoryPanel: React.FC = () => {
|
||||
let imageUrl: string | null = null;
|
||||
|
||||
if (selectedGenerationId) {
|
||||
const gen = generations.find(g => g.id === selectedGenerationId);
|
||||
imageUrl = gen?.outputAssets[0]?.url || null;
|
||||
const { canvasImage } = useAppStore.getState();
|
||||
imageUrl = canvasImage;
|
||||
} else {
|
||||
// 如果没有选择生成记录,尝试获取当前画布图像
|
||||
const { canvasImage } = useAppStore.getState();
|
||||
|
||||
Reference in New Issue
Block a user