新增 历史记录搜索、筛选功能;

新增 历史记录悬浮大图功能;
优化 现在历史记录最多可以存储1000条;
优化 历史记录的存储形式改为了使用IndexedDB;
This commit is contained in:
2025-09-15 22:19:24 +08:00
parent bda049fcd1
commit 7a5e5d77b0
4 changed files with 733 additions and 238 deletions

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cn } from './utils/cn'; import { cn } from './utils/cn';
import { Header } from './components/Header'; import { Header } from './components/Header';
@@ -8,6 +8,7 @@ import { HistoryPanel } from './components/HistoryPanel';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { useAppStore } from './store/useAppStore'; import { useAppStore } from './store/useAppStore';
import { ToastProvider } from './components/ToastContext'; import { ToastProvider } from './components/ToastContext';
import * as indexedDBService from './services/indexedDBService';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -23,6 +24,19 @@ function AppContent() {
const { showPromptPanel, setShowPromptPanel, showHistory, setShowHistory } = useAppStore(); const { showPromptPanel, setShowPromptPanel, showHistory, setShowHistory } = useAppStore();
// 在挂载时初始化IndexedDB
useEffect(() => {
const init = async () => {
try {
await indexedDBService.initDB();
} catch (err) {
console.error('初始化IndexedDB失败:', err);
}
};
init();
}, []);
// 在挂载时设置移动设备默认值 // 在挂载时设置移动设备默认值
React.useEffect(() => { React.useEffect(() => {
const checkMobile = () => { const checkMobile = () => {

View File

@@ -4,6 +4,7 @@ import { Button } from './ui/Button';
import { History, Download, Image as ImageIcon } from 'lucide-react'; import { History, Download, Image as ImageIcon } from 'lucide-react';
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
import { ImagePreviewModal } from './ImagePreviewModal'; import { ImagePreviewModal } from './ImagePreviewModal';
import * as indexedDBService from '../services/indexedDBService';
export const HistoryPanel: React.FC = () => { export const HistoryPanel: React.FC = () => {
const { const {
@@ -36,6 +37,19 @@ export const HistoryPanel: React.FC = () => {
// 存储从Blob URL解码的图像数据 // 存储从Blob URL解码的图像数据
const [decodedImages, setDecodedImages] = useState<Record<string, string>>({}); 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 generations = currentProject?.generations || [];
const edits = currentProject?.edits || []; const edits = currentProject?.edits || [];
@@ -66,6 +80,78 @@ export const HistoryPanel: React.FC = () => {
} }
}, [canvasImage]); }, [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图像 // 当项目变化时解码Blob图像
useEffect(() => { useEffect(() => {
const decodeBlobImages = async () => { const decodeBlobImages = async () => {
@@ -138,20 +224,81 @@ export const HistoryPanel: React.FC = () => {
return ( return (
<div className="w-80 bg-white border-l border-gray-200 p-6 flex flex-col h-full"> <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"> <div className="flex items-center space-x-2">
<History className="h-5 w-5 text-gray-400" /> <History className="h-5 w-5 text-gray-400" />
<h3 className="text-sm font-medium text-gray-300"></h3> <h3 className="text-sm font-medium text-gray-300"></h3>
</div> </div>
<Button <div className="flex space-x-1">
variant="ghost" <Button
size="icon" variant="ghost"
onClick={() => setShowHistory(!showHistory)} size="icon"
className="h-6 w-6" onClick={async () => {
title="隐藏历史面板" try {
> const allGenerations = await indexedDBService.getAllGenerations();
× const allEdits = await indexedDBService.getAllEdits();
</Button> 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>
{/* 变体网格 */} {/* 变体网格 */}
@@ -159,18 +306,18 @@ export const HistoryPanel: React.FC = () => {
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-medium text-gray-400"></h4> <h4 className="text-xs font-medium text-gray-400"></h4>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{generations.length + edits.length}/10 {filteredGenerations.length + filteredEdits.length}/1000
</span> </span>
</div> </div>
{generations.length === 0 && edits.length === 0 ? ( {filteredGenerations.length === 0 && filteredEdits.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="text-4xl mb-2">🖼</div> <div className="text-4xl mb-2">🖼</div>
<p className="text-sm text-gray-500"></p> <p className="text-sm text-gray-500"></p>
</div> </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 <div
key={generation.id} key={generation.id}
className={cn( className={cn(
@@ -182,56 +329,94 @@ export const HistoryPanel: React.FC = () => {
onClick={() => { onClick={() => {
selectGeneration(generation.id); selectGeneration(generation.id);
// 设置画布图像为第一个输出资产 // 设置画布图像为第一个输出资产
if (generation.outputAssetsBlobUrls && generation.outputAssetsBlobUrls.length > 0) { if (generation.outputAssets && generation.outputAssets.length > 0) {
const blobUrl = generation.outputAssetsBlobUrls[0]; const asset = generation.outputAssets[0];
const decodedUrl = decodedImages[blobUrl]; if (asset.url) {
if (decodedUrl) { setCanvasImage(asset.url);
setCanvasImage(decodedUrl);
} else if (!blobUrl.startsWith('blob:')) {
// 如果不是Blob URL直接使用
setCanvasImage(blobUrl);
} }
} }
}} }}
> onMouseEnter={(e) => {
{generation.outputAssetsBlobUrls && generation.outputAssetsBlobUrls.length > 0 ? ( if (generation.outputAssets && generation.outputAssets.length > 0) {
(() => { const asset = generation.outputAssets[0];
// 首先尝试使用上传后的图片链接 if (asset.url) {
const uploadedUrl = getUploadedImageUrl(generation, 0); setHoveredImage({
if (uploadedUrl) { url: asset.url,
return <img src={uploadedUrl} alt="生成的变体" className="w-full h-full object-cover" />; 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;
// 如果没有上传链接则使用原来的Blob URL let x = e.clientX + offsetX;
const blobUrl = generation.outputAssetsBlobUrls[0]; let y = e.clientY + offsetY;
const decodedUrl = decodedImages[blobUrl];
if (decodedUrl) { // 检查是否超出右边界
return <img src={decodedUrl} alt="生成的变体" className="w-full h-full object-cover" />; if (x + previewWidth > window.innerWidth) {
} else if (!blobUrl.startsWith('blob:')) { x = window.innerWidth - previewWidth - 10;
return <img src={blobUrl} alt="生成的变体" className="w-full h-full object-cover" />; }
// 检查是否超出下边界
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 { } else {
return ( return (
<div className="w-full h-full bg-gray-100 flex items-center justify-center"> <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>
); );
} }
})() })()
) : ( ) : (
<div className="w-full h-full bg-gray-100 flex items-center justify-center"> <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>
)} )}
{/* 变体编号 */} {/* 变体编号 */}
<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} G{index + 1}
</div> </div>
</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 <div
key={edit.id} key={edit.id}
className={cn( className={cn(
@@ -244,49 +429,87 @@ export const HistoryPanel: React.FC = () => {
selectEdit(edit.id); selectEdit(edit.id);
selectGeneration(null); selectGeneration(null);
// 设置画布图像为第一个输出资产 // 设置画布图像为第一个输出资产
if (edit.outputAssetsBlobUrls && edit.outputAssetsBlobUrls.length > 0) { if (edit.outputAssets && edit.outputAssets.length > 0) {
const blobUrl = edit.outputAssetsBlobUrls[0]; const asset = edit.outputAssets[0];
const decodedUrl = decodedImages[blobUrl]; if (asset.url) {
if (decodedUrl) { setCanvasImage(asset.url);
setCanvasImage(decodedUrl);
} else if (!blobUrl.startsWith('blob:')) {
// 如果不是Blob URL直接使用
setCanvasImage(blobUrl);
} }
} }
}} }}
> onMouseEnter={(e) => {
{edit.outputAssetsBlobUrls && edit.outputAssetsBlobUrls.length > 0 ? ( if (edit.outputAssets && edit.outputAssets.length > 0) {
(() => { const asset = edit.outputAssets[0];
// 首先尝试使用上传后的图片链接 if (asset.url) {
const uploadedUrl = getUploadedImageUrl(edit, 0); setHoveredImage({
if (uploadedUrl) { url: asset.url,
return <img src={uploadedUrl} alt="编辑的变体" className="w-full h-full object-cover" />; 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;
// 如果没有上传链接则使用原来的Blob URL let x = e.clientX + offsetX;
const blobUrl = edit.outputAssetsBlobUrls[0]; let y = e.clientY + offsetY;
const decodedUrl = decodedImages[blobUrl];
if (decodedUrl) { // 检查是否超出右边界
return <img src={decodedUrl} alt="编辑的变体" className="w-full h-full object-cover" />; if (x + previewWidth > window.innerWidth) {
} else if (!blobUrl.startsWith('blob:')) { x = window.innerWidth - previewWidth - 10;
return <img src={blobUrl} alt="编辑的变体" className="w-full h-full object-cover" />; }
// 检查是否超出下边界
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 { } else {
return ( return (
<div className="w-full h-full bg-gray-100 flex items-center justify-center"> <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>
); );
} }
})() })()
) : ( ) : (
<div className="w-full h-full bg-gray-100 flex items-center justify-center"> <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>
)} )}
{/* 编辑标签 */} {/* 编辑标签 */}
<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} E{index + 1}
</div> </div>
</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"> <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> <h4 className="text-xs font-medium text-gray-500 mb-2"></h4>
{(() => { {(() => {
const gen = generations.find(g => g.id === selectedGenerationId); const gen = filteredGenerations.find(g => g.id === selectedGenerationId) || dbGenerations.find(g => g.id === selectedGenerationId);
const selectedEdit = edits.find(e => e.id === selectedEditId); const selectedEdit = filteredEdits.find(e => e.id === selectedEditId) || dbEdits.find(e => e.id === selectedEditId);
if (gen) { if (gen) {
return ( return (
@@ -375,7 +598,7 @@ export const HistoryPanel: React.FC = () => {
)} )}
{/* 参考图像信息 */} {/* 参考图像信息 */}
{gen.sourceAssets.length > 0 && ( {gen.sourceAssets && gen.sourceAssets.length > 0 && (
<div> <div>
<h5 className="text-xs font-medium text-gray-500 mb-2"></h5> <h5 className="text-xs font-medium text-gray-500 mb-2"></h5>
<div className="text-xs text-gray-600"> <div className="text-xs text-gray-600">
@@ -386,7 +609,7 @@ export const HistoryPanel: React.FC = () => {
</div> </div>
); );
} else if (selectedEdit) { } else if (selectedEdit) {
const parentGen = generations.find(g => g.id === selectedEdit.parentGenerationId); const parentGen = dbGenerations.find(g => g.id === selectedEdit.parentGenerationId);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2 text-xs text-gray-600"> <div className="space-y-2 text-xs text-gray-600">
@@ -444,7 +667,7 @@ export const HistoryPanel: React.FC = () => {
<div> <div>
<h5 className="text-xs font-medium text-gray-500 mb-2"></h5> <h5 className="text-xs font-medium text-gray-500 mb-2"></h5>
<div className="text-xs text-gray-600"> <div className="text-xs text-gray-600">
基于: G{generations.length - generations.indexOf(parentGen)} 基于: G{dbGenerations.findIndex(g => g.id === parentGen.id) + 1}
</div> </div>
</div> </div>
)} )}
@@ -520,6 +743,28 @@ export const HistoryPanel: React.FC = () => {
title={previewModal.title} title={previewModal.title}
description={previewModal.description} 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> </div>
); );
}; };

View File

@@ -0,0 +1,208 @@
import { Generation, Edit } from '../types';
// 数据库配置
const DB_NAME = 'NanoBananaDB';
const DB_VERSION = 1;
const GENERATIONS_STORE = 'generations';
const EDITS_STORE = 'edits';
// IndexedDB实例
let db: IDBDatabase | null = null;
/**
* 初始化数据库
*/
export const initDB = (): Promise<void> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
console.error('数据库打开失败:', request.error);
reject(request.error);
};
request.onsuccess = () => {
db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// 创建生成记录存储
if (!db.objectStoreNames.contains(GENERATIONS_STORE)) {
const genStore = db.createObjectStore(GENERATIONS_STORE, { keyPath: 'id' });
genStore.createIndex('timestamp', 'timestamp', { unique: false });
}
// 创建编辑记录存储
if (!db.objectStoreNames.contains(EDITS_STORE)) {
const editStore = db.createObjectStore(EDITS_STORE, { keyPath: 'id' });
editStore.createIndex('timestamp', 'timestamp', { unique: false });
editStore.createIndex('parentGenerationId', 'parentGenerationId', { unique: false });
}
};
});
};
/**
* 获取数据库实例
*/
const getDB = (): IDBDatabase => {
if (!db) {
throw new Error('数据库未初始化');
}
return db;
};
/**
* 添加生成记录
*/
export const addGeneration = async (generation: Generation): Promise<void> => {
const db = getDB();
const transaction = db.transaction([GENERATIONS_STORE], 'readwrite');
const store = transaction.objectStore(GENERATIONS_STORE);
return new Promise((resolve, reject) => {
const request = store.add(generation);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
};
/**
* 添加编辑记录
*/
export const addEdit = async (edit: Edit): Promise<void> => {
const db = getDB();
const transaction = db.transaction([EDITS_STORE], 'readwrite');
const store = transaction.objectStore(EDITS_STORE);
return new Promise((resolve, reject) => {
const request = store.add(edit);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
};
/**
* 获取所有生成记录(按时间倒序)
*/
export const getAllGenerations = async (): Promise<Generation[]> => {
const db = getDB();
const transaction = db.transaction([GENERATIONS_STORE], 'readonly');
const store = transaction.objectStore(GENERATIONS_STORE);
const index = store.index('timestamp');
return new Promise((resolve, reject) => {
const request = index.getAll();
request.onsuccess = () => {
// 按时间倒序排列
const generations = request.result.sort((a, b) => b.timestamp - a.timestamp);
resolve(generations);
};
request.onerror = () => reject(request.error);
});
};
/**
* 获取所有编辑记录(按时间倒序)
*/
export const getAllEdits = async (): Promise<Edit[]> => {
const db = getDB();
const transaction = db.transaction([EDITS_STORE], 'readonly');
const store = transaction.objectStore(EDITS_STORE);
const index = store.index('timestamp');
return new Promise((resolve, reject) => {
const request = index.getAll();
request.onsuccess = () => {
// 按时间倒序排列
const edits = request.result.sort((a, b) => b.timestamp - a.timestamp);
resolve(edits);
};
request.onerror = () => reject(request.error);
});
};
/**
* 根据父生成ID获取编辑记录
*/
export const getEditsByParentGenerationId = async (parentGenerationId: string): Promise<Edit[]> => {
const db = getDB();
const transaction = db.transaction([EDITS_STORE], 'readonly');
const store = transaction.objectStore(EDITS_STORE);
const index = store.index('parentGenerationId');
return new Promise((resolve, reject) => {
const request = index.getAll(IDBKeyRange.only(parentGenerationId));
request.onsuccess = () => {
// 按时间倒序排列
const edits = request.result.sort((a, b) => b.timestamp - a.timestamp);
resolve(edits);
};
request.onerror = () => reject(request.error);
});
};
/**
* 删除最旧的记录以保持限制
*/
export const cleanupOldRecords = async (limit: number = 1000): Promise<void> => {
const db = getDB();
// 清理生成记录
const genTransaction = db.transaction([GENERATIONS_STORE], 'readwrite');
const genStore = genTransaction.objectStore(GENERATIONS_STORE);
const genIndex = genStore.index('timestamp');
// 清理编辑记录
const editTransaction = db.transaction([EDITS_STORE], 'readwrite');
const editStore = editTransaction.objectStore(EDITS_STORE);
const editIndex = editStore.index('timestamp');
// 获取所有记录并按时间排序
const allGenerations = await getAllGenerations();
const allEdits = await getAllEdits();
// 计算需要删除的记录数量
if (allGenerations.length > limit) {
const toDelete = allGenerations.slice(limit);
for (const gen of toDelete) {
genStore.delete(gen.id);
}
}
if (allEdits.length > limit) {
const toDelete = allEdits.slice(limit);
for (const edit of toDelete) {
editStore.delete(edit.id);
}
}
};
/**
* 清空所有记录
*/
export const clearAllRecords = async (): Promise<void> => {
const db = getDB();
const genTransaction = db.transaction([GENERATIONS_STORE], 'readwrite');
const genStore = genTransaction.objectStore(GENERATIONS_STORE);
const editTransaction = db.transaction([EDITS_STORE], 'readwrite');
const editStore = editTransaction.objectStore(EDITS_STORE);
return Promise.all([
new Promise<void>((resolve, reject) => {
const request = genStore.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
}),
new Promise<void>((resolve, reject) => {
const request = editStore.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
})
]).then(() => undefined);
};

View File

@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware'; import { devtools, persist } from 'zustand/middleware';
import { Project, Generation, Edit, SegmentationMask, BrushStroke, UploadResult } from '../types'; import { Project, Generation, Edit, SegmentationMask, BrushStroke, UploadResult } from '../types';
import { generateId } from '../utils/imageUtils'; import { generateId } from '../utils/imageUtils';
import * as indexedDBService from '../services/indexedDBService';
// 定义不包含图像数据的轻量级项目结构 // 定义不包含图像数据的轻量级项目结构
interface LightweightProject { interface LightweightProject {
@@ -204,29 +205,45 @@ export const useAppStore = create<AppState>()(
return get().blobStore.get(url); return get().blobStore.get(url);
}, },
addGeneration: (generation) => set((state) => { addGeneration: (generation) => {
// 将base64图像数据转换为Blob并存储 // 保存到IndexedDB
const sourceAssets = generation.sourceAssets.map(asset => { indexedDBService.addGeneration(generation).catch(err => {
if (asset.url.startsWith('data:')) { console.error('保存生成记录到IndexedDB失败:', err);
// 从base64创建Blob });
const base64 = asset.url.split(',')[1];
const byteString = atob(base64); set((state) => {
const mimeString = asset.url.split(',')[0].split(':')[1].split(';')[0]; // 将base64图像数据转换为Blob并存储
const ab = new ArrayBuffer(byteString.length); const sourceAssets = generation.sourceAssets.map(asset => {
const ia = new Uint8Array(ab); if (asset.url.startsWith('data:')) {
for (let i = 0; i < byteString.length; i++) { // 从base64创建Blob
ia[i] = byteString.charCodeAt(i); const base64 = asset.url.split(',')[1];
const byteString = atob(base64);
const mimeString = asset.url.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString });
const blobUrl = URL.createObjectURL(blob);
// 存储Blob对象
set((innerState) => {
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.set(blobUrl, blob);
return { blobStore: newBlobStore };
});
return {
id: asset.id,
type: asset.type,
mime: asset.mime,
width: asset.width,
height: asset.height,
checksum: asset.checksum,
blobUrl
};
} }
const blob = new Blob([ab], { type: mimeString });
const blobUrl = URL.createObjectURL(blob);
// 存储Blob对象
set((innerState) => {
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.set(blobUrl, blob);
return { blobStore: newBlobStore };
});
return { return {
id: asset.id, id: asset.id,
type: asset.type, type: asset.type,
@@ -234,164 +251,170 @@ export const useAppStore = create<AppState>()(
width: asset.width, width: asset.width,
height: asset.height, height: asset.height,
checksum: asset.checksum, checksum: asset.checksum,
blobUrl blobUrl: asset.url
}; };
});
// 将输出资产转换为Blob URL
const outputAssetsBlobUrls = generation.outputAssets.map(asset => {
if (asset.url.startsWith('data:')) {
// 从base64创建Blob
const base64 = asset.url.split(',')[1];
const byteString = atob(base64);
const mimeString = asset.url.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString });
const blobUrl = URL.createObjectURL(blob);
// 存储Blob对象
set((innerState) => {
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.set(blobUrl, blob);
return { blobStore: newBlobStore };
});
return blobUrl;
}
return asset.url;
});
// 创建轻量级生成记录
const lightweightGeneration = {
id: generation.id,
prompt: generation.prompt,
parameters: generation.parameters,
sourceAssets,
outputAssetsBlobUrls,
modelVersion: generation.modelVersion,
timestamp: generation.timestamp,
uploadResults: generation.uploadResults
};
const updatedProject = state.currentProject ? {
...state.currentProject,
generations: [...state.currentProject.generations, lightweightGeneration],
updatedAt: Date.now()
} : {
// 如果没有项目,创建一个新项目包含此生成记录
id: generateId(),
title: '未命名项目',
generations: [lightweightGeneration],
edits: [],
createdAt: Date.now(),
updatedAt: Date.now()
};
// 清理旧记录以保持在限制内现在限制为1000条
if (updatedProject.generations.length > 1000) {
updatedProject.generations.splice(0, updatedProject.generations.length - 1000);
// 同时清理IndexedDB中的旧记录
indexedDBService.cleanupOldRecords(1000).catch(err => {
console.error('清理IndexedDB旧记录失败:', err);
});
} }
return { return {
id: asset.id, currentProject: updatedProject
type: asset.type,
mime: asset.mime,
width: asset.width,
height: asset.height,
checksum: asset.checksum,
blobUrl: asset.url
}; };
}); });
},
// 将输出资产转换为Blob URL addEdit: (edit) => {
const outputAssetsBlobUrls = generation.outputAssets.map(asset => { // 保存到IndexedDB
if (asset.url.startsWith('data:')) { indexedDBService.addEdit(edit).catch(err => {
// 从base64创建Blob console.error('保存编辑记录到IndexedDB失败:', err);
const base64 = asset.url.split(',')[1]; });
set((state) => {
// 将遮罩参考资产转换为Blob URL如果存在
let maskReferenceAssetBlobUrl: string | undefined;
if (edit.maskReferenceAsset && edit.maskReferenceAsset.url.startsWith('data:')) {
const base64 = edit.maskReferenceAsset.url.split(',')[1];
const byteString = atob(base64); const byteString = atob(base64);
const mimeString = asset.url.split(',')[0].split(':')[1].split(';')[0]; const mimeString = edit.maskReferenceAsset.url.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length); const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab); const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) { for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i); ia[i] = byteString.charCodeAt(i);
} }
const blob = new Blob([ab], { type: mimeString }); const blob = new Blob([ab], { type: mimeString });
const blobUrl = URL.createObjectURL(blob); maskReferenceAssetBlobUrl = URL.createObjectURL(blob);
// 存储Blob对象 // 存储Blob对象
set((innerState) => { set((innerState) => {
const newBlobStore = new Map(innerState.blobStore); const newBlobStore = new Map(innerState.blobStore);
newBlobStore.set(blobUrl, blob); newBlobStore.set(maskReferenceAssetBlobUrl!, blob);
return { blobStore: newBlobStore }; return { blobStore: newBlobStore };
}); });
} else if (edit.maskReferenceAsset) {
return blobUrl; maskReferenceAssetBlobUrl = edit.maskReferenceAsset.url;
} }
return asset.url;
});
// 创建轻量级生成记录 // 将输出资产转换为Blob URL
const lightweightGeneration = { const outputAssetsBlobUrls = edit.outputAssets.map(asset => {
id: generation.id, if (asset.url.startsWith('data:')) {
prompt: generation.prompt, // 从base64创建Blob
parameters: generation.parameters, const base64 = asset.url.split(',')[1];
sourceAssets, const byteString = atob(base64);
outputAssetsBlobUrls, const mimeString = asset.url.split(',')[0].split(':')[1].split(';')[0];
modelVersion: generation.modelVersion, const ab = new ArrayBuffer(byteString.length);
timestamp: generation.timestamp, const ia = new Uint8Array(ab);
uploadResults: generation.uploadResults for (let i = 0; i < byteString.length; i++) {
}; ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString });
const blobUrl = URL.createObjectURL(blob);
const updatedProject = state.currentProject ? { // 存储Blob对象
...state.currentProject, set((innerState) => {
generations: [...state.currentProject.generations, lightweightGeneration], const newBlobStore = new Map(innerState.blobStore);
updatedAt: Date.now() newBlobStore.set(blobUrl, blob);
} : { return { blobStore: newBlobStore };
// 如果没有项目,创建一个新项目包含此生成记录 });
id: generateId(),
title: '未命名项目',
generations: [lightweightGeneration],
edits: [],
createdAt: Date.now(),
updatedAt: Date.now()
};
// 清理旧记录以保持在限制内 return blobUrl;
if (updatedProject.generations.length > 10) { }
updatedProject.generations.splice(0, updatedProject.generations.length - 10); return asset.url;
}
return {
currentProject: updatedProject
};
}),
addEdit: (edit) => set((state) => {
// 将遮罩参考资产转换为Blob URL如果存在
let maskReferenceAssetBlobUrl: string | undefined;
if (edit.maskReferenceAsset && edit.maskReferenceAsset.url.startsWith('data:')) {
const base64 = edit.maskReferenceAsset.url.split(',')[1];
const byteString = atob(base64);
const mimeString = edit.maskReferenceAsset.url.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString });
maskReferenceAssetBlobUrl = URL.createObjectURL(blob);
// 存储Blob对象
set((innerState) => {
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.set(maskReferenceAssetBlobUrl!, blob);
return { blobStore: newBlobStore };
}); });
} else if (edit.maskReferenceAsset) {
maskReferenceAssetBlobUrl = edit.maskReferenceAsset.url;
}
// 将输出资产转换为Blob URL // 创建轻量级编辑记录
const outputAssetsBlobUrls = edit.outputAssets.map(asset => { const lightweightEdit = {
if (asset.url.startsWith('data:')) { id: edit.id,
// 从base64创建Blob parentGenerationId: edit.parentGenerationId,
const base64 = asset.url.split(',')[1]; maskAssetId: edit.maskAssetId,
const byteString = atob(base64); maskReferenceAssetBlobUrl,
const mimeString = asset.url.split(',')[0].split(':')[1].split(';')[0]; instruction: edit.instruction,
const ab = new ArrayBuffer(byteString.length); outputAssetsBlobUrls,
const ia = new Uint8Array(ab); timestamp: edit.timestamp,
for (let i = 0; i < byteString.length; i++) { uploadResults: edit.uploadResults
ia[i] = byteString.charCodeAt(i); };
}
const blob = new Blob([ab], { type: mimeString });
const blobUrl = URL.createObjectURL(blob);
// 存储Blob对象 if (!state.currentProject) return {};
set((innerState) => {
const newBlobStore = new Map(innerState.blobStore); const updatedProject = {
newBlobStore.set(blobUrl, blob); ...state.currentProject,
return { blobStore: newBlobStore }; edits: [...state.currentProject.edits, lightweightEdit],
updatedAt: Date.now()
};
// 清理旧记录以保持在限制内现在限制为1000条
if (updatedProject.edits.length > 1000) {
updatedProject.edits.splice(0, updatedProject.edits.length - 1000);
// 同时清理IndexedDB中的旧记录
indexedDBService.cleanupOldRecords(1000).catch(err => {
console.error('清理IndexedDB旧记录失败:', err);
}); });
return blobUrl;
} }
return asset.url;
return {
currentProject: updatedProject
};
}); });
},
// 创建轻量级编辑记录
const lightweightEdit = {
id: edit.id,
parentGenerationId: edit.parentGenerationId,
maskAssetId: edit.maskAssetId,
maskReferenceAssetBlobUrl,
instruction: edit.instruction,
outputAssetsBlobUrls,
timestamp: edit.timestamp,
uploadResults: edit.uploadResults
};
if (!state.currentProject) return {};
const updatedProject = {
...state.currentProject,
edits: [...state.currentProject.edits, lightweightEdit],
updatedAt: Date.now()
};
// 清理旧记录以保持在限制内
if (updatedProject.edits.length > 10) {
updatedProject.edits.splice(0, updatedProject.edits.length - 10);
}
return {
currentProject: updatedProject
};
}),
selectGeneration: (id) => set({ selectedGenerationId: id }), selectGeneration: (id) => set({ selectedGenerationId: id }),
selectEdit: (id) => set({ selectedEditId: id }), selectEdit: (id) => set({ selectedEditId: id }),
@@ -401,23 +424,28 @@ export const useAppStore = create<AppState>()(
setSelectedTool: (tool) => set({ selectedTool: tool }), setSelectedTool: (tool) => set({ selectedTool: tool }),
// 清理旧的历史记录保留最多10条 // 清理旧的历史记录保留最多1000
cleanupOldHistory: () => set((state) => { cleanupOldHistory: () => set((state) => {
if (!state.currentProject) return {}; if (!state.currentProject) return {};
const generations = [...state.currentProject.generations]; const generations = [...state.currentProject.generations];
const edits = [...state.currentProject.edits]; const edits = [...state.currentProject.edits];
// 如果生成记录超过10条只保留最新的10条 // 如果生成记录超过1000只保留最新的1000
if (generations.length > 10) { if (generations.length > 1000) {
generations.splice(0, generations.length - 10); generations.splice(0, generations.length - 1000);
} }
// 如果编辑记录超过10条只保留最新的10条 // 如果编辑记录超过1000只保留最新的1000
if (edits.length > 10) { if (edits.length > 1000) {
edits.splice(0, edits.length - 10); edits.splice(0, edits.length - 1000);
} }
// 同时清理IndexedDB中的旧记录
indexedDBService.cleanupOldRecords(1000).catch(err => {
console.error('清理IndexedDB旧记录失败:', err);
});
return { return {
currentProject: { currentProject: {
...state.currentProject, ...state.currentProject,