优化界面

This commit is contained in:
yuantao
2025-09-16 18:38:02 +08:00
parent e0600f5d50
commit 2345ed80f1
15 changed files with 725 additions and 266 deletions

View File

@@ -22,7 +22,7 @@ const queryClient = new QueryClient({
function AppContent() { function AppContent() {
useKeyboardShortcuts(); useKeyboardShortcuts();
const { showPromptPanel, setShowPromptPanel, showHistory, setShowHistory } = useAppStore(); const { showPromptPanel, setShowPromptPanel, setShowHistory } = useAppStore();
// 在挂载时初始化IndexedDB // 在挂载时初始化IndexedDB
useEffect(() => { useEffect(() => {
@@ -59,7 +59,7 @@ function AppContent() {
</div> </div>
<div className="flex-1 flex overflow-hidden p-4 gap-4"> <div className="flex-1 flex overflow-hidden p-4 gap-4">
<div className={cn("flex-shrink-0 transition-all duration-300", !showPromptPanel && "w-8")}> <div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out", !showPromptPanel && "w-8")}>
<div className="h-full card card-lg"> <div className="h-full card card-lg">
<PromptComposer /> <PromptComposer />
</div> </div>

View File

@@ -47,7 +47,7 @@ export const HistoryPanel: React.FC = () => {
const [searchTerm, setSearchTerm] = useState<string>(''); const [searchTerm, setSearchTerm] = useState<string>('');
// 悬浮预览状态 // 悬浮预览状态
const [hoveredImage, setHoveredImage] = useState<{url: string, title: string} | null>(null); const [hoveredImage, setHoveredImage] = useState<{url: string, title: string, width?: number, height?: number, size?: number} | null>(null);
const [previewPosition, setPreviewPosition] = useState<{x: number, y: number}>({x: 0, y: 0}); const [previewPosition, setPreviewPosition] = useState<{x: number, y: number}>({x: 0, y: 0});
const generations = currentProject?.generations || []; const generations = currentProject?.generations || [];
@@ -211,7 +211,7 @@ export const HistoryPanel: React.FC = () => {
} }
return ( return (
<div className="w-72 bg-white p-4 flex flex-col h-full"> <div className="w-72 bg-white p-4 flex flex-col h-full relative">
{/* 头部 */} {/* 头部 */}
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -298,7 +298,7 @@ export const HistoryPanel: React.FC = () => {
<p className="text-xs text-gray-400"></p> <p className="text-xs text-gray-400"></p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-3 gap-1.5 max-h-72 overflow-y-auto"> <div className="grid grid-cols-3 gap-1.5 max-h-72 relative">
{/* 显示生成记录 */} {/* 显示生成记录 */}
{[...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp).slice(0, 50).map((generation, index) => ( {[...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp).slice(0, 50).map((generation, index) => (
<div <div
@@ -320,16 +320,122 @@ export const HistoryPanel: React.FC = () => {
} }
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (generation.outputAssets && generation.outputAssets.length > 0) { // 优先使用上传后的远程链接,如果没有则使用原始链接
const asset = generation.outputAssets[0]; let imageUrl = getUploadedImageUrl(generation, 0);
if (asset.url) { if (!imageUrl && generation.outputAssets && generation.outputAssets.length > 0) {
imageUrl = generation.outputAssets[0].url;
}
if (imageUrl) {
// 创建图像对象以获取尺寸
const img = new Image();
img.onload = () => {
// 计算文件大小仅对base64数据
let size = 0;
if (imageUrl.startsWith('data:')) {
// 估算base64数据大小
const base64Data = imageUrl.split(',')[1];
size = Math.round((base64Data.length * 3) / 4);
}
setHoveredImage({ setHoveredImage({
url: asset.url, url: imageUrl,
title: `生成记录 G${index + 1}`, title: `生成记录 G${index + 1}`,
description: generation.prompt width: img.width,
height: img.height,
size: size
}); });
setPreviewPosition({x: e.clientX, y: e.clientY});
} // 计算预览位置,确保不超出屏幕边界
const previewWidth = 300;
const previewHeight = 300;
const offsetX = 10;
const offsetY = 10;
// 获取HistoryPanel的位置
const historyPanel = document.querySelector('.w-72.bg-white.p-4');
const panelRect = historyPanel ? historyPanel.getBoundingClientRect() : { left: 0, top: 0 };
// 计算相对于HistoryPanel的位置
let x = e.clientX - panelRect.left + offsetX;
let y = e.clientY - panelRect.top + 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;
}
// 添加额外的安全边界检查
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
setPreviewPosition({x, y});
};
img.onerror = (error) => {
console.error('图像加载失败:', error);
// 即使图像加载失败,也显示预览
setHoveredImage({
url: imageUrl,
title: `生成记录 G${index + 1}`,
width: 0,
height: 0,
size: 0
});
// 计算预览位置
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;
}
// 添加额外的安全边界检查
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
setPreviewPosition({x, y});
};
img.src = imageUrl;
} }
}} }}
onMouseMove={(e) => { onMouseMove={(e) => {
@@ -339,58 +445,62 @@ export const HistoryPanel: React.FC = () => {
const offsetX = 10; const offsetX = 10;
const offsetY = 10; const offsetY = 10;
let x = e.clientX + offsetX;
let y = e.clientY + offsetY;
// 检查是否超出右边界 // 获取HistoryPanel的位置
if (x + previewWidth > window.innerWidth) { const historyPanel = document.querySelector('.w-72.bg-white.p-4');
x = window.innerWidth - previewWidth - 10; const panelRect = historyPanel ? historyPanel.getBoundingClientRect() : { left: 0, top: 0 };
// 计算相对于HistoryPanel的位置
let x = e.clientX - panelRect.left + offsetX;
let y = e.clientY - panelRect.top + offsetY;
// 确保预览窗口不会超出右边界
if (x + previewWidth > (historyPanel ? historyPanel.clientWidth : window.innerWidth)) {
x = (historyPanel ? historyPanel.clientWidth : window.innerWidth) - previewWidth - 10;
} }
// 检查是否超出下边界 // 确保预览窗口不会超出下边界
if (y + previewHeight > window.innerHeight) { if (y + previewHeight > (historyPanel ? historyPanel.clientHeight : window.innerHeight)) {
y = window.innerHeight - previewHeight - 10; y = (historyPanel ? historyPanel.clientHeight : window.innerHeight) - previewHeight - 10;
} }
// 检查是否超出左边界 // 确保预览窗口不会超出左边界
if (x < 0) { if (x < 0) {
x = 10; x = 10;
} }
// 检查是否超出上边界 // 确保预览窗口不会超出上边界
if (y < 0) { if (y < 0) {
y = 10; y = 10;
} }
// 添加额外的安全边界检查
const maxWidth = historyPanel ? historyPanel.clientWidth : window.innerWidth;
const maxHeight = historyPanel ? historyPanel.clientHeight : window.innerHeight;
x = Math.max(10, Math.min(x, maxWidth - previewWidth - 10));
y = Math.max(10, Math.min(y, maxHeight - previewHeight - 10));
setPreviewPosition({x, y}); setPreviewPosition({x, y});
}} }}
onMouseLeave={() => { onMouseLeave={() => {
setHoveredImage(null); setHoveredImage(null);
}} }}
> >
{generation.outputAssets && generation.outputAssets.length > 0 ? ( {(() => {
(() => { // 优先使用上传后的远程链接,如果没有则使用原始链接
const asset = generation.outputAssets[0]; const imageUrl = getUploadedImageUrl(generation, 0) ||
if (asset.url) { (generation.outputAssets && generation.outputAssets.length > 0 ? generation.outputAssets[0].url : null);
// 如果是base64数据URL直接显示
if (asset.url.startsWith('data:')) { if (imageUrl) {
return <img src={asset.url} alt="生成的变体" className="w-full h-full object-cover" />; return <img src={imageUrl} alt="生成的变体" className="w-full h-full object-cover" />;
} } else {
// 如果是普通URL直接显示 return (
return <img src={asset.url} alt="生成的变体" className="w-full h-full object-cover" />; <div className="w-full h-full bg-gray-100 flex items-center justify-center">
} else { <ImageIcon className="h-4 w-4 text-gray-400" />
return ( </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="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"> <div className="absolute top-1 left-1 bg-gray-900/80 text-xs px-1 py-0.5 rounded text-white">
@@ -421,16 +531,120 @@ export const HistoryPanel: React.FC = () => {
} }
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (edit.outputAssets && edit.outputAssets.length > 0) { // 优先使用上传后的远程链接,如果没有则使用原始链接
const asset = edit.outputAssets[0]; let imageUrl = getUploadedImageUrl(edit, 0);
if (asset.url) { if (!imageUrl && edit.outputAssets && edit.outputAssets.length > 0) {
imageUrl = edit.outputAssets[0].url;
}
if (imageUrl) {
// 创建图像对象以获取尺寸
const img = new Image();
img.onload = () => {
// 计算文件大小仅对base64数据
let size = 0;
if (imageUrl.startsWith('data:')) {
// 估算base64数据大小
const base64Data = imageUrl.split(',')[1];
size = Math.round((base64Data.length * 3) / 4);
}
setHoveredImage({ setHoveredImage({
url: asset.url, url: imageUrl,
title: `编辑记录 E${index + 1}`, title: `编辑记录 E${index + 1}`,
description: edit.instruction width: img.width,
height: img.height,
size: size
}); });
setPreviewPosition({x: e.clientX, y: e.clientY});
} // 计算预览位置,确保不超出屏幕边界
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;
}
// 添加额外的安全边界检查
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
setPreviewPosition({x, y});
};
img.onerror = (error) => {
console.error('图像加载失败:', error);
// 即使图像加载失败,也显示预览
setHoveredImage({
url: imageUrl,
title: `编辑记录 E${index + 1}`,
width: 0,
height: 0,
size: 0
});
// 计算预览位置
const previewWidth = 300;
const previewHeight = 300;
const offsetX = 10;
const offsetY = 10;
// 获取HistoryPanel的位置信息
const historyPanel = e.currentTarget.closest('.w-72');
const panelRect = historyPanel ? historyPanel.getBoundingClientRect() : { left: 0, top: 0 };
// 计算相对于整个视窗的位置
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;
}
// 添加额外的安全边界检查
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
setPreviewPosition({x, y});
};
img.src = imageUrl;
} }
}} }}
onMouseMove={(e) => { onMouseMove={(e) => {
@@ -443,55 +657,51 @@ export const HistoryPanel: React.FC = () => {
let x = e.clientX + offsetX; let x = e.clientX + offsetX;
let y = e.clientY + offsetY; let y = e.clientY + offsetY;
// 检查是否超出右边界 // 确保预览窗口不会超出右边界
if (x + previewWidth > window.innerWidth) { if (x + previewWidth > window.innerWidth) {
x = window.innerWidth - previewWidth - 10; x = window.innerWidth - previewWidth - 10;
} }
// 检查是否超出下边界 // 确保预览窗口不会超出下边界
if (y + previewHeight > window.innerHeight) { if (y + previewHeight > window.innerHeight) {
y = window.innerHeight - previewHeight - 10; y = window.innerHeight - previewHeight - 10;
} }
// 检查是否超出左边界 // 确保预览窗口不会超出左边界
if (x < 0) { if (x < 0) {
x = 10; x = 10;
} }
// 检查是否超出上边界 // 确保预览窗口不会超出上边界
if (y < 0) { if (y < 0) {
y = 10; y = 10;
} }
// 添加额外的安全边界检查
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
setPreviewPosition({x, y}); setPreviewPosition({x, y});
}} }}
onMouseLeave={() => { onMouseLeave={() => {
setHoveredImage(null); setHoveredImage(null);
}} }}
> >
{edit.outputAssets && edit.outputAssets.length > 0 ? ( {(() => {
(() => { // 优先使用上传后的远程链接,如果没有则使用原始链接
const asset = edit.outputAssets[0]; const imageUrl = getUploadedImageUrl(edit, 0) ||
if (asset.url) { (edit.outputAssets && edit.outputAssets.length > 0 ? edit.outputAssets[0].url : null);
// 如果是base64数据URL直接显示
if (asset.url.startsWith('data:')) { if (imageUrl) {
return <img src={asset.url} alt="编辑的变体" className="w-full h-full object-cover" />; return <img src={imageUrl} alt="编辑的变体" className="w-full h-full object-cover" />;
} } else {
// 如果是普通URL直接显示 return (
return <img src={asset.url} alt="编辑的变体" className="w-full h-full object-cover" />; <div className="w-full h-full bg-gray-100 flex items-center justify-center">
} else { <ImageIcon className="h-4 w-4 text-gray-400" />
return ( </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="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"> <div className="absolute top-1 left-1 bg-purple-900/80 text-xs px-1 py-0.5 rounded text-white">
@@ -573,27 +783,36 @@ export const HistoryPanel: React.FC = () => {
{gen.sourceAssets.length} {gen.sourceAssets.length}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{gen.sourceAssets.slice(0, 4).map((asset: any, index: number) => ( {gen.sourceAssets.slice(0, 4).map((asset: any, index: number) => {
<div // 获取上传后的远程链接(如果存在)
key={asset.id} // 参考图像在uploadResults中从索引1开始索引0是生成的图像
className="relative w-16 h-16 rounded border overflow-hidden cursor-pointer hover:ring-2 hover:ring-yellow-400" const uploadedUrl = gen.uploadResults && gen.uploadResults[index + 1] && gen.uploadResults[index + 1].success
onClick={(e) => { ? `${gen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_50`
e.stopPropagation(); : null;
setPreviewModal({ const displayUrl = uploadedUrl || asset.url;
open: true,
imageUrl: asset.url, return (
title: `参考图像 ${index + 1}`, <div
description: `${asset.width} × ${asset.height}` key={asset.id}
}); className="relative w-16 h-16 rounded border overflow-hidden cursor-pointer hover:ring-2 hover:ring-yellow-400"
}} onClick={(e) => {
> e.stopPropagation();
<img setPreviewModal({
src={asset.url} open: true,
alt={`参考图像 ${index + 1}`} imageUrl: displayUrl,
className="w-full h-full object-cover" title: `参考图像 ${index + 1}`,
/> description: `${asset.width} × ${asset.height}`
</div> });
))} }}
>
<img
src={displayUrl}
alt={`参考图像 ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
);
})}
{gen.sourceAssets.length > 4 && ( {gen.sourceAssets.length > 4 && (
<div className="w-16 h-16 rounded border flex items-center justify-center bg-gray-100 text-xs text-gray-500"> <div className="w-16 h-16 rounded border flex items-center justify-center bg-gray-100 text-xs text-gray-500">
+{gen.sourceAssets.length - 4} +{gen.sourceAssets.length - 4}
@@ -672,27 +891,36 @@ export const HistoryPanel: React.FC = () => {
: :
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{parentGen.sourceAssets.slice(0, 4).map((asset: any, index: number) => ( {parentGen.sourceAssets.slice(0, 4).map((asset: any, index: number) => {
<div // 获取上传后的远程链接(如果存在)
key={asset.id} // 参考图像在uploadResults中从索引1开始索引0是生成的图像
className="relative w-16 h-16 rounded border overflow-hidden cursor-pointer hover:ring-2 hover:ring-yellow-400" const uploadedUrl = parentGen.uploadResults && parentGen.uploadResults[index + 1] && parentGen.uploadResults[index + 1].success
onClick={(e) => { ? `${parentGen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_50`
e.stopPropagation(); : null;
setPreviewModal({ const displayUrl = uploadedUrl || asset.url;
open: true,
imageUrl: asset.url, return (
title: `原始参考图像 ${index + 1}`, <div
description: `${asset.width} × ${asset.height}` key={asset.id}
}); className="relative w-16 h-16 rounded border overflow-hidden cursor-pointer hover:ring-2 hover:ring-yellow-400"
}} onClick={(e) => {
> e.stopPropagation();
<img setPreviewModal({
src={asset.url} open: true,
alt={`原始参考图像 ${index + 1}`} imageUrl: displayUrl,
className="w-full h-full object-cover" title: `原始参考图像 ${index + 1}`,
/> description: `${asset.width} × ${asset.height}`
</div> });
))} }}
>
<img
src={displayUrl}
alt={`原始参考图像 ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
);
})}
{parentGen.sourceAssets.length > 4 && ( {parentGen.sourceAssets.length > 4 && (
<div className="w-16 h-16 rounded border flex items-center justify-center bg-gray-100 text-xs text-gray-500"> <div className="w-16 h-16 rounded border flex items-center justify-center bg-gray-100 text-xs text-gray-500">
+{parentGen.sourceAssets.length - 4} +{parentGen.sourceAssets.length - 4}
@@ -715,6 +943,28 @@ export const HistoryPanel: React.FC = () => {
})()} })()}
</div> </div>
{/* 测试按钮 - 用于调试 */}
<div className="mb-2">
<Button
variant="outline"
size="sm"
className="w-full h-8 text-xs card"
onClick={() => {
// 测试悬浮预览功能
setHoveredImage({
url: 'https://images.unsplash.com/photo-1501854140801-50d01698950b?w=200',
title: '测试图像',
width: 200,
height: 200,
size: 102400
});
setPreviewPosition({x: 100, y: 100});
}}
>
</Button>
</div>
{/* 操作 */} {/* 操作 */}
<div className="space-y-2 flex-shrink-0 pt-2 border-t border-gray-100"> <div className="space-y-2 flex-shrink-0 pt-2 border-t border-gray-100">
<Button <Button
@@ -779,12 +1029,12 @@ export const HistoryPanel: React.FC = () => {
{/* 悬浮预览 */} {/* 悬浮预览 */}
{hoveredImage && ( {hoveredImage && (
<div <div
className="fixed z-50 shadow-2xl border border-gray-300 rounded-lg overflow-hidden bg-white backdrop-blur-sm" className="absolute z-[9999] shadow-2xl border border-gray-300 rounded-lg overflow-hidden bg-white backdrop-blur-sm pointer-events-none"
style={{ style={{
left: Math.min(previewPosition.x + 10, window.innerWidth - 250), left: `${previewPosition.x}px`,
top: Math.min(previewPosition.y + 10, window.innerHeight - 250), top: `${previewPosition.y}px`,
maxWidth: '250px', maxWidth: '300px',
maxHeight: '250px' maxHeight: '300px'
}} }}
> >
<div className="bg-gray-900 text-white text-xs p-2 truncate font-medium"> <div className="bg-gray-900 text-white text-xs p-2 truncate font-medium">
@@ -793,20 +1043,22 @@ export const HistoryPanel: React.FC = () => {
<img <img
src={hoveredImage.url} src={hoveredImage.url}
alt="预览" alt="预览"
className="w-full h-auto max-h-[150px] object-contain" className="w-full h-auto max-h-[200px] object-contain"
/> />
{/* 图像信息 */} {/* 图像信息 */}
<div className="p-2 bg-gray-50 border-t border-gray-200 text-xs"> <div className="p-2 bg-gray-50 border-t border-gray-200 text-xs">
{imageDimensions && ( {hoveredImage.width && hoveredImage.height && (
<div className="flex justify-between text-gray-600"> <div className="flex justify-between text-gray-600">
<span>:</span> <span>:</span>
<span className="text-gray-800">{imageDimensions.width} × {imageDimensions.height}</span> <span className="text-gray-800">{hoveredImage.width} × {hoveredImage.height}</span>
</div>
)}
{hoveredImage.size && (
<div className="flex justify-between text-gray-600 mt-1">
<span>:</span>
<span className="text-gray-800">{Math.round(hoveredImage.size / 1024)} KB</span>
</div> </div>
)} )}
<div className="flex justify-between text-gray-600 mt-1">
<span>:</span>
<span className="text-gray-800 capitalize">{selectedTool}</span>
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -231,8 +231,8 @@ export const ImageCanvas: React.FC = () => {
)} )}
{isGenerating && ( {isGenerating && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900/40 z-50 backdrop-blur-sm rounded-lg"> <div className="absolute inset-0 flex items-center justify-center bg-gray-900/40 z-50 backdrop-blur-sm rounded-lg animate-in fade-in duration-300">
<div className="text-center bg-white/90 rounded-xl p-6 card-lg backdrop-blur-sm"> <div className="text-center bg-white/90 rounded-xl p-6 card-lg backdrop-blur-sm animate-in scale-in duration-200">
<div className="animate-spin rounded-full h-10 w-10 border-2 border-yellow-400 border-t-transparent mx-auto mb-3" /> <div className="animate-spin rounded-full h-10 w-10 border-2 border-yellow-400 border-t-transparent mx-auto mb-3" />
<p className="text-gray-700 text-sm font-medium">...</p> <p className="text-gray-700 text-sm font-medium">...</p>
</div> </div>

View File

@@ -21,8 +21,8 @@ export const ImagePreviewModal: React.FC<ImagePreviewModalProps> = ({
return ( return (
<Dialog.Root open={open} onOpenChange={onOpenChange}> <Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50" /> <Dialog.Overlay className="fixed inset-0 bg-black/50 z-50 animate-in fade-in duration-200" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white border border-gray-200 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto z-50"> <Dialog.Content className="fixed inset-0 m-auto w-full max-w-2xl h-fit max-h-[90vh] overflow-y-auto bg-white border border-gray-200 rounded-lg p-6 z-50 animate-in scale-in duration-200">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<Dialog.Title className="text-lg font-semibold text-gray-900"> <Dialog.Title className="text-lg font-semibold text-gray-900">
{title} {title}

View File

@@ -12,8 +12,8 @@ export const InfoModal: React.FC<InfoModalProps> = ({ open, onOpenChange }) => {
return ( return (
<Dialog.Root open={open} onOpenChange={onOpenChange}> <Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50" /> <Dialog.Overlay className="fixed inset-0 bg-black/50 z-50 animate-in fade-in duration-200" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white border border-gray-200 rounded-lg p-6 w-full max-w-4xl z-50"> <Dialog.Content className="fixed inset-0 m-auto w-full max-w-4xl h-fit max-h-[90vh] overflow-y-auto bg-white border border-gray-200 rounded-lg p-6 z-50 animate-in scale-in duration-200">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<Dialog.Title className="text-lg font-semibold text-gray-900"> <Dialog.Title className="text-lg font-semibold text-gray-900">
Nano Banana AI Nano Banana AI
@@ -25,7 +25,7 @@ export const InfoModal: React.FC<InfoModalProps> = ({ open, onOpenChange }) => {
</Dialog.Close> </Dialog.Close>
</div> </div>
<div className="space-y-4"> <div className="space-y-6">
<div className="space-y-3 text-sm text-gray-700"> <div className="space-y-3 text-sm text-gray-700">
<p> <p>
{' '} {' '}
@@ -85,6 +85,37 @@ export const InfoModal: React.FC<InfoModalProps> = ({ open, onOpenChange }) => {
</div> </div>
</div> </div>
</div> </div>
{/* 键盘快捷键 */}
<div className="border-t border-gray-200 pt-4">
<h3 className="text-base font-semibold text-gray-900 mb-3"></h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm text-gray-600"></span>
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs"> + Enter</span>
</div>
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm text-gray-600"></span>
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs"> + R</span>
</div>
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm text-gray-600"></span>
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs">E</span>
</div>
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm text-gray-600"></span>
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs">H</span>
</div>
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm text-gray-600"></span>
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs">P</span>
</div>
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm text-gray-600"></span>
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs">Esc</span>
</div>
</div>
</div>
</div> </div>
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>

View File

@@ -3,7 +3,7 @@ import { Textarea } from './ui/Textarea';
import { Button } from './ui/Button'; import { Button } from './ui/Button';
import { useAppStore } from '../store/useAppStore'; import { useAppStore } from '../store/useAppStore';
import { useImageGeneration, useImageEditing } from '../hooks/useImageGeneration'; import { useImageGeneration, useImageEditing } from '../hooks/useImageGeneration';
import { Upload, Wand2, Edit3, MousePointer, HelpCircle, Menu, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react'; import { Upload, Wand2, Edit3, MousePointer, HelpCircle, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
import { blobToBase64 } from '../utils/imageUtils'; import { blobToBase64 } from '../utils/imageUtils';
import { PromptHints } from './PromptHints'; import { PromptHints } from './PromptHints';
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
@@ -54,7 +54,7 @@ export const PromptComposer: React.FC = () => {
prompt: currentPrompt, prompt: currentPrompt,
referenceImages: referenceImages.length > 0 ? referenceImages : undefined, referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
temperature, temperature,
seed: seed || undefined seed: seed !== null ? seed : undefined
}); });
} else if (selectedTool === 'edit' || selectedTool === 'mask') { } else if (selectedTool === 'edit' || selectedTool === 'mask') {
edit(currentPrompt); edit(currentPrompt);
@@ -141,7 +141,7 @@ export const PromptComposer: React.FC = () => {
<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">
<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-colors 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">
@@ -156,24 +156,24 @@ export const PromptComposer: React.FC = () => {
return ( return (
<> <>
<div className="w-72 h-full bg-white p-4 flex flex-col space-y-5 overflow-y-auto"> <div className="w-72 h-full bg-white p-5 flex flex-col overflow-y-auto space-y-5">
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide"></h3> <h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide"></h3>
<div className="flex items-center space-x-0.5"> <div className="flex items-center space-x-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setShowHintsModal(true)} onClick={() => setShowHintsModal(true)}
className="h-6 w-6 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full card" className="h-7 w-7 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full card"
> >
<HelpCircle className="h-3.5 w-3.5" /> <HelpCircle className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setShowPromptPanel(false)} onClick={() => setShowPromptPanel(false)}
className="h-6 w-6 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full card" className="h-7 w-7 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full card"
title="隐藏面板" title="隐藏面板"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -183,19 +183,19 @@ export const PromptComposer: React.FC = () => {
</Button> </Button>
</div> </div>
</div> </div>
<div className="grid grid-cols-3 gap-1.5"> <div className="grid grid-cols-3 gap-2.5">
{tools.map((tool) => ( {tools.map((tool) => (
<button <button
key={tool.id} key={tool.id}
onClick={() => setSelectedTool(tool.id)} onClick={() => setSelectedTool(tool.id)}
className={cn( className={cn(
'flex flex-col items-center p-2 rounded-lg border transition-all duration-200', 'flex flex-col items-center p-3 rounded-xl border transition-all duration-200 hover:scale-105',
selectedTool === tool.id selectedTool === tool.id
? 'bg-yellow-50 border-yellow-300 text-yellow-700 shadow-sm' ? 'bg-yellow-50 border-yellow-300 text-yellow-700 shadow-sm'
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700' : 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
)} )}
> >
<tool.icon className="h-4 w-4 mb-1" /> <tool.icon className="h-5 w-5 mb-1.5" />
<span className="text-xs font-medium">{tool.label}</span> <span className="text-xs font-medium">{tool.label}</span>
</button> </button>
))} ))}
@@ -203,29 +203,29 @@ export const PromptComposer: React.FC = () => {
</div> </div>
{/* 文件上传 */} {/* 文件上传 */}
<div> <div className="space-y-3">
<div <div
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
className={cn( className={cn(
"border border-dashed rounded-md p-4 text-center transition-colors", "border-2 border-dashed rounded-xl p-5 text-center transition-colors",
isDragOver isDragOver
? "border-yellow-400 bg-yellow-400/10" ? "border-yellow-400 bg-yellow-400/10"
: "border-gray-300 hover:border-yellow-400" : "border-gray-300 hover:border-yellow-400"
)} )}
> >
<label className="text-sm font-medium text-gray-700 mb-1 block"> <label className="text-sm font-semibold text-gray-700 mb-2 block">
{selectedTool === 'generate' ? '参考图像' : selectedTool === 'edit' ? '样式参考' : '上传图像'} {selectedTool === 'generate' ? '参考图像' : selectedTool === 'edit' ? '样式参考' : '上传图像'}
</label> </label>
{selectedTool === 'mask' && ( {selectedTool === 'mask' && (
<p className="text-xs text-gray-500 mb-2">使</p> <p className="text-xs text-gray-500 mb-3">使</p>
)} )}
{selectedTool === 'generate' && ( {selectedTool === 'generate' && (
<p className="text-xs text-gray-500 mb-2">2</p> <p className="text-xs text-gray-500 mb-3">2</p>
)} )}
{selectedTool === 'edit' && ( {selectedTool === 'edit' && (
<p className="text-xs text-gray-500 mb-2"> <p className="text-xs text-gray-500 mb-3">
{canvasImage ? '可选样式参考最多2张图像' : '上传要编辑的图像最多2张图像'} {canvasImage ? '可选样式参考最多2张图像' : '上传要编辑的图像最多2张图像'}
</p> </p>
)} )}
@@ -238,8 +238,8 @@ export const PromptComposer: React.FC = () => {
className="hidden" className="hidden"
/> />
<div className="flex flex-col items-center justify-center space-y-2"> <div className="flex flex-col items-center justify-center space-y-3">
<Upload className={cn("h-6 w-6", isDragOver ? "text-yellow-500" : "text-gray-400")} /> <Upload className={cn("h-7 w-7", isDragOver ? "text-yellow-500" : "text-gray-400")} />
<div> <div>
<p className={cn( <p className={cn(
"text-sm font-medium", "text-sm font-medium",
@@ -261,7 +261,7 @@ export const PromptComposer: React.FC = () => {
(selectedTool === 'edit' && editReferenceImages.length >= 2) (selectedTool === 'edit' && editReferenceImages.length >= 2)
} }
> >
<Upload className="h-3 w-3 mr-1" /> <Upload className="h-3.5 w-3.5 mr-1.5" />
</Button> </Button>
</div> </div>
@@ -270,21 +270,24 @@ export const PromptComposer: React.FC = () => {
{/* Show uploaded images preview */} {/* Show uploaded images preview */}
{((selectedTool === 'generate' && uploadedImages.length > 0) || {((selectedTool === 'generate' && uploadedImages.length > 0) ||
(selectedTool === 'edit' && editReferenceImages.length > 0)) && ( (selectedTool === 'edit' && editReferenceImages.length > 0)) && (
<div className="mt-2 space-y-2"> <div className="space-y-2.5">
{(selectedTool === 'generate' ? uploadedImages : editReferenceImages).map((image, index) => ( {(selectedTool === 'generate' ? uploadedImages : editReferenceImages).map((image, index) => (
<div key={index} className="relative"> <div key={index} className="relative">
<img <img
src={image} src={image}
alt={`参考图像 ${index + 1}`} alt={`参考图像 ${index + 1}`}
className="w-full h-16 object-cover rounded border border-gray-300" className="w-full h-20 object-cover rounded-lg border-2 border-gray-200"
/> />
<button <button
onClick={() => selectedTool === 'generate' ? removeUploadedImage(index) : removeEditReferenceImage(index)} onClick={() => selectedTool === 'generate' ? removeUploadedImage(index) : removeEditReferenceImage(index)}
className="absolute top-1 right-1 bg-gray-100/80 text-gray-600 hover:text-gray-800 rounded-full p-1 transition-colors" className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1.5 transition-colors shadow-md hover:bg-red-600"
> >
× <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button> </button>
<div className="absolute bottom-1 left-1 bg-gray-100/80 text-xs px-1 py-0.5 rounded text-gray-700"> <div className="absolute bottom-2 left-2 bg-black/60 text-white text-xs px-2 py-1 rounded-lg">
{index + 1} {index + 1}
</div> </div>
</div> </div>
@@ -294,8 +297,8 @@ export const PromptComposer: React.FC = () => {
</div> </div>
{/* 提示输入 */} {/* 提示输入 */}
<div> <div className="flex-grow space-y-3">
<label className="text-xs font-medium text-gray-500 mb-2 block uppercase tracking-wide"> <label className="text-xs font-semibold text-gray-500 block uppercase tracking-wide">
{selectedTool === 'generate' ? '提示词' : '编辑指令'} {selectedTool === 'generate' ? '提示词' : '编辑指令'}
</label> </label>
<Textarea <Textarea
@@ -306,19 +309,19 @@ export const PromptComposer: React.FC = () => {
? '描述您想要创建的内容...' ? '描述您想要创建的内容...'
: '描述您想要的修改...' : '描述您想要的修改...'
} }
className="min-h-[100px] resize-none text-sm" className="min-h-[120px] resize-none text-sm rounded-xl"
/> />
{/* 提示质量指示器 */} {/* 提示质量指示器 */}
<button <button
onClick={() => setShowHintsModal(true)} onClick={() => setShowHintsModal(true)}
className="mt-2 flex items-center text-xs hover:text-gray-700 transition-colors group" className="flex items-center text-xs hover:text-gray-700 transition-colors group"
> >
{currentPrompt.length < 20 ? ( {currentPrompt.length < 20 ? (
<HelpCircle className="h-3 w-3 mr-2 text-red-400 group-hover:text-red-500" /> <HelpCircle className="h-4 w-4 mr-2 text-red-400 group-hover:text-red-500" />
) : ( ) : (
<div className={cn( <div className={cn(
'h-2 w-2 rounded-full mr-2', 'h-2.5 w-2.5 rounded-full mr-2',
currentPrompt.length < 50 ? 'bg-yellow-400' : 'bg-green-400' currentPrompt.length < 50 ? 'bg-yellow-400' : 'bg-green-400'
)} /> )} />
)} )}
@@ -331,44 +334,46 @@ export const PromptComposer: React.FC = () => {
{/* 生成按钮 */} {/* 生成按钮 */}
{isGenerating ? ( <div className="flex-shrink-0">
<div className="flex gap-2"> {isGenerating ? (
<div className="flex gap-3">
<Button
onClick={() => selectedTool === 'generate' ? cancelGeneration() : cancelEdit()}
className="flex-1 h-14 text-base font-semibold bg-red-500 hover:bg-red-600 rounded-xl card"
>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2" />
</Button>
</div>
) : (
<Button <Button
onClick={() => selectedTool === 'generate' ? cancelGeneration() : cancelEdit()} onClick={handleGenerate}
className="flex-1 h-12 text-sm font-medium bg-red-500 hover:bg-red-600 rounded-lg card" disabled={!currentPrompt.trim()}
className="w-full h-14 text-base font-semibold rounded-xl shadow-md hover:shadow-lg transition-all card"
> >
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" /> <Wand2 className="h-5 w-5 mr-2" />
{selectedTool === 'generate' ? '生成图像' : '应用编辑'}
</Button> </Button>
</div> )}
) : ( </div>
<Button
onClick={handleGenerate}
disabled={!currentPrompt.trim()}
className="w-full h-12 text-sm font-medium rounded-lg shadow-sm hover:shadow-md transition-shadow card"
>
<Wand2 className="h-4 w-4 mr-2" />
{selectedTool === 'generate' ? '生成图像' : '应用编辑'}
</Button>
)}
{/* 高级控制 */} {/* 高级控制 */}
<div className="pt-2 border-t border-gray-100"> <div className="pt-3 border-t border-gray-100 flex-shrink-0">
<button <button
onClick={() => setShowAdvanced(!showAdvanced)} onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center text-xs text-gray-500 hover:text-gray-700 transition-colors duration-200" className="flex items-center text-sm text-gray-500 hover:text-gray-700 transition-colors duration-200"
> >
{showAdvanced ? <ChevronDown className="h-3 w-3 mr-1" /> : <ChevronRight className="h-3 w-3 mr-1" />} {showAdvanced ? <ChevronDown className="h-4 w-4 mr-1.5" /> : <ChevronRight className="h-4 w-4 mr-1.5" />}
</button> </button>
{showAdvanced && ( {showAdvanced && (
<div className="mt-3 space-y-3"> <div className="mt-4 space-y-4 animate-in slide-down duration-300">
{/* 创造力 */} {/* 创造力 */}
<div> <div className="space-y-2">
<label className="text-xs text-gray-500 mb-1.5 block flex justify-between"> <label className="text-sm text-gray-500 block flex justify-between">
<span></span> <span className="font-medium"></span>
<span className="font-mono">{temperature}</span> <span className="font-mono bg-gray-100 px-2 py-0.5 rounded">{temperature}</span>
</label> </label>
<input <input
type="range" type="range"
@@ -377,13 +382,13 @@ export const PromptComposer: React.FC = () => {
step="0.1" step="0.1"
value={temperature} value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))} onChange={(e) => setTemperature(parseFloat(e.target.value))}
className="w-full h-1.5 bg-gray-200 rounded-full appearance-none cursor-pointer slider" className="w-full h-2 bg-gray-200 rounded-full appearance-none cursor-pointer slider"
/> />
</div> </div>
{/* 种子 */} {/* 种子 */}
<div> <div className="space-y-2">
<label className="text-xs text-gray-500 mb-1.5 block"> <label className="text-sm text-gray-500 block font-medium">
() ()
</label> </label>
<input <input
@@ -391,7 +396,7 @@ export const PromptComposer: React.FC = () => {
value={seed || ''} value={seed || ''}
onChange={(e) => setSeed(e.target.value ? parseInt(e.target.value) : null)} onChange={(e) => setSeed(e.target.value ? parseInt(e.target.value) : null)}
placeholder="随机" placeholder="随机"
className="w-full h-8 px-2.5 bg-gray-50 border border-gray-200 rounded-md text-xs text-gray-700 focus:outline-none focus:ring-1 focus:ring-yellow-400" className="w-full h-10 px-3 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-yellow-400 focus:border-transparent"
/> />
</div> </div>
</div> </div>
@@ -399,31 +404,29 @@ export const PromptComposer: React.FC = () => {
<button <button
onClick={() => setShowClearConfirm(!showClearConfirm)} onClick={() => setShowClearConfirm(!showClearConfirm)}
className="flex items-center text-xs text-gray-500 hover:text-red-500 transition-colors duration-200 mt-3" className="flex items-center text-sm text-gray-500 hover:text-red-500 transition-colors duration-200 mt-4"
> >
<RotateCcw className="h-3 w-3 mr-1.5" /> <RotateCcw className="h-4 w-4 mr-2" />
</button> </button>
{showClearConfirm && ( {showClearConfirm && (
<div className="mt-2 p-3 bg-red-50 rounded-lg border border-red-100"> <div className="mt-3 p-4 bg-red-50 rounded-xl border border-red-100 animate-in slide-down duration-300">
<p className="text-xs text-red-700 mb-3"> <p className="text-sm text-red-700 mb-4">
</p> </p>
<div className="flex space-x-2"> <div className="flex space-x-3">
<Button <Button
variant="destructive" variant="destructive"
size="sm"
onClick={handleClearSession} onClick={handleClearSession}
className="flex-1 h-8 text-xs card" className="flex-1 h-10 text-sm font-semibold card text-gray-700"
> >
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm"
onClick={() => setShowClearConfirm(false)} onClick={() => setShowClearConfirm(false)}
className="flex-1 h-8 text-xs border-gray-200 card" className="flex-1 h-10 text-sm font-semibold border-gray-300 text-gray-700 hover:bg-gray-100 card"
> >
</Button> </Button>
@@ -432,35 +435,11 @@ export const PromptComposer: React.FC = () => {
)} )}
</div> </div>
{/* 键盘快捷键 */}
<div className="pt-3 border-t border-gray-100">
<h4 className="text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide"></h4>
<div className="space-y-1.5 text-xs text-gray-600">
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-mono bg-gray-100 px-1.5 py-0.5 rounded text-gray-700"> + Enter</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-mono bg-gray-100 px-1.5 py-0.5 rounded text-gray-700"> + R</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-mono bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">E</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-mono bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">H</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="font-mono bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">P</span>
</div>
</div>
</div>
</div> </div>
{/* 提示提示模态框 */} {/* 提示提示模态框 */}
<PromptHints open={showHintsModal} onOpenChange={setShowHintsModal} /> <PromptHints open={showHintsModal} onOpenChange={setShowHintsModal} />
</> </>
); );
}; };
export default PromptComposer;

View File

@@ -49,8 +49,8 @@ export const PromptHints: React.FC<PromptHintsProps> = ({ open, onOpenChange })
return ( return (
<Dialog.Root open={open} onOpenChange={onOpenChange}> <Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/30 z-50" /> <Dialog.Overlay className="fixed inset-0 bg-black/30 z-50 animate-in fade-in duration-200" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white border border-gray-200 rounded-lg p-6 w-full max-w-md max-h-[80vh] overflow-y-auto z-50"> <Dialog.Content className="fixed inset-0 m-auto w-full max-w-md h-fit max-h-[80vh] overflow-y-auto bg-white border border-gray-200 rounded-lg p-6 z-50 animate-in scale-in duration-200">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<Dialog.Title className="text-lg font-semibold text-gray-900"> <Dialog.Title className="text-lg font-semibold text-gray-900">

View File

@@ -14,6 +14,7 @@ export interface ToastProps {
export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClose, onHoverChange }) => { export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClose, onHoverChange }) => {
const [showDetails, setShowDetails] = useState(false); const [showDetails, setShowDetails] = useState(false);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null); const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const getTypeStyles = () => { const getTypeStyles = () => {
@@ -50,6 +51,13 @@ export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClos
}, 1000); }, 1000);
}; };
const handleClose = () => {
setIsExiting(true);
setTimeout(() => {
onClose(id);
}, 300); // Match the animation duration
};
// Cleanup timeout on unmount // Cleanup timeout on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -64,7 +72,7 @@ export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClos
className={cn( className={cn(
'flex flex-col rounded-lg shadow-lg min-w-[300px] max-w-md transition-all duration-300 transform', 'flex flex-col rounded-lg shadow-lg min-w-[300px] max-w-md transition-all duration-300 transform',
getTypeStyles(), getTypeStyles(),
'animate-in slide-in-from-top-full duration-300' isExiting ? 'animate-out slide-out-to-right duration-300' : 'animate-in slide-in-from-right duration-300'
)} )}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
@@ -92,7 +100,7 @@ export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClos
)} )}
</div> </div>
<button <button
onClick={() => onClose(id)} onClick={handleClose}
className="ml-4 hover:bg-black/10 rounded-full p-1 transition-colors" className="ml-4 hover:bg-black/10 rounded-full p-1 transition-colors"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />

View File

@@ -97,15 +97,19 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
{children} {children}
<div className="fixed top-4 right-4 z-50 space-y-2"> <div className="fixed top-4 right-4 z-50 space-y-2">
{toasts.map(toast => ( {toasts.map(toast => (
<Toast <div
key={toast.id} key={toast.id}
id={toast.id} className="animate-in slide-in-from-right duration-300"
message={toast.message} >
type={toast.type} <Toast
details={toast.details} id={toast.id}
onClose={removeToast} message={toast.message}
onHoverChange={(hovered) => setToastHovered(toast.id, hovered)} type={toast.type}
/> details={toast.details}
onClose={removeToast}
onHoverChange={(hovered) => setToastHovered(toast.id, hovered)}
/>
</div>
))} ))}
</div> </div>
</ToastContext.Provider> </ToastContext.Provider>

View File

@@ -33,11 +33,25 @@ export interface ButtonProps
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => { ({ className, variant, size, ...props }, ref) => {
const [isClicking, setIsClicking] = React.useState(false);
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
setIsClicking(true);
// Reset the clicking state after the animation completes
setTimeout(() => setIsClicking(false), 200);
// Call the original onClick handler if it exists
if (props.onClick) {
props.onClick(e);
}
};
return ( return (
<button <button
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }), isClicking && 'animate-pulse-click')}
ref={ref} ref={ref}
{...props} {...props}
onClick={handleClick}
/> />
); );
} }

View File

@@ -19,19 +19,20 @@ export const useImageGeneration = () => {
// 重置中断标志 // 重置中断标志
isCancelledRef.current = false isCancelledRef.current = false
const images = await geminiService.generateImage(request) const result = await geminiService.generateImage(request)
// 检查是否已中断 // 检查是否已中断
if (isCancelledRef.current) { if (isCancelledRef.current) {
throw new Error('生成已中断') throw new Error('生成已中断')
} }
return images return result
}, },
onMutate: () => { onMutate: () => {
setIsGenerating(true) setIsGenerating(true)
}, },
onSuccess: async (images, request) => { onSuccess: async (result, request) => {
const { images, usageMetadata } = result;
if (images.length > 0) { if (images.length > 0) {
const outputAssets: Asset[] = images.map((base64, index) => ({ const outputAssets: Asset[] = images.map((base64, index) => ({
id: generateId(), id: generateId(),
@@ -82,6 +83,11 @@ export const useImageGeneration = () => {
console.warn('未找到accessToken跳过上传'); console.warn('未找到accessToken跳过上传');
} }
// 显示Token消耗信息如果可用
if (usageMetadata?.totalTokenCount) {
addToast(`本次生成消耗 ${usageMetadata.totalTokenCount} Tokens`, 'info', 3000);
}
const generation: Generation = { const generation: Generation = {
id: generateId(), id: generateId(),
prompt: request.prompt, prompt: request.prompt,
@@ -102,7 +108,8 @@ export const useImageGeneration = () => {
outputAssets, outputAssets,
modelVersion: 'gemini-2.5-flash-image-preview', modelVersion: 'gemini-2.5-flash-image-preview',
timestamp: Date.now(), timestamp: Date.now(),
uploadResults: uploadResults uploadResults: uploadResults,
usageMetadata: usageMetadata // 保存usageMetadata到历史记录
}; };
addGeneration(generation); addGeneration(generation);
@@ -249,19 +256,20 @@ export const useImageEditing = () => {
seed, seed,
} }
const images = await geminiService.editImage(request) const result = await geminiService.editImage(request)
// 检查是否已中断 // 检查是否已中断
if (isCancelledRef.current) { if (isCancelledRef.current) {
throw new Error('编辑已中断') throw new Error('编辑已中断')
} }
return { images, maskedReferenceImage } return { result, maskedReferenceImage }
}, },
onMutate: () => { onMutate: () => {
setIsGenerating(true) setIsGenerating(true)
}, },
onSuccess: async ({ images, maskedReferenceImage }, instruction) => { onSuccess: async ({ result, maskedReferenceImage }, instruction) => {
const { images, usageMetadata } = result;
if (images.length > 0) { if (images.length > 0) {
const outputAssets: Asset[] = images.map((base64, index) => ({ const outputAssets: Asset[] = images.map((base64, index) => ({
id: generateId(), id: generateId(),
@@ -312,6 +320,11 @@ export const useImageEditing = () => {
console.warn('未找到accessToken跳过上传'); console.warn('未找到accessToken跳过上传');
} }
// 显示Token消耗信息如果可用
if (usageMetadata?.totalTokenCount) {
addToast(`本次编辑消耗 ${usageMetadata.totalTokenCount} Tokens`, 'info', 3000);
}
const edit: Edit = { const edit: Edit = {
id: generateId(), id: generateId(),
parentGenerationId: selectedGenerationId || '', parentGenerationId: selectedGenerationId || '',
@@ -320,7 +333,12 @@ export const useImageEditing = () => {
instruction, instruction,
outputAssets, outputAssets,
timestamp: Date.now(), timestamp: Date.now(),
uploadResults: uploadResults uploadResults: uploadResults,
parameters: {
seed: seed || undefined,
temperature: temperature
},
usageMetadata: usageMetadata // 保存usageMetadata到历史记录
}; };
addEdit(edit); addEdit(edit);

View File

@@ -15,6 +15,93 @@
} }
} }
@keyframes slide-in-from-right {
0% {
transform: translateX(100%);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slide-out-to-right {
0% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes slide-in-from-left {
0% {
transform: translateX(-100%);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes scale-in {
0% {
transform: scale(0.95);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes scale-out {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.95);
opacity: 0;
}
}
@keyframes slide-down {
0% {
transform: translateY(-10px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes pulse-click {
0% {
transform: scale(1);
}
50% {
transform: scale(0.95);
}
100% {
transform: scale(1);
}
}
.animate-in { .animate-in {
animation-duration: 300ms; animation-duration: 300ms;
animation-fill-mode: both; animation-fill-mode: both;
@@ -24,6 +111,30 @@
animation-name: slide-in-from-top-full; animation-name: slide-in-from-top-full;
} }
.slide-in-from-right {
animation-name: slide-in-from-right;
}
.slide-out-to-right {
animation-name: slide-out-to-right;
}
.slide-in-from-left {
animation-name: slide-in-from-left;
}
.fade-in {
animation-name: fade-in;
}
.scale-in {
animation-name: scale-in;
}
.scale-out {
animation-name: scale-out;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -99,6 +210,15 @@ body {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
} }
/* Animation classes */
.slide-down {
animation-name: slide-down;
}
.pulse-click {
animation-name: pulse-click;
}
/* Card styles */ /* Card styles */
.card { .card {
@apply bg-white rounded-xl shadow-card border border-gray-100 overflow-hidden; @apply bg-white rounded-xl shadow-card border border-gray-100 overflow-hidden;

View File

@@ -20,13 +20,19 @@ export interface EditRequest {
seed?: number; seed?: number;
} }
export interface UsageMetadata {
totalTokenCount?: number;
promptTokenCount?: number;
candidatesTokenCount?: number;
}
export interface SegmentationRequest { export interface SegmentationRequest {
image: string; // base64 image: string; // base64
query: string; // "像素(x,y)处的对象" 或 "红色汽车" query: string; // "像素(x,y)处的对象" 或 "红色汽车"
} }
export class GeminiService { export class GeminiService {
async generateImage(request: GenerationRequest): Promise<string[]> { async generateImage(request: GenerationRequest): Promise<{images: string[], usageMetadata?: any}> {
try { try {
const contents: any[] = [{ text: request.prompt }]; const contents: any[] = [{ text: request.prompt }];
@@ -67,7 +73,10 @@ export class GeminiService {
} }
} }
return images; // 获取usageMetadata如果存在
const usageMetadata = response.usageMetadata;
return { images, usageMetadata };
} catch (error) { } catch (error) {
console.error('生成图像时出错:', error); console.error('生成图像时出错:', error);
if (error instanceof Error && error.message) { if (error instanceof Error && error.message) {
@@ -77,7 +86,7 @@ export class GeminiService {
} }
} }
async editImage(request: EditRequest): Promise<string[]> { async editImage(request: EditRequest): Promise<{images: string[], usageMetadata?: any}> {
try { try {
const contents = [ const contents = [
{ text: this.buildEditPrompt(request) }, { text: this.buildEditPrompt(request) },
@@ -135,7 +144,10 @@ export class GeminiService {
} }
} }
return images; // 获取usageMetadata如果存在
const usageMetadata = response.usageMetadata;
return { images, usageMetadata };
} catch (error) { } catch (error) {
console.error('编辑图像时出错:', error); console.error('编辑图像时出错:', error);
if (error instanceof Error && error.message) { if (error instanceof Error && error.message) {

View File

@@ -27,6 +27,7 @@ interface LightweightProject {
modelVersion: string; modelVersion: string;
timestamp: number; timestamp: number;
uploadResults?: UploadResult[]; uploadResults?: UploadResult[];
usageMetadata?: Generation['usageMetadata'];
}>; }>;
edits: Array<{ edits: Array<{
id: string; id: string;
@@ -39,6 +40,8 @@ interface LightweightProject {
outputAssetsBlobUrls: string[]; outputAssetsBlobUrls: string[];
timestamp: number; timestamp: number;
uploadResults?: UploadResult[]; uploadResults?: UploadResult[];
parameters?: Edit['parameters'];
usageMetadata?: Edit['usageMetadata'];
}>; }>;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
@@ -291,7 +294,8 @@ export const useAppStore = create<AppState>()(
outputAssetsBlobUrls, outputAssetsBlobUrls,
modelVersion: generation.modelVersion, modelVersion: generation.modelVersion,
timestamp: generation.timestamp, timestamp: generation.timestamp,
uploadResults: generation.uploadResults uploadResults: generation.uploadResults,
usageMetadata: generation.usageMetadata
}; };
const updatedProject = state.currentProject ? { const updatedProject = state.currentProject ? {
@@ -390,7 +394,9 @@ export const useAppStore = create<AppState>()(
instruction: edit.instruction, instruction: edit.instruction,
outputAssetsBlobUrls, outputAssetsBlobUrls,
timestamp: edit.timestamp, timestamp: edit.timestamp,
uploadResults: edit.uploadResults uploadResults: edit.uploadResults,
parameters: edit.parameters,
usageMetadata: edit.usageMetadata
}; };
if (!state.currentProject) return {}; if (!state.currentProject) return {};

View File

@@ -21,6 +21,7 @@ export interface Generation {
parameters: { parameters: {
seed?: number; seed?: number;
temperature?: number; temperature?: number;
aspectRatio?: string;
}; };
sourceAssets: Asset[]; sourceAssets: Asset[];
outputAssets: Asset[]; outputAssets: Asset[];
@@ -28,6 +29,11 @@ export interface Generation {
timestamp: number; timestamp: number;
costEstimate?: number; costEstimate?: number;
uploadResults?: UploadResult[]; uploadResults?: UploadResult[];
usageMetadata?: {
totalTokenCount?: number;
promptTokenCount?: number;
candidatesTokenCount?: number;
};
} }
export interface Edit { export interface Edit {
@@ -39,6 +45,15 @@ export interface Edit {
outputAssets: Asset[]; outputAssets: Asset[];
timestamp: number; timestamp: number;
uploadResults?: UploadResult[]; uploadResults?: UploadResult[];
parameters?: {
seed?: number;
temperature?: number;
};
usageMetadata?: {
totalTokenCount?: number;
promptTokenCount?: number;
candidatesTokenCount?: number;
};
} }
export interface Project { export interface Project {