新增 历史记录删除功能

This commit is contained in:
yuantao
2025-09-19 18:40:43 +08:00
parent eae15ced5a
commit 4b5b1a5eba
6 changed files with 261 additions and 47 deletions

View File

@@ -22,7 +22,7 @@ const queryClient = new QueryClient({
function AppContent() { function AppContent() {
useKeyboardShortcuts(); useKeyboardShortcuts();
const { showPromptPanel, setShowPromptPanel, setShowHistory } = useAppStore(); const { showPromptPanel, setShowPromptPanel, showHistory, setShowHistory } = useAppStore();
const [hoveredImage, setHoveredImage] = useState<{url: string, title: string, width?: number, height?: number} | null>(null); const [hoveredImage, setHoveredImage] = useState<{url: string, title: string, width?: number, height?: number} | null>(null);
const [previewPosition, setPreviewPosition] = useState<{x: number, y: number} | null>(null); const [previewPosition, setPreviewPosition] = useState<{x: number, y: number} | null>(null);
const [isPreviewVisible, setIsPreviewVisible] = useState(false); const [isPreviewVisible, setIsPreviewVisible] = useState(false);
@@ -95,8 +95,8 @@ function AppContent() {
</div> </div>
<div className="flex-1 flex overflow-hidden p-4 gap-4 relative"> <div className="flex-1 flex overflow-hidden p-4 gap-4 relative">
<div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out", !showPromptPanel && "w-8")}> <div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out overflow-hidden", showPromptPanel ? "w-72" : "w-8")}>
<div className="h-full card card-lg"> <div className={cn("h-full", showPromptPanel ? "card card-lg" : "")}>
<PromptComposer /> <PromptComposer />
</div> </div>
</div> </div>
@@ -105,8 +105,8 @@ function AppContent() {
<ImageCanvas /> <ImageCanvas />
</div> </div>
</div> </div>
<div className="flex-shrink-0"> <div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out overflow-hidden", showHistory ? "w-72" : "w-8")}>
<div className="h-full card card-lg"> <div className={cn("h-full", showHistory ? "card card-lg" : "")}>
<HistoryPanel setHoveredImage={setHoveredImage} setPreviewPosition={setPreviewPosition} /> <HistoryPanel setHoveredImage={setHoveredImage} setPreviewPosition={setPreviewPosition} />
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useAppStore } from '../store/useAppStore'; import { useAppStore } from '../store/useAppStore';
import { Button } from './ui/Button'; import { Button } from './ui/Button';
import { History, Download, Image as ImageIcon } from 'lucide-react'; import { History, Download, Trash2, 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'; import * as indexedDBService from '../services/indexedDBService';
@@ -23,7 +23,9 @@ export const HistoryPanel: React.FC<{
showHistory, showHistory,
setShowHistory, setShowHistory,
setCanvasImage, setCanvasImage,
selectedTool selectedTool,
removeGeneration,
removeEdit
} = useAppStore(); } = useAppStore();
const { getBlob } = useAppStore.getState(); const { getBlob } = useAppStore.getState();
@@ -46,6 +48,9 @@ export const HistoryPanel: React.FC<{
// 使用自定义hook获取IndexedDB记录 // 使用自定义hook获取IndexedDB记录
const { generations: dbGenerations, edits: dbEdits, loading, error, refresh } = useIndexedDBListener(); const { generations: dbGenerations, edits: dbEdits, loading, error, refresh } = useIndexedDBListener();
// 跟踪当前悬停的记录
const [hoveredRecord, setHoveredRecord] = useState<{type: 'generation' | 'edit', id: string} | null>(null);
// 筛选和搜索状态 // 筛选和搜索状态
const [startDate, setStartDate] = useState<string>(() => { const [startDate, setStartDate] = useState<string>(() => {
const today = new Date(); const today = new Date();
@@ -212,18 +217,47 @@ export const HistoryPanel: React.FC<{
decodeBlobImages(); decodeBlobImages();
}, [generations, edits, getBlob, decodedImages]); }, [generations, edits, getBlob, decodedImages]);
// 监听鼠标离开窗口事件,确保悬浮预览正确关闭
useEffect(() => {
const handleMouseLeave = (e: MouseEvent) => {
// 当鼠标离开浏览器窗口时,关闭悬浮预览
if (e.relatedTarget === null) {
setHoveredImage(null);
if (setPreviewPosition) {
setPreviewPosition(null);
}
}
};
const handleBlur = () => {
// 当窗口失去焦点时,关闭悬浮预览
setHoveredImage(null);
if (setPreviewPosition) {
setPreviewPosition(null);
}
};
window.addEventListener('mouseleave', handleMouseLeave);
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('mouseleave', handleMouseLeave);
window.removeEventListener('blur', handleBlur);
};
}, [setHoveredImage, setPreviewPosition]);
if (!showHistory) { if (!showHistory) {
return ( return (
<div className="w-8 bg-white flex flex-col items-center justify-center rounded-r-xl"> <div className="w-8 bg-white flex flex-col items-center justify-center rounded-r-xl overflow-hidden">
<button <button
onClick={() => setShowHistory(true)} 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" className="w-6 h-16 bg-gray-100 hover:bg-gray-200 rounded-l-lg flex items-center justify-center transition-all duration-300 ease-in-out group"
title="显示历史面板" title="显示历史面板"
> >
<div className="flex flex-col space-y-1"> <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 transition-colors duration-200"></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 transition-colors duration-200"></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 transition-colors duration-200"></div>
</div> </div>
</button> </button>
</div> </div>
@@ -522,6 +556,9 @@ export const HistoryPanel: React.FC<{
} }
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
// 设置当前悬停的记录
setHoveredRecord({type: 'generation', id: generation.id});
// 优先使用上传后的远程链接,如果没有则使用原始链接 // 优先使用上传后的远程链接,如果没有则使用原始链接
let imageUrl = getUploadedImageUrl(generation, 0); let imageUrl = getUploadedImageUrl(generation, 0);
if (!imageUrl && generation.outputAssets && generation.outputAssets.length > 0) { if (!imageUrl && generation.outputAssets && generation.outputAssets.length > 0) {
@@ -563,6 +600,9 @@ export const HistoryPanel: React.FC<{
// 不需要处理鼠标移动事件,因为预览现在在页面中心显示 // 不需要处理鼠标移动事件,因为预览现在在页面中心显示
}} }}
onMouseLeave={() => { onMouseLeave={() => {
// 清除当前悬停的记录
setHoveredRecord(null);
setHoveredImage(null); setHoveredImage(null);
if (setPreviewPosition) { if (setPreviewPosition) {
setPreviewPosition(null); setPreviewPosition(null);
@@ -589,6 +629,47 @@ export const HistoryPanel: React.FC<{
<div className="absolute top-1 left-1 bg-gray-900/80 text-xs px-1 py-0.5 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{globalIndex + 1} G{globalIndex + 1}
</div> </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();
// 下载图像
const imageUrl = getUploadedImageUrl(generation, 0) ||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
if (imageUrl) {
const link = document.createElement('a');
link.href = imageUrl;
link.download = `generation-G${globalIndex + 1}.png`;
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> </div>
); );
}); });
@@ -629,6 +710,9 @@ export const HistoryPanel: React.FC<{
} }
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
// 设置当前悬停的记录
setHoveredRecord({type: 'edit', id: edit.id});
// 优先使用上传后的远程链接,如果没有则使用原始链接 // 优先使用上传后的远程链接,如果没有则使用原始链接
let imageUrl = getUploadedImageUrl(edit, 0); let imageUrl = getUploadedImageUrl(edit, 0);
if (!imageUrl && edit.outputAssets && edit.outputAssets.length > 0) { if (!imageUrl && edit.outputAssets && edit.outputAssets.length > 0) {
@@ -671,6 +755,9 @@ export const HistoryPanel: React.FC<{
// 不需要处理鼠标移动事件,因为预览现在在页面中心显示 // 不需要处理鼠标移动事件,因为预览现在在页面中心显示
}} }}
onMouseLeave={() => { onMouseLeave={() => {
// 清除当前悬停的记录
setHoveredRecord(null);
setHoveredImage(null); setHoveredImage(null);
if (setPreviewPosition) { if (setPreviewPosition) {
setPreviewPosition(null); setPreviewPosition(null);
@@ -697,6 +784,47 @@ export const HistoryPanel: React.FC<{
<div className="absolute top-1 left-1 bg-purple-900/80 text-xs px-1 py-0.5 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{globalIndex + 1} E{globalIndex + 1}
</div> </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();
// 下载图像
const imageUrl = getUploadedImageUrl(edit, 0) ||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
if (imageUrl) {
const link = document.createElement('a');
link.href = imageUrl;
link.download = `edit-E${globalIndex + 1}.png`;
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>
); );
}); });
@@ -818,7 +946,8 @@ export const HistoryPanel: React.FC<{
const uploadedUrl = gen.uploadResults && gen.uploadResults[index + 1] && gen.uploadResults[index + 1].success const uploadedUrl = gen.uploadResults && gen.uploadResults[index + 1] && gen.uploadResults[index + 1].success
? `${gen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_30` ? `${gen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_30`
: null; : null;
const displayUrl = uploadedUrl || asset.url; // 对于Blob URL我们需要从decodedImages中获取解码后的图像数据
const displayUrl = uploadedUrl || decodedImages[asset.blobUrl] || asset.blobUrl || asset.url;
return ( return (
<div <div
@@ -926,7 +1055,8 @@ export const HistoryPanel: React.FC<{
const uploadedUrl = parentGen.uploadResults && parentGen.uploadResults[index + 1] && parentGen.uploadResults[index + 1].success const uploadedUrl = parentGen.uploadResults && parentGen.uploadResults[index + 1] && parentGen.uploadResults[index + 1].success
? `${parentGen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_30` ? `${parentGen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_30`
: null; : null;
const displayUrl = uploadedUrl || asset.url; // 对于Blob URL我们需要从decodedImages中获取解码后的图像数据
const displayUrl = uploadedUrl || decodedImages[asset.blobUrl] || asset.blobUrl || asset.url;
return ( return (
<div <div

View File

@@ -168,16 +168,16 @@ export const PromptComposer: React.FC = () => {
if (!showPromptPanel) { if (!showPromptPanel) {
return ( return (
<div className="w-8 bg-white flex flex-col items-center justify-center rounded-l-xl"> <div className="w-8 bg-white flex flex-col items-center justify-center rounded-l-xl overflow-hidden">
<button <button
onClick={() => setShowPromptPanel(true)} onClick={() => setShowPromptPanel(true)}
className="w-6 h-16 bg-gray-100 hover:bg-gray-200 rounded-r-lg flex items-center justify-center transition-all duration-300 ease-in-out group" className="w-6 h-16 bg-gray-100 hover:bg-gray-200 rounded-r-lg flex items-center justify-center transition-all duration-300 ease-in-out group"
title="显示提示面板" title="显示提示面板"
> >
<div className="flex flex-col space-y-1"> <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 transition-colors duration-200"></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 transition-colors duration-200"></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 transition-colors duration-200"></div>
</div> </div>
</button> </button>
</div> </div>
@@ -339,7 +339,7 @@ export const PromptComposer: React.FC = () => {
? '描述您想要创建的内容...' ? '描述您想要创建的内容...'
: '描述您想要的修改...' : '描述您想要的修改...'
} }
className="min-h-[120px] resize-none text-sm rounded-xl" className="min-h-[180px] resize-none text-sm rounded-xl"
/> />
{/* 常用提示词 */} {/* 常用提示词 */}

View File

@@ -171,7 +171,7 @@ export const useImageGeneration = () => {
id: generateId(), id: generateId(),
type: 'original' as const, type: 'original' as const,
url: blobUrl, // 存储Blob URL而不是base64 url: blobUrl, // 存储Blob URL而不是base64
mime: 'image/png', mime: blob.type || 'image/png',
width: 1024, width: 1024,
height: 1024, height: 1024,
checksum checksum

View File

@@ -90,34 +90,13 @@ async function getBlobFromUrl(blobUrl: string): Promise<Blob> {
const blob = useAppStore.getState().getBlob(blobUrl) const blob = useAppStore.getState().getBlob(blobUrl)
if (!blob) { if (!blob) {
// 如果AppStore中没有找到Blob尝试从URL获取 throw new Error('无法从AppStore获取BlobBlob可能已被清理');
console.warn('无法从AppStore获取Blob尝试从URL获取:', blobUrl)
try {
const response = await fetch(blobUrl)
if (!response.ok) {
throw new Error(`获取Blob失败: ${response.status} ${response.statusText}`)
}
return await response.blob()
} catch (error) {
console.error('从URL获取Blob失败:', error)
throw new Error('无法从Blob URL获取图像数据')
}
} }
return blob return blob;
} catch (error) { } catch (error) {
console.error('从AppStore获取Blob时出错:', error) console.error('从AppStore获取Blob时出错:', error);
// 如果导入AppStore失败直接尝试从URL获取 throw new Error('无法从Blob URL获取图像数据');
try {
const response = await fetch(blobUrl)
if (!response.ok) {
throw new Error(`获取Blob失败: ${response.status} ${response.statusText}`)
}
return await response.blob()
} catch (fetchError) {
console.error('从URL获取Blob失败:', fetchError)
throw new Error('无法从Blob URL获取图像数据')
}
} }
} }

View File

@@ -111,6 +111,8 @@ interface AppState {
addGeneration: (generation: Generation) => void; addGeneration: (generation: Generation) => void;
addEdit: (edit: Edit) => void; addEdit: (edit: Edit) => void;
removeGeneration: (id: string) => void;
removeEdit: (id: string) => void;
selectGeneration: (id: string | null) => void; selectGeneration: (id: string | null) => void;
selectEdit: (id: string | null) => void; selectEdit: (id: string | null) => void;
setShowHistory: (show: boolean) => void; setShowHistory: (show: boolean) => void;
@@ -259,6 +261,17 @@ export const useAppStore = create<AppState>()(
}; };
} else if (asset.url.startsWith('blob:')) { } else if (asset.url.startsWith('blob:')) {
// 如果已经是Blob URL直接使用 // 如果已经是Blob URL直接使用
// 同时确保存储在blobStore中
set((innerState) => {
const blob = innerState.blobStore.get(asset.url);
if (blob) {
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.set(asset.url, blob);
return { blobStore: newBlobStore };
}
return innerState;
});
return { return {
id: asset.id, id: asset.id,
type: asset.type, type: asset.type,
@@ -269,7 +282,7 @@ export const useAppStore = create<AppState>()(
blobUrl: asset.url blobUrl: asset.url
}; };
} }
// 对于其他URL类型创建一个新的Blob URL // 对于其他URL类型直接使用URL
return { return {
id: asset.id, id: asset.id,
type: asset.type, type: asset.type,
@@ -519,6 +532,98 @@ export const useAppStore = create<AppState>()(
setSelectedTool: (tool) => set({ selectedTool: tool }), setSelectedTool: (tool) => set({ selectedTool: tool }),
// 删除生成记录
removeGeneration: (id) => set((state) => {
if (!state.currentProject) return {};
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
const generationToRemove = state.currentProject.generations.find(gen => gen.id === id);
if (generationToRemove) {
// 收集要删除的生成记录中的Blob URLs
generationToRemove.sourceAssets.forEach(asset => {
if (asset.blobUrl.startsWith('blob:')) {
urlsToRevoke.push(asset.blobUrl);
}
});
generationToRemove.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
// 释放Blob URLs
if (urlsToRevoke.length > 0) {
set((innerState) => {
urlsToRevoke.forEach(url => {
URL.revokeObjectURL(url);
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.delete(url);
innerState = { ...innerState, blobStore: newBlobStore };
});
return innerState;
});
}
}
// 从项目中移除生成记录
const updatedProject = {
...state.currentProject,
generations: state.currentProject.generations.filter(gen => gen.id !== id),
updatedAt: Date.now()
};
return {
currentProject: updatedProject
};
}),
// 删除编辑记录
removeEdit: (id) => set((state) => {
if (!state.currentProject) return {};
// 收集需要释放的Blob URLs
const urlsToRevoke: string[] = [];
const editToRemove = state.currentProject.edits.find(edit => edit.id === id);
if (editToRemove) {
// 收集要删除的编辑记录中的Blob URLs
if (editToRemove.maskReferenceAssetBlobUrl && editToRemove.maskReferenceAssetBlobUrl.startsWith('blob:')) {
urlsToRevoke.push(editToRemove.maskReferenceAssetBlobUrl);
}
editToRemove.outputAssetsBlobUrls.forEach(url => {
if (url.startsWith('blob:')) {
urlsToRevoke.push(url);
}
});
// 释放Blob URLs
if (urlsToRevoke.length > 0) {
set((innerState) => {
urlsToRevoke.forEach(url => {
URL.revokeObjectURL(url);
const newBlobStore = new Map(innerState.blobStore);
newBlobStore.delete(url);
innerState = { ...innerState, blobStore: newBlobStore };
});
return innerState;
});
}
}
// 从项目中移除编辑记录
const updatedProject = {
...state.currentProject,
edits: state.currentProject.edits.filter(edit => edit.id !== id),
updatedAt: Date.now()
};
return {
currentProject: updatedProject
};
}),
// 清理旧的历史记录 // 清理旧的历史记录
cleanupOldHistory: () => set((state) => { cleanupOldHistory: () => set((state) => {
if (!state.currentProject) return {}; if (!state.currentProject) return {};