You've already forked Nano-Banana-AI-Image-Editor
优化界面
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
setHoveredImage({
|
imageUrl = generation.outputAssets[0].url;
|
||||||
url: asset.url,
|
|
||||||
title: `生成记录 G${index + 1}`,
|
|
||||||
description: generation.prompt
|
|
||||||
});
|
|
||||||
setPreviewPosition({x: e.clientX, y: e.clientY});
|
|
||||||
}
|
}
|
||||||
|
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: imageUrl,
|
||||||
|
title: `生成记录 G${index + 1}`,
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
size: size
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算预览位置,确保不超出屏幕边界
|
||||||
|
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,45 +445,54 @@ 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" />;
|
||||||
}
|
|
||||||
// 如果是普通URL,直接显示
|
|
||||||
return <img src={asset.url} alt="生成的变体" className="w-full h-full object-cover" />;
|
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||||
@@ -385,12 +500,7 @@ export const HistoryPanel: React.FC = () => {
|
|||||||
</div>
|
</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) {
|
||||||
setHoveredImage({
|
imageUrl = edit.outputAssets[0].url;
|
||||||
url: asset.url,
|
|
||||||
title: `编辑记录 E${index + 1}`,
|
|
||||||
description: edit.instruction
|
|
||||||
});
|
|
||||||
setPreviewPosition({x: e.clientX, y: e.clientY});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: imageUrl,
|
||||||
|
title: `编辑记录 E${index + 1}`,
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
size: size
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算预览位置,确保不超出屏幕边界
|
||||||
|
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,42 +657,43 @@ 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" />;
|
||||||
}
|
|
||||||
// 如果是普通URL,直接显示
|
|
||||||
return <img src={asset.url} alt="编辑的变体" className="w-full h-full object-cover" />;
|
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
|
||||||
@@ -486,12 +701,7 @@ export const HistoryPanel: React.FC = () => {
|
|||||||
</div>
|
</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,7 +783,15 @@ 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) => {
|
||||||
|
// 获取上传后的远程链接(如果存在)
|
||||||
|
// 参考图像在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
|
<div
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
className="relative w-16 h-16 rounded border overflow-hidden cursor-pointer hover:ring-2 hover:ring-yellow-400"
|
className="relative w-16 h-16 rounded border overflow-hidden cursor-pointer hover:ring-2 hover:ring-yellow-400"
|
||||||
@@ -581,19 +799,20 @@ export const HistoryPanel: React.FC = () => {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPreviewModal({
|
setPreviewModal({
|
||||||
open: true,
|
open: true,
|
||||||
imageUrl: asset.url,
|
imageUrl: displayUrl,
|
||||||
title: `参考图像 ${index + 1}`,
|
title: `参考图像 ${index + 1}`,
|
||||||
description: `${asset.width} × ${asset.height}`
|
description: `${asset.width} × ${asset.height}`
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={asset.url}
|
src={displayUrl}
|
||||||
alt={`参考图像 ${index + 1}`}
|
alt={`参考图像 ${index + 1}`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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,7 +891,15 @@ 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) => {
|
||||||
|
// 获取上传后的远程链接(如果存在)
|
||||||
|
// 参考图像在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
|
<div
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
className="relative w-16 h-16 rounded border overflow-hidden cursor-pointer hover:ring-2 hover:ring-yellow-400"
|
className="relative w-16 h-16 rounded border overflow-hidden cursor-pointer hover:ring-2 hover:ring-yellow-400"
|
||||||
@@ -680,19 +907,20 @@ export const HistoryPanel: React.FC = () => {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPreviewModal({
|
setPreviewModal({
|
||||||
open: true,
|
open: true,
|
||||||
imageUrl: asset.url,
|
imageUrl: displayUrl,
|
||||||
title: `原始参考图像 ${index + 1}`,
|
title: `原始参考图像 ${index + 1}`,
|
||||||
description: `${asset.width} × ${asset.height}`
|
description: `${asset.width} × ${asset.height}`
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={asset.url}
|
src={displayUrl}
|
||||||
alt={`原始参考图像 ${index + 1}`}
|
alt={`原始参考图像 ${index + 1}`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{hoveredImage.size && (
|
||||||
<div className="flex justify-between text-gray-600 mt-1">
|
<div className="flex justify-between text-gray-600 mt-1">
|
||||||
<span>模式:</span>
|
<span>大小:</span>
|
||||||
<span className="text-gray-800 capitalize">{selectedTool}</span>
|
<span className="text-gray-800">{Math.round(hoveredImage.size / 1024)} KB</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,13 +334,14 @@ export const PromptComposer: React.FC = () => {
|
|||||||
|
|
||||||
|
|
||||||
{/* 生成按钮 */}
|
{/* 生成按钮 */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => selectedTool === 'generate' ? cancelGeneration() : cancelEdit()}
|
onClick={() => selectedTool === 'generate' ? cancelGeneration() : cancelEdit()}
|
||||||
className="flex-1 h-12 text-sm font-medium bg-red-500 hover:bg-red-600 rounded-lg card"
|
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-4 w-4 border-b-2 border-white mr-2" />
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2" />
|
||||||
中断
|
中断
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,30 +349,31 @@ export const PromptComposer: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
disabled={!currentPrompt.trim()}
|
disabled={!currentPrompt.trim()}
|
||||||
className="w-full h-12 text-sm font-medium rounded-lg shadow-sm hover:shadow-md transition-shadow card"
|
className="w-full h-14 text-base font-semibold rounded-xl shadow-md hover:shadow-lg transition-all card"
|
||||||
>
|
>
|
||||||
<Wand2 className="h-4 w-4 mr-2" />
|
<Wand2 className="h-5 w-5 mr-2" />
|
||||||
{selectedTool === 'generate' ? '生成图像' : '应用编辑'}
|
{selectedTool === 'generate' ? '生成图像' : '应用编辑'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 高级控制 */}
|
{/* 高级控制 */}
|
||||||
<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;
|
||||||
@@ -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">
|
||||||
提示质量技巧
|
提示质量技巧
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -97,8 +97,11 @@ 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}
|
||||||
|
className="animate-in slide-in-from-right duration-300"
|
||||||
|
>
|
||||||
|
<Toast
|
||||||
id={toast.id}
|
id={toast.id}
|
||||||
message={toast.message}
|
message={toast.message}
|
||||||
type={toast.type}
|
type={toast.type}
|
||||||
@@ -106,6 +109,7 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
onClose={removeToast}
|
onClose={removeToast}
|
||||||
onHoverChange={(hovered) => setToastHovered(toast.id, hovered)}
|
onHoverChange={(hovered) => setToastHovered(toast.id, hovered)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ToastContext.Provider>
|
</ToastContext.Provider>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
120
src/index.css
120
src/index.css
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user