- Modified ImageNode to upload images to storage bucket before use - Updated RMBG API to support imageUrl parameter (fetch from URL and convert to Base64 internally) - Updated Enhance API to support imageUrl parameter - Updated canvas service layer to use imageUrl for all image processing APIs - All processed images are saved to storage bucket and user gallery
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { toast } from "sonner";
|
|
import { useCanvasStore } from "@/store/canvas";
|
|
import { imageGenerationService } from "@/services/canvas";
|
|
import type { CanvasNode, ImageNodeData } from "@/types/canvas";
|
|
import { cn } from "@/lib/utils";
|
|
import { AngleEditDialog } from "@/components/angle-edit-dialog";
|
|
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
|
|
|
interface ImageNodeProps {
|
|
node: CanvasNode;
|
|
isSelected: boolean;
|
|
}
|
|
|
|
export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|
const data = node.data as ImageNodeData;
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [angleDialogOpen, setAngleDialogOpen] = useState(false);
|
|
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
|
|
|
|
const updateNode = useCanvasStore((state) => state.updateNode);
|
|
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
|
const addNode = useCanvasStore((state) => state.addNode);
|
|
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
|
// 处理图片上传 - 上传到存储桶,不使用 Base64
|
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
// 验证文件类型
|
|
if (!file.type.startsWith('image/')) {
|
|
toast.error('请上传图片文件');
|
|
return;
|
|
}
|
|
|
|
// 验证文件大小 (8MB)
|
|
if (file.size > 8 * 1024 * 1024) {
|
|
toast.error('图片大小不能超过 8MB');
|
|
return;
|
|
}
|
|
|
|
setIsUploading(true);
|
|
// 先显示上传中状态
|
|
updateNode(node.id, {
|
|
data: { ...data, status: 'generating' },
|
|
});
|
|
|
|
try {
|
|
// 上传到存储桶
|
|
const result = await imageGenerationService.uploadImage(file);
|
|
|
|
if (result.success && result.data?.url) {
|
|
updateNode(node.id, {
|
|
data: {
|
|
...data,
|
|
src: result.data.url,
|
|
status: 'completed'
|
|
},
|
|
});
|
|
toast.success('图片上传成功');
|
|
} else {
|
|
updateNode(node.id, {
|
|
data: { ...data, status: 'failed' },
|
|
});
|
|
toast.error(result.error || '图片上传失败');
|
|
}
|
|
} catch (error) {
|
|
console.error('图片上传失败:', error);
|
|
updateNode(node.id, {
|
|
data: { ...data, status: 'failed' },
|
|
});
|
|
toast.error('图片上传失败,请重试');
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
|
|
// 重置 input
|
|
e.target.value = '';
|
|
};
|
|
|
|
// 处理图片编辑操作 - 集成我们现有的 API
|
|
const handleAction = async (action: string) => {
|
|
if (!data.src) {
|
|
toast.error("请先上传图片");
|
|
return;
|
|
}
|
|
|
|
// 对话框类型的操作
|
|
if (action === 'angle') {
|
|
setAngleDialogOpen(true);
|
|
return;
|
|
}
|
|
if (action === 'scene-fusion') {
|
|
setSceneFusionDialogOpen(true);
|
|
return;
|
|
}
|
|
if (action === 'resize') {
|
|
toast.info("尺寸调整功能即将上线");
|
|
return;
|
|
}
|
|
|
|
setIsProcessing(true);
|
|
let result;
|
|
|
|
try {
|
|
switch (action) {
|
|
case 'rmbg':
|
|
result = await imageGenerationService.removeBackground(data.src);
|
|
break;
|
|
case 'enhance':
|
|
result = await imageGenerationService.enhanceImage(data.src);
|
|
break;
|
|
case 'extend':
|
|
result = await imageGenerationService.extendImage(data.src);
|
|
break;
|
|
default:
|
|
setIsProcessing(false);
|
|
return;
|
|
}
|
|
|
|
if (result?.success && result.data?.url) {
|
|
// 创建新的图片节点显示处理结果
|
|
addNode({
|
|
type: 'image',
|
|
position: {
|
|
x: node.position.x + node.size.width + 20,
|
|
y: node.position.y
|
|
},
|
|
size: { width: node.size.width, height: node.size.height },
|
|
data: {
|
|
src: result.data.url,
|
|
status: 'completed',
|
|
prompt: `${action} 处理结果`,
|
|
originalSrc: data.src,
|
|
},
|
|
connections: [node.id],
|
|
});
|
|
|
|
toast.success(`${action} 处理完成`);
|
|
} else {
|
|
toast.error(result?.error || "处理失败");
|
|
}
|
|
} catch (error) {
|
|
console.error(`${action} 处理失败:`, error);
|
|
toast.error("处理失败,请重试");
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
};
|
|
|
|
// 角度编辑完成回调
|
|
const handleAngleEditComplete = (resultUrl: string) => {
|
|
// 创建新的图片节点显示结果
|
|
addNode({
|
|
type: 'image',
|
|
position: {
|
|
x: node.position.x + node.size.width + 20,
|
|
y: node.position.y
|
|
},
|
|
size: { width: node.size.width, height: node.size.height },
|
|
data: {
|
|
src: resultUrl,
|
|
status: 'completed',
|
|
prompt: '角度编辑结果',
|
|
originalSrc: data.src,
|
|
},
|
|
connections: [node.id],
|
|
});
|
|
};
|
|
|
|
// 下载图片
|
|
const handleDownload = () => {
|
|
if (data.src) {
|
|
const link = document.createElement('a');
|
|
link.href = data.src;
|
|
link.download = `image-${node.id}.png`;
|
|
link.click();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={cn(
|
|
"relative w-full h-full bg-card rounded-lg overflow-hidden border shadow-sm",
|
|
isSelected && "border-primary"
|
|
)}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
>
|
|
{/* 图片内容 */}
|
|
{data.src ? (
|
|
<img
|
|
src={data.src}
|
|
alt={data.prompt || "图片"}
|
|
className="w-full h-full object-cover"
|
|
draggable={false}
|
|
/>
|
|
) : data.status === 'generating' ? (
|
|
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
|
|
<span className="text-sm text-muted-foreground">生成中...</span>
|
|
</div>
|
|
) : data.status === 'failed' ? (
|
|
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10">
|
|
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
|
|
<span className="text-sm text-destructive">生成失败</span>
|
|
</div>
|
|
) : (
|
|
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
|
|
<ImagePlus className="h-8 w-8 text-muted-foreground mb-2" />
|
|
<span className="text-sm text-muted-foreground mb-3">点击上传图片</span>
|
|
<label
|
|
className="cursor-pointer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleImageUpload}
|
|
/>
|
|
<span className="px-4 py-2 bg-primary text-primary-foreground text-sm rounded-md hover:bg-primary/90 transition-colors">
|
|
选择文件
|
|
</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{/* 处理中遮罩 */}
|
|
{isProcessing && (
|
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
|
<div className="flex flex-col items-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-white mb-2" />
|
|
<span className="text-sm text-white">处理中...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 悬浮操作栏 */}
|
|
{isHovered && data.src && !isProcessing && (
|
|
<div className="absolute top-2 right-2 flex items-center gap-1">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="secondary" size="icon" className="h-7 w-7">
|
|
<MoreVertical className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => handleAction('rmbg')}>
|
|
<Scissors className="h-4 w-4 mr-2" />
|
|
抠图
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleAction('enhance')}>
|
|
<Sparkles className="h-4 w-4 mr-2" />
|
|
变清晰
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleAction('extend')}>
|
|
<Expand className="h-4 w-4 mr-2" />
|
|
图片扩展
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleAction('resize')}>
|
|
<Maximize2 className="h-4 w-4 mr-2" />
|
|
尺寸调整
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleAction('angle')}>
|
|
<Move3D className="h-4 w-4 mr-2" />
|
|
角度编辑
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleAction('scene-fusion')}>
|
|
<Layers className="h-4 w-4 mr-2" />
|
|
场景裂变
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={handleDownload}>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
下载
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="text-destructive"
|
|
onClick={() => deleteNode(node.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
|
删除
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
|
|
{/* 提示词显示 */}
|
|
{data.prompt && isHovered && !isProcessing && (
|
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
|
|
<p className="text-xs text-white line-clamp-2">{data.prompt}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 角度编辑对话框 */}
|
|
{data.src && (
|
|
<AngleEditDialog
|
|
open={angleDialogOpen}
|
|
onOpenChange={setAngleDialogOpen}
|
|
imageUrl={data.src}
|
|
onComplete={handleAngleEditComplete}
|
|
/>
|
|
)}
|
|
|
|
{/* 场景裂变对话框 */}
|
|
<SceneFusionDialog
|
|
open={sceneFusionDialogOpen}
|
|
onOpenChange={setSceneFusionDialogOpen}
|
|
/>
|
|
</>
|
|
);
|
|
}
|