You've already forked Nano-Banana-AI-Image-Editor
新增 历史记录搜索、筛选功能;
新增 历史记录悬浮大图功能; 优化 现在历史记录最多可以存储1000条; 优化 历史记录的存储形式改为了使用IndexedDB;
This commit is contained in:
@@ -4,6 +4,7 @@ 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 {
|
||||
@@ -36,6 +37,19 @@ export const HistoryPanel: React.FC = () => {
|
||||
// 存储从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 || [];
|
||||
|
||||
@@ -66,6 +80,78 @@ export const HistoryPanel: React.FC = () => {
|
||||
}
|
||||
}, [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 () => {
|
||||
@@ -138,20 +224,81 @@ export const HistoryPanel: React.FC = () => {
|
||||
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 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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="h-6 w-6"
|
||||
title="隐藏历史面板"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
{/* 变体网格 */}
|
||||
@@ -159,18 +306,18 @@ export const HistoryPanel: React.FC = () => {
|
||||
<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
|
||||
{filteredGenerations.length + filteredEdits.length}/1000
|
||||
</span>
|
||||
</div>
|
||||
{generations.length === 0 && edits.length === 0 ? (
|
||||
{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-2 gap-3 max-h-80 overflow-y-auto">
|
||||
<div className="grid grid-cols-3 gap-2 max-h-80 overflow-y-auto">
|
||||
{/* 显示生成记录 */}
|
||||
{[...generations].sort((a, b) => b.timestamp - a.timestamp).slice(0, 10).map((generation, index) => (
|
||||
{[...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp).slice(0, 50).map((generation, index) => (
|
||||
<div
|
||||
key={generation.id}
|
||||
className={cn(
|
||||
@@ -182,56 +329,94 @@ export const HistoryPanel: React.FC = () => {
|
||||
onClick={() => {
|
||||
selectGeneration(generation.id);
|
||||
// 设置画布图像为第一个输出资产
|
||||
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);
|
||||
if (generation.outputAssets && generation.outputAssets.length > 0) {
|
||||
const asset = generation.outputAssets[0];
|
||||
if (asset.url) {
|
||||
setCanvasImage(asset.url);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{generation.outputAssetsBlobUrls && generation.outputAssetsBlobUrls.length > 0 ? (
|
||||
(() => {
|
||||
// 首先尝试使用上传后的图片链接
|
||||
const uploadedUrl = getUploadedImageUrl(generation, 0);
|
||||
if (uploadedUrl) {
|
||||
return <img src={uploadedUrl} alt="生成的变体" className="w-full h-full object-cover" />;
|
||||
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});
|
||||
}
|
||||
|
||||
// 如果没有上传链接,则使用原来的Blob URL
|
||||
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" />;
|
||||
}
|
||||
}}
|
||||
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">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-yellow-400" />
|
||||
<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-8 w-8 text-gray-400" />
|
||||
<ImageIcon className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 变体编号 */}
|
||||
<div className="absolute top-2 left-2 bg-gray-900/80 text-xs px-2 py-1 rounded text-white">
|
||||
<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>
|
||||
))}
|
||||
|
||||
{/* 显示编辑记录 */}
|
||||
{[...edits].sort((a, b) => b.timestamp - a.timestamp).slice(0, 10).map((edit, index) => (
|
||||
{[...filteredEdits].sort((a, b) => b.timestamp - a.timestamp).slice(0, 50).map((edit, index) => (
|
||||
<div
|
||||
key={edit.id}
|
||||
className={cn(
|
||||
@@ -244,49 +429,87 @@ export const HistoryPanel: React.FC = () => {
|
||||
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);
|
||||
if (edit.outputAssets && edit.outputAssets.length > 0) {
|
||||
const asset = edit.outputAssets[0];
|
||||
if (asset.url) {
|
||||
setCanvasImage(asset.url);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{edit.outputAssetsBlobUrls && edit.outputAssetsBlobUrls.length > 0 ? (
|
||||
(() => {
|
||||
// 首先尝试使用上传后的图片链接
|
||||
const uploadedUrl = getUploadedImageUrl(edit, 0);
|
||||
if (uploadedUrl) {
|
||||
return <img src={uploadedUrl} alt="编辑的变体" className="w-full h-full object-cover" />;
|
||||
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});
|
||||
}
|
||||
|
||||
// 如果没有上传链接,则使用原来的Blob URL
|
||||
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" />;
|
||||
}
|
||||
}}
|
||||
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">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-400" />
|
||||
<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-8 w-8 text-gray-400" />
|
||||
<ImageIcon className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑标签 */}
|
||||
<div className="absolute top-2 left-2 bg-purple-900/80 text-xs px-2 py-1 rounded text-white">
|
||||
<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>
|
||||
@@ -318,8 +541,8 @@ export const HistoryPanel: React.FC = () => {
|
||||
<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);
|
||||
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 (
|
||||
@@ -375,7 +598,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 参考图像信息 */}
|
||||
{gen.sourceAssets.length > 0 && (
|
||||
{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">
|
||||
@@ -386,7 +609,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
} else if (selectedEdit) {
|
||||
const parentGen = generations.find(g => g.id === selectedEdit.parentGenerationId);
|
||||
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">
|
||||
@@ -444,7 +667,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
<div>
|
||||
<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)}
|
||||
基于: G{dbGenerations.findIndex(g => g.id === parentGen.id) + 1}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -520,6 +743,28 @@ export const HistoryPanel: React.FC = () => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user