476 lines
15 KiB
TypeScript
476 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useRef } 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 [isUploading, setIsUploading] = useState(false);
|
|
|
|
// 文件输入的 ref
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const updateNode = useCanvasStore((state) => state.updateNode);
|
|
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
|
const addNode = useCanvasStore((state) => state.addNode);
|
|
|
|
// 触发文件选择
|
|
const handleSelectFile = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
// 处理图片上传 - 上传到存储桶,不使用 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 = '';
|
|
};
|
|
|
|
// 获取操作的中文名称
|
|
const getActionName = (action: string): string => {
|
|
const names: Record<string, string> = {
|
|
'rmbg': '抠图',
|
|
'enhance': '变清晰',
|
|
'extend': '图片扩展',
|
|
'resize': '尺寸调整',
|
|
'angle': '角度编辑',
|
|
'scene-fusion': '详情替换',
|
|
};
|
|
return names[action] || action;
|
|
};
|
|
|
|
// 处理图片编辑操作 - 集成我们现有的 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;
|
|
}
|
|
|
|
const actionName = getActionName(action);
|
|
|
|
// 先创建一个"处理中"状态的节点,显示在原图右侧
|
|
const newNodeId = 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: '', // 暂时没有图片
|
|
status: 'generating',
|
|
prompt: `${actionName}处理中...`,
|
|
originalSrc: data.src,
|
|
},
|
|
connections: [node.id],
|
|
});
|
|
|
|
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:
|
|
// 删除创建的节点
|
|
if (newNodeId) deleteNode(newNodeId);
|
|
return;
|
|
}
|
|
|
|
if (result?.success && result.data?.url) {
|
|
// 更新节点为完成状态
|
|
if (newNodeId) {
|
|
updateNode(newNodeId, {
|
|
data: {
|
|
src: result.data.url,
|
|
status: 'completed',
|
|
prompt: `${actionName}结果`,
|
|
originalSrc: data.src,
|
|
},
|
|
});
|
|
}
|
|
toast.success(`${actionName}处理完成`);
|
|
} else {
|
|
// 更新节点为失败状态
|
|
if (newNodeId) {
|
|
updateNode(newNodeId, {
|
|
data: {
|
|
src: '',
|
|
status: 'failed',
|
|
prompt: `${actionName}失败`,
|
|
originalSrc: data.src,
|
|
},
|
|
});
|
|
}
|
|
toast.error(result?.error || "处理失败");
|
|
}
|
|
} catch (error) {
|
|
console.error(`${action} 处理失败:`, error);
|
|
// 更新节点为失败状态
|
|
if (newNodeId) {
|
|
updateNode(newNodeId, {
|
|
data: {
|
|
src: '',
|
|
status: 'failed',
|
|
prompt: `${actionName}失败`,
|
|
originalSrc: data.src,
|
|
},
|
|
});
|
|
}
|
|
toast.error("处理失败,请重试");
|
|
}
|
|
};
|
|
|
|
// 角度编辑完成回调
|
|
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>
|
|
{/* 隐藏的文件输入 */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleImageUpload}
|
|
/>
|
|
{/* 选择文件按钮 */}
|
|
<button
|
|
type="button"
|
|
onClick={handleSelectFile}
|
|
className="px-4 py-2 bg-primary text-primary-foreground text-sm rounded-md hover:bg-primary/90 transition-colors cursor-pointer"
|
|
>
|
|
选择文件
|
|
</button>
|
|
</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>
|
|
)}
|
|
|
|
{/* 选中时显示的顶部工具栏 - 和聊天窗口中的功能一致 */}
|
|
{isSelected && data.src && !isProcessing && (
|
|
<div
|
|
className="absolute -top-12 left-1/2 -translate-x-1/2 flex items-center gap-1 bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg p-1.5 z-10"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2 text-xs gap-1"
|
|
onClick={() => handleAction('rmbg')}
|
|
title="抠图"
|
|
>
|
|
<Scissors className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">抠图</span>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2 text-xs gap-1"
|
|
onClick={() => handleAction('enhance')}
|
|
title="变清晰"
|
|
>
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">变清晰</span>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2 text-xs gap-1"
|
|
onClick={() => handleAction('extend')}
|
|
title="扩展"
|
|
>
|
|
<Expand className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">扩展</span>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2 text-xs gap-1"
|
|
onClick={() => handleAction('resize')}
|
|
title="调整"
|
|
>
|
|
<Maximize2 className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">调整</span>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2 text-xs gap-1"
|
|
onClick={() => handleAction('angle')}
|
|
title="角度"
|
|
>
|
|
<Move3D className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">角度</span>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2 text-xs gap-1"
|
|
onClick={() => handleAction('scene-fusion')}
|
|
title="场景"
|
|
>
|
|
<Layers className="h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">场景</span>
|
|
</Button>
|
|
<div className="w-px h-5 bg-border mx-0.5" />
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2 text-xs gap-1"
|
|
onClick={handleDownload}
|
|
title="下载"
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2 text-xs gap-1 text-destructive hover:text-destructive"
|
|
onClick={() => deleteNode(node.id)}
|
|
title="删除"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 悬浮时的更多操作按钮 (右上角) */}
|
|
{isHovered && data.src && !isProcessing && !isSelected && (
|
|
<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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|