优化界面

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

@@ -47,7 +47,7 @@ export const HistoryPanel: React.FC = () => {
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 generations = currentProject?.generations || [];
@@ -211,7 +211,7 @@ export const HistoryPanel: React.FC = () => {
}
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 space-x-2">
@@ -298,7 +298,7 @@ export const HistoryPanel: React.FC = () => {
<p className="text-xs text-gray-400"></p>
</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) => (
<div
@@ -320,16 +320,122 @@ export const HistoryPanel: React.FC = () => {
}
}}
onMouseEnter={(e) => {
if (generation.outputAssets && generation.outputAssets.length > 0) {
const asset = generation.outputAssets[0];
if (asset.url) {
// 优先使用上传后的远程链接,如果没有则使用原始链接
let imageUrl = getUploadedImageUrl(generation, 0);
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({
url: asset.url,
url: imageUrl,
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) => {
@@ -339,58 +445,62 @@ export const HistoryPanel: React.FC = () => {
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;
// 获取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 > (historyPanel ? historyPanel.clientWidth : window.innerWidth)) {
x = (historyPanel ? historyPanel.clientWidth : window.innerWidth) - previewWidth - 10;
}
// 检查是否超出下边界
if (y + previewHeight > window.innerHeight) {
y = window.innerHeight - previewHeight - 10;
// 确保预览窗口不会超出下边界
if (y + previewHeight > (historyPanel ? historyPanel.clientHeight : window.innerHeight)) {
y = (historyPanel ? historyPanel.clientHeight : window.innerHeight) - previewHeight - 10;
}
// 检查是否超出左边界
// 确保预览窗口不会超出左边界
if (x < 0) {
x = 10;
}
// 检查是否超出上边界
// 确保预览窗口不会超出上边界
if (y < 0) {
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});
}}
onMouseLeave={() => {
setHoveredImage(null);
}}
>
{generation.outputAssets && generation.outputAssets.length > 0 ? (
(() => {
const asset = generation.outputAssets[0];
if (asset.url) {
// 如果是base64数据URL直接显示
if (asset.url.startsWith('data:')) {
return <img src={asset.url} alt="生成的变体" className="w-full h-full object-cover" />;
}
// 如果是普通URL直接显示
return <img src={asset.url} alt="生成的变体" className="w-full h-full object-cover" />;
} else {
return (
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
<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>
)}
{(() => {
// 优先使用上传后的远程链接,如果没有则使用原始链接
const imageUrl = getUploadedImageUrl(generation, 0) ||
(generation.outputAssets && generation.outputAssets.length > 0 ? generation.outputAssets[0].url : null);
if (imageUrl) {
return <img src={imageUrl} alt="生成的变体" className="w-full h-full object-cover" />;
} else {
return (
<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">
@@ -421,16 +531,120 @@ export const HistoryPanel: React.FC = () => {
}
}}
onMouseEnter={(e) => {
if (edit.outputAssets && edit.outputAssets.length > 0) {
const asset = edit.outputAssets[0];
if (asset.url) {
// 优先使用上传后的远程链接,如果没有则使用原始链接
let imageUrl = getUploadedImageUrl(edit, 0);
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({
url: asset.url,
url: imageUrl,
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) => {
@@ -443,55 +657,51 @@ export const HistoryPanel: React.FC = () => {
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});
}}
onMouseLeave={() => {
setHoveredImage(null);
}}
>
{edit.outputAssets && edit.outputAssets.length > 0 ? (
(() => {
const asset = edit.outputAssets[0];
if (asset.url) {
// 如果是base64数据URL直接显示
if (asset.url.startsWith('data:')) {
return <img src={asset.url} alt="编辑的变体" className="w-full h-full object-cover" />;
}
// 如果是普通URL直接显示
return <img src={asset.url} alt="编辑的变体" className="w-full h-full object-cover" />;
} else {
return (
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
<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>
)}
{(() => {
// 优先使用上传后的远程链接,如果没有则使用原始链接
const imageUrl = getUploadedImageUrl(edit, 0) ||
(edit.outputAssets && edit.outputAssets.length > 0 ? edit.outputAssets[0].url : null);
if (imageUrl) {
return <img src={imageUrl} alt="编辑的变体" className="w-full h-full object-cover" />;
} else {
return (
<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">
@@ -573,27 +783,36 @@ export const HistoryPanel: React.FC = () => {
{gen.sourceAssets.length}
</div>
<div className="flex flex-wrap gap-2">
{gen.sourceAssets.slice(0, 4).map((asset: any, index: number) => (
<div
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();
setPreviewModal({
open: true,
imageUrl: asset.url,
title: `参考图像 ${index + 1}`,
description: `${asset.width} × ${asset.height}`
});
}}
>
<img
src={asset.url}
alt={`参考图像 ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
))}
{gen.sourceAssets.slice(0, 4).map((asset: any, index: number) => {
// 获取上传后的远程链接(如果存在)
// 参考图像在uploadResults中从索引1开始索引0是生成的图像
const uploadedUrl = gen.uploadResults && gen.uploadResults[index + 1] && gen.uploadResults[index + 1].success
? `${gen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_50`
: null;
const displayUrl = uploadedUrl || asset.url;
return (
<div
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();
setPreviewModal({
open: true,
imageUrl: displayUrl,
title: `参考图像 ${index + 1}`,
description: `${asset.width} × ${asset.height}`
});
}}
>
<img
src={displayUrl}
alt={`参考图像 ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
);
})}
{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">
+{gen.sourceAssets.length - 4}
@@ -672,27 +891,36 @@ export const HistoryPanel: React.FC = () => {
:
</div>
<div className="flex flex-wrap gap-2">
{parentGen.sourceAssets.slice(0, 4).map((asset: any, index: number) => (
<div
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();
setPreviewModal({
open: true,
imageUrl: asset.url,
title: `原始参考图像 ${index + 1}`,
description: `${asset.width} × ${asset.height}`
});
}}
>
<img
src={asset.url}
alt={`原始参考图像 ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
))}
{parentGen.sourceAssets.slice(0, 4).map((asset: any, index: number) => {
// 获取上传后的远程链接(如果存在)
// 参考图像在uploadResults中从索引1开始索引0是生成的图像
const uploadedUrl = parentGen.uploadResults && parentGen.uploadResults[index + 1] && parentGen.uploadResults[index + 1].success
? `${parentGen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_50`
: null;
const displayUrl = uploadedUrl || asset.url;
return (
<div
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();
setPreviewModal({
open: true,
imageUrl: displayUrl,
title: `原始参考图像 ${index + 1}`,
description: `${asset.width} × ${asset.height}`
});
}}
>
<img
src={displayUrl}
alt={`原始参考图像 ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
);
})}
{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">
+{parentGen.sourceAssets.length - 4}
@@ -715,6 +943,28 @@ export const HistoryPanel: React.FC = () => {
})()}
</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">
<Button
@@ -779,12 +1029,12 @@ export const HistoryPanel: React.FC = () => {
{/* 悬浮预览 */}
{hoveredImage && (
<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={{
left: Math.min(previewPosition.x + 10, window.innerWidth - 250),
top: Math.min(previewPosition.y + 10, window.innerHeight - 250),
maxWidth: '250px',
maxHeight: '250px'
left: `${previewPosition.x}px`,
top: `${previewPosition.y}px`,
maxWidth: '300px',
maxHeight: '300px'
}}
>
<div className="bg-gray-900 text-white text-xs p-2 truncate font-medium">
@@ -793,20 +1043,22 @@ export const HistoryPanel: React.FC = () => {
<img
src={hoveredImage.url}
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">
{imageDimensions && (
{hoveredImage.width && hoveredImage.height && (
<div className="flex justify-between text-gray-600">
<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 className="flex justify-between text-gray-600 mt-1">
<span>:</span>
<span className="text-gray-800 capitalize">{selectedTool}</span>
</div>
</div>
</div>
)}

View File

@@ -231,8 +231,8 @@ export const ImageCanvas: React.FC = () => {
)}
{isGenerating && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900/40 z-50 backdrop-blur-sm rounded-lg">
<div className="text-center bg-white/90 rounded-xl p-6 card-lg backdrop-blur-sm">
<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 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" />
<p className="text-gray-700 text-sm font-medium">...</p>
</div>

View File

@@ -21,8 +21,8 @@ export const ImagePreviewModal: React.FC<ImagePreviewModalProps> = ({
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50" />
<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.Overlay className="fixed inset-0 bg-black/50 z-50 animate-in fade-in duration-200" />
<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">
<Dialog.Title className="text-lg font-semibold text-gray-900">
{title}

View File

@@ -12,8 +12,8 @@ export const InfoModal: React.FC<InfoModalProps> = ({ open, onOpenChange }) => {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50" />
<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.Overlay className="fixed inset-0 bg-black/50 z-50 animate-in fade-in duration-200" />
<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">
<Dialog.Title className="text-lg font-semibold text-gray-900">
Nano Banana AI
@@ -25,7 +25,7 @@ export const InfoModal: React.FC<InfoModalProps> = ({ open, onOpenChange }) => {
</Dialog.Close>
</div>
<div className="space-y-4">
<div className="space-y-6">
<div className="space-y-3 text-sm text-gray-700">
<p>
{' '}
@@ -85,6 +85,37 @@ export const InfoModal: React.FC<InfoModalProps> = ({ open, onOpenChange }) => {
</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>
</Dialog.Content>
</Dialog.Portal>

View File

@@ -3,7 +3,7 @@ import { Textarea } from './ui/Textarea';
import { Button } from './ui/Button';
import { useAppStore } from '../store/useAppStore';
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 { PromptHints } from './PromptHints';
import { cn } from '../utils/cn';
@@ -54,7 +54,7 @@ export const PromptComposer: React.FC = () => {
prompt: currentPrompt,
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
temperature,
seed: seed || undefined
seed: seed !== null ? seed : undefined
});
} else if (selectedTool === 'edit' || selectedTool === 'mask') {
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">
<button
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="显示提示面板"
>
<div className="flex flex-col space-y-1">
@@ -156,24 +156,24 @@ export const PromptComposer: React.FC = () => {
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 className="flex items-center justify-between mb-2">
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide"></h3>
<div className="flex items-center space-x-0.5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide"></h3>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
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
variant="ghost"
size="icon"
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="隐藏面板"
>
<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>
</div>
</div>
<div className="grid grid-cols-3 gap-1.5">
<div className="grid grid-cols-3 gap-2.5">
{tools.map((tool) => (
<button
key={tool.id}
onClick={() => setSelectedTool(tool.id)}
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
? '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'
)}
>
<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>
</button>
))}
@@ -203,29 +203,29 @@ export const PromptComposer: React.FC = () => {
</div>
{/* 文件上传 */}
<div>
<div className="space-y-3">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
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
? "border-yellow-400 bg-yellow-400/10"
: "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' ? '样式参考' : '上传图像'}
</label>
{selectedTool === 'mask' && (
<p className="text-xs text-gray-500 mb-2">使</p>
<p className="text-xs text-gray-500 mb-3">使</p>
)}
{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' && (
<p className="text-xs text-gray-500 mb-2">
<p className="text-xs text-gray-500 mb-3">
{canvasImage ? '可选样式参考最多2张图像' : '上传要编辑的图像最多2张图像'}
</p>
)}
@@ -238,8 +238,8 @@ export const PromptComposer: React.FC = () => {
className="hidden"
/>
<div className="flex flex-col items-center justify-center space-y-2">
<Upload className={cn("h-6 w-6", isDragOver ? "text-yellow-500" : "text-gray-400")} />
<div className="flex flex-col items-center justify-center space-y-3">
<Upload className={cn("h-7 w-7", isDragOver ? "text-yellow-500" : "text-gray-400")} />
<div>
<p className={cn(
"text-sm font-medium",
@@ -261,7 +261,7 @@ export const PromptComposer: React.FC = () => {
(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>
</div>
@@ -270,21 +270,24 @@ export const PromptComposer: React.FC = () => {
{/* Show uploaded images preview */}
{((selectedTool === 'generate' && uploadedImages.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) => (
<div key={index} className="relative">
<img
src={image}
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
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>
<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}
</div>
</div>
@@ -294,8 +297,8 @@ export const PromptComposer: React.FC = () => {
</div>
{/* 提示输入 */}
<div>
<label className="text-xs font-medium text-gray-500 mb-2 block uppercase tracking-wide">
<div className="flex-grow space-y-3">
<label className="text-xs font-semibold text-gray-500 block uppercase tracking-wide">
{selectedTool === 'generate' ? '提示词' : '编辑指令'}
</label>
<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
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 ? (
<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(
'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'
)} />
)}
@@ -331,44 +334,46 @@ export const PromptComposer: React.FC = () => {
{/* 生成按钮 */}
{isGenerating ? (
<div className="flex gap-2">
<div className="flex-shrink-0">
{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
onClick={() => selectedTool === 'generate' ? cancelGeneration() : cancelEdit()}
className="flex-1 h-12 text-sm font-medium bg-red-500 hover:bg-red-600 rounded-lg card"
onClick={handleGenerate}
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>
</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>
{/* 高级控制 */}
<div className="pt-2 border-t border-gray-100">
<div className="pt-3 border-t border-gray-100 flex-shrink-0">
<button
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>
{showAdvanced && (
<div className="mt-3 space-y-3">
<div className="mt-4 space-y-4 animate-in slide-down duration-300">
{/* 创造力 */}
<div>
<label className="text-xs text-gray-500 mb-1.5 block flex justify-between">
<span></span>
<span className="font-mono">{temperature}</span>
<div className="space-y-2">
<label className="text-sm text-gray-500 block flex justify-between">
<span className="font-medium"></span>
<span className="font-mono bg-gray-100 px-2 py-0.5 rounded">{temperature}</span>
</label>
<input
type="range"
@@ -377,13 +382,13 @@ export const PromptComposer: React.FC = () => {
step="0.1"
value={temperature}
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>
<label className="text-xs text-gray-500 mb-1.5 block">
<div className="space-y-2">
<label className="text-sm text-gray-500 block font-medium">
()
</label>
<input
@@ -391,7 +396,7 @@ export const PromptComposer: React.FC = () => {
value={seed || ''}
onChange={(e) => setSeed(e.target.value ? parseInt(e.target.value) : null)}
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>
@@ -399,31 +404,29 @@ export const PromptComposer: React.FC = () => {
<button
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>
{showClearConfirm && (
<div className="mt-2 p-3 bg-red-50 rounded-lg border border-red-100">
<p className="text-xs text-red-700 mb-3">
<div className="mt-3 p-4 bg-red-50 rounded-xl border border-red-100 animate-in slide-down duration-300">
<p className="text-sm text-red-700 mb-4">
</p>
<div className="flex space-x-2">
<div className="flex space-x-3">
<Button
variant="destructive"
size="sm"
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
variant="outline"
size="sm"
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>
@@ -432,35 +435,11 @@ export const PromptComposer: React.FC = () => {
)}
</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>
{/* 提示提示模态框 */}
<PromptHints open={showHintsModal} onOpenChange={setShowHintsModal} />
</>
);
};
};
export default PromptComposer;

View File

@@ -49,8 +49,8 @@ export const PromptHints: React.FC<PromptHintsProps> = ({ open, onOpenChange })
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/30 z-50" />
<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.Overlay className="fixed inset-0 bg-black/30 z-50 animate-in fade-in duration-200" />
<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">
<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 }) => {
const [showDetails, setShowDetails] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const getTypeStyles = () => {
@@ -50,6 +51,13 @@ export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClos
}, 1000);
};
const handleClose = () => {
setIsExiting(true);
setTimeout(() => {
onClose(id);
}, 300); // Match the animation duration
};
// Cleanup timeout on unmount
useEffect(() => {
return () => {
@@ -64,7 +72,7 @@ export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClos
className={cn(
'flex flex-col rounded-lg shadow-lg min-w-[300px] max-w-md transition-all duration-300 transform',
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}
onMouseLeave={handleMouseLeave}
@@ -92,7 +100,7 @@ export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClos
)}
</div>
<button
onClick={() => onClose(id)}
onClick={handleClose}
className="ml-4 hover:bg-black/10 rounded-full p-1 transition-colors"
>
<X className="h-4 w-4" />

View File

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

View File

@@ -33,11 +33,25 @@ export interface ButtonProps
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ 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 (
<button
className={cn(buttonVariants({ variant, size, className }))}
className={cn(buttonVariants({ variant, size, className }), isClicking && 'animate-pulse-click')}
ref={ref}
{...props}
onClick={handleClick}
/>
);
}