1066 lines
36 KiB
TypeScript
1066 lines
36 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw, Pencil, MessageSquarePlus, Crosshair, Check, X } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
} from "@/components/ui/dialog";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/ui/tooltip";
|
||
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";
|
||
import { ImageAnnotator } from "@/components/image-annotator";
|
||
import type { PickedObject } from "@/store/canvas";
|
||
|
||
// 已标注的物品标记(本地状态)
|
||
interface PickedMarker {
|
||
clickX: number;
|
||
clickY: number;
|
||
objectName: string;
|
||
index: number;
|
||
}
|
||
|
||
/**
|
||
* 在图片上绘制标记点,返回带标记的图片 base64
|
||
*/
|
||
async function drawMarkerOnImage(
|
||
imageUrl: string,
|
||
clickX: number,
|
||
clickY: number
|
||
): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
|
||
img.onload = () => {
|
||
const canvas = document.createElement('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) {
|
||
reject(new Error('无法创建 canvas'));
|
||
return;
|
||
}
|
||
|
||
// 压缩图片尺寸(最大 800px)
|
||
const maxSize = 800;
|
||
let width = img.width;
|
||
let height = img.height;
|
||
|
||
if (width > maxSize || height > maxSize) {
|
||
if (width > height) {
|
||
height = (height / width) * maxSize;
|
||
width = maxSize;
|
||
} else {
|
||
width = (width / height) * maxSize;
|
||
height = maxSize;
|
||
}
|
||
}
|
||
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
|
||
// 计算标记位置
|
||
const markerX = clickX * width;
|
||
const markerY = clickY * height;
|
||
const markerRadius = Math.max(12, Math.min(width, height) * 0.025);
|
||
|
||
// 绘制红色标记
|
||
ctx.beginPath();
|
||
ctx.arc(markerX, markerY, markerRadius + 3, 0, Math.PI * 2);
|
||
ctx.fillStyle = 'white';
|
||
ctx.fill();
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(markerX, markerY, markerRadius, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#FF0000';
|
||
ctx.fill();
|
||
|
||
// 绘制十字线
|
||
ctx.strokeStyle = '#FF0000';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(markerX - markerRadius * 2, markerY);
|
||
ctx.lineTo(markerX - markerRadius - 2, markerY);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(markerX + markerRadius + 2, markerY);
|
||
ctx.lineTo(markerX + markerRadius * 2, markerY);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(markerX, markerY - markerRadius * 2);
|
||
ctx.lineTo(markerX, markerY - markerRadius - 2);
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(markerX, markerY + markerRadius + 2);
|
||
ctx.lineTo(markerX, markerY + markerRadius * 2);
|
||
ctx.stroke();
|
||
|
||
resolve(canvas.toDataURL('image/jpeg', 0.85));
|
||
};
|
||
|
||
img.onerror = () => reject(new Error('图片加载失败'));
|
||
img.src = imageUrl;
|
||
});
|
||
}
|
||
|
||
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 [resizeDialogOpen, setResizeDialogOpen] = useState(false);
|
||
const [resizeWidth, setResizeWidth] = useState(1024);
|
||
const [resizeHeight, setResizeHeight] = useState(1024);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
||
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
||
|
||
// 文件输入的 ref
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
||
const updateNode = useCanvasStore((state) => state.updateNode);
|
||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||
const addNode = useCanvasStore((state) => state.addNode);
|
||
const addPendingChatAttachment = useCanvasStore((state) => state.addPendingChatAttachment);
|
||
const setPickerMode = useCanvasStore((state) => state.setPickerMode);
|
||
|
||
// 🆕 物品拾取相关状态
|
||
const isPickerMode = useCanvasStore((state) => state.isPickerMode);
|
||
const pickerTargetNodeId = useCanvasStore((state) => state.pickerTargetNodeId);
|
||
const isPickerLoading = useCanvasStore((state) => state.isPickerLoading);
|
||
const setPickerLoading = useCanvasStore((state) => state.setPickerLoading);
|
||
const addPendingPickedObject = useCanvasStore((state) => state.addPendingPickedObject);
|
||
|
||
// 当前节点是否处于拾取模式
|
||
const isThisNodePicking = isPickerMode && pickerTargetNodeId === node.id;
|
||
|
||
// 当前正在识别的点击位置
|
||
const [currentClick, setCurrentClick] = useState<{ x: number; y: number } | null>(null);
|
||
|
||
// 退出拾取模式时清空状态
|
||
useEffect(() => {
|
||
if (!isThisNodePicking) {
|
||
setCurrentClick(null);
|
||
}
|
||
}, [isThisNodePicking]);
|
||
|
||
// 🆕 处理图片点击标注
|
||
const handlePickerClick = useCallback(async (e: React.MouseEvent<HTMLImageElement>) => {
|
||
if (!isThisNodePicking || isPickerLoading || !data.src) return;
|
||
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
|
||
const img = e.currentTarget;
|
||
const rect = img.getBoundingClientRect();
|
||
|
||
// 计算相对坐标 (0-1)
|
||
const clickX = (e.clientX - rect.left) / rect.width;
|
||
const clickY = (e.clientY - rect.top) / rect.height;
|
||
|
||
setCurrentClick({ x: clickX, y: clickY });
|
||
setPickerLoading(true);
|
||
|
||
try {
|
||
// 在图片上绘制红色标记点
|
||
const markedImageBase64 = await drawMarkerOnImage(data.src, clickX, clickY);
|
||
|
||
const response = await fetch('/api/pick-object', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
imageData: markedImageBase64,
|
||
clickX,
|
||
clickY,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) throw new Error('识别失败');
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success && result.object) {
|
||
const picked: PickedObject = {
|
||
imageUrl: data.src,
|
||
imageNodeId: node.id,
|
||
clickX,
|
||
clickY,
|
||
objectName: result.object.name,
|
||
objectPosition: result.object.position,
|
||
confidence: result.object.confidence,
|
||
boundingBox: result.object.boundingBox,
|
||
};
|
||
|
||
addPendingPickedObject(picked);
|
||
|
||
// 标注成功后自动关闭拾取模式,直接生成标签
|
||
setPickerMode(false);
|
||
setCurrentClick(null);
|
||
toast.success(`已添加: ${result.object.name}`);
|
||
} else {
|
||
toast.error(result.error || '未能识别物品');
|
||
setCurrentClick(null);
|
||
}
|
||
} catch (error) {
|
||
console.error('物品拾取失败:', error);
|
||
toast.error('识别失败,请重试');
|
||
setCurrentClick(null);
|
||
} finally {
|
||
setPickerLoading(false);
|
||
}
|
||
}, [isThisNodePicking, isPickerLoading, data.src, node.id, setPickerLoading, addPendingPickedObject, setPickerMode]);
|
||
|
||
// 🆕 取消标注
|
||
const handleCancelPicking = useCallback(() => {
|
||
setPickerMode(false);
|
||
}, [setPickerMode]);
|
||
|
||
// 获取工具栏位置 - 在渲染时直接计算
|
||
const getToolbarPosition = () => {
|
||
if (containerRef.current) {
|
||
const rect = containerRef.current.getBoundingClientRect();
|
||
return {
|
||
top: rect.top - 50,
|
||
left: rect.left + rect.width / 2,
|
||
};
|
||
}
|
||
return { top: 0, left: 0 };
|
||
};
|
||
|
||
// 触发文件选择
|
||
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;
|
||
}
|
||
|
||
// 验证文件大小 (20MB)
|
||
if (file.size > 20 * 1024 * 1024) {
|
||
toast.error('图片大小不能超过 20MB');
|
||
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': '详情替换',
|
||
'annotate': '图片标注',
|
||
};
|
||
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') {
|
||
setResizeDialogOpen(true);
|
||
return;
|
||
}
|
||
if (action === 'annotate') {
|
||
setAnnotatorOpen(true);
|
||
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 handleAnnotateComplete = (annotatedImageUrl: string) => {
|
||
setAnnotatorOpen(false);
|
||
|
||
// 更新当前节点,标记为已标注图片
|
||
// 标注后的图片会带有视觉标记(箭头、圆圈、文字等)
|
||
// 这些标记指示了用户希望 AI 重点关注/修改的区域
|
||
updateNode(node.id, {
|
||
data: {
|
||
...data,
|
||
src: annotatedImageUrl,
|
||
hasAnnotations: true, // 🆕 标记为已标注
|
||
originalSrc: data.originalSrc || data.src, // 保留原始图片
|
||
prompt: data.prompt ? `${data.prompt} (已标注)` : '已标注图片',
|
||
},
|
||
});
|
||
|
||
toast.success('标注已保存!发送对话时将重点处理标注区域');
|
||
};
|
||
|
||
// 尺寸调整执行
|
||
const handleResize = async () => {
|
||
if (!data.src) return;
|
||
|
||
setResizeDialogOpen(false);
|
||
|
||
// 创建处理中节点
|
||
const newNodeId = addNode({
|
||
type: 'image',
|
||
position: {
|
||
x: node.position.x + node.size.width + 20,
|
||
y: node.position.y
|
||
},
|
||
size: { width: resizeWidth / 3, height: resizeHeight / 3 }, // 按比例显示
|
||
data: {
|
||
src: '',
|
||
status: 'generating',
|
||
prompt: `尺寸调整 ${resizeWidth}x${resizeHeight}`,
|
||
originalSrc: data.src,
|
||
},
|
||
connections: [node.id],
|
||
});
|
||
|
||
try {
|
||
const result = await imageGenerationService.resizeImage(data.src, resizeWidth, resizeHeight);
|
||
|
||
if (result?.success && result.data?.url) {
|
||
updateNode(newNodeId, {
|
||
data: {
|
||
src: result.data.url,
|
||
status: 'completed',
|
||
prompt: `尺寸调整 ${resizeWidth}x${resizeHeight}`,
|
||
originalSrc: data.src,
|
||
},
|
||
});
|
||
toast.success('尺寸调整完成');
|
||
} else {
|
||
updateNode(newNodeId, {
|
||
data: {
|
||
src: '',
|
||
status: 'failed',
|
||
prompt: '尺寸调整失败',
|
||
originalSrc: data.src,
|
||
},
|
||
});
|
||
toast.error(result?.error || '尺寸调整失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('尺寸调整失败:', error);
|
||
updateNode(newNodeId, {
|
||
data: {
|
||
src: '',
|
||
status: 'failed',
|
||
prompt: '尺寸调整失败',
|
||
originalSrc: data.src,
|
||
},
|
||
});
|
||
toast.error('尺寸调整失败');
|
||
}
|
||
};
|
||
|
||
// 下载图片 - 通过 fetch 获取图片数据实现真正的下载(避免跨域问题导致跳转)
|
||
const handleDownload = async () => {
|
||
if (!data.src) return;
|
||
|
||
try {
|
||
toast.info('正在准备下载...');
|
||
|
||
// 通过 fetch 获取图片数据
|
||
const response = await fetch(data.src);
|
||
const blob = await response.blob();
|
||
|
||
// 创建 Blob URL
|
||
const blobUrl = URL.createObjectURL(blob);
|
||
|
||
// 创建下载链接
|
||
const link = document.createElement('a');
|
||
link.href = blobUrl;
|
||
|
||
// 从 URL 中提取文件扩展名,默认使用 png
|
||
const urlParts = data.src.split('.');
|
||
const ext = urlParts[urlParts.length - 1]?.split('?')[0] || 'png';
|
||
link.download = `image-${node.id}.${ext}`;
|
||
|
||
// 触发下载
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
|
||
// 释放 Blob URL
|
||
URL.revokeObjectURL(blobUrl);
|
||
|
||
toast.success('下载成功');
|
||
} catch (error) {
|
||
console.error('下载失败:', error);
|
||
toast.error('下载失败,请重试');
|
||
|
||
// 降级方案:直接打开图片
|
||
window.open(data.src, '_blank');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{/* 选中时在图片上方显示标题和尺寸 */}
|
||
{isSelected && data.src && (
|
||
<div className="absolute -top-7 left-0 right-0 flex items-center justify-between text-xs text-muted-foreground px-1 z-10">
|
||
<div className="flex items-center gap-1">
|
||
<ImagePlus className="h-3 w-3" />
|
||
<span className="truncate max-w-[150px]">{data.prompt || '图片'}</span>
|
||
</div>
|
||
<span>{node.size.width} × {node.size.height}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
ref={containerRef}
|
||
className={cn(
|
||
"relative w-full h-full bg-card rounded-lg overflow-hidden border-2 shadow-sm transition-colors",
|
||
isSelected ? "border-primary" : "border-transparent"
|
||
)}
|
||
onMouseEnter={() => setIsHovered(true)}
|
||
onMouseLeave={() => setIsHovered(false)}
|
||
>
|
||
{/* 图片内容 - 使用 object-contain 保持原始比例 */}
|
||
{data.src ? (
|
||
<div className="relative w-full h-full">
|
||
<img
|
||
src={data.src}
|
||
alt={data.prompt || "图片"}
|
||
className={cn(
|
||
"w-full h-full object-contain bg-muted/30",
|
||
isThisNodePicking ? "cursor-crosshair" : "cursor-pointer"
|
||
)}
|
||
draggable={false}
|
||
onClick={isThisNodePicking ? handlePickerClick : undefined}
|
||
onDoubleClick={(e) => {
|
||
if (!isThisNodePicking) {
|
||
e.stopPropagation();
|
||
setFullscreenPreviewOpen(true);
|
||
}
|
||
}}
|
||
/>
|
||
|
||
{/* 🆕 拾取模式:当前点击位置(正在识别) */}
|
||
{isThisNodePicking && currentClick && (
|
||
<div
|
||
className="absolute pointer-events-none"
|
||
style={{
|
||
left: `${currentClick.x * 100}%`,
|
||
top: `${currentClick.y * 100}%`,
|
||
transform: 'translate(-50%, -50%)',
|
||
}}
|
||
>
|
||
<div className="w-6 h-6 rounded-full border-2 border-orange-500 border-dashed animate-spin" />
|
||
</div>
|
||
)}
|
||
|
||
{/* 🆕 拾取模式:状态栏 */}
|
||
{isThisNodePicking && (
|
||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex items-center gap-2 bg-white rounded-full px-3 py-1.5 shadow-lg">
|
||
<Crosshair className="h-4 w-4 text-orange-500" />
|
||
<span className="text-xs font-medium">
|
||
{isPickerLoading ? '正在识别...' : '点击标注物品'}
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-6 w-6 rounded-full hover:bg-gray-100"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleCancelPicking();
|
||
}}
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : 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>
|
||
)}
|
||
|
||
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
|
||
|
||
{/* 悬浮时的更多操作按钮 (右上角) */}
|
||
{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}
|
||
/>
|
||
|
||
{/* 尺寸调整对话框 */}
|
||
<Dialog open={resizeDialogOpen} onOpenChange={setResizeDialogOpen}>
|
||
<DialogContent className="sm:max-w-[400px]">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Maximize2 className="h-5 w-5" />
|
||
尺寸调整
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="grid gap-4 py-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="width">宽度 (px)</Label>
|
||
<Input
|
||
id="width"
|
||
type="number"
|
||
value={resizeWidth}
|
||
onChange={(e) => setResizeWidth(parseInt(e.target.value) || 1024)}
|
||
min={100}
|
||
max={4096}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="height">高度 (px)</Label>
|
||
<Input
|
||
id="height"
|
||
type="number"
|
||
value={resizeHeight}
|
||
onChange={(e) => setResizeHeight(parseInt(e.target.value) || 1024)}
|
||
min={100}
|
||
max={4096}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(512); setResizeHeight(512); }}>512x512</Button>
|
||
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(1024); setResizeHeight(1024); }}>1024x1024</Button>
|
||
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(1920); setResizeHeight(1080); }}>1920x1080</Button>
|
||
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(1080); setResizeHeight(1920); }}>1080x1920</Button>
|
||
<Button variant="outline" size="sm" onClick={() => { setResizeWidth(2048); setResizeHeight(2048); }}>2048x2048</Button>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setResizeDialogOpen(false)}>取消</Button>
|
||
<Button onClick={handleResize}>确定</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 工具栏使用 Portal 渲染到 body,避免被裁剪 */}
|
||
{isSelected && data.src && !isProcessing && typeof document !== 'undefined' && createPortal(
|
||
<TooltipProvider delayDuration={200}>
|
||
<div
|
||
className="fixed flex items-center gap-0.5 bg-white border border-gray-200 rounded-full shadow-xl px-2 py-1 z-[9999]"
|
||
style={{
|
||
top: getToolbarPosition().top,
|
||
left: getToolbarPosition().left,
|
||
transform: 'translateX(-50%)',
|
||
pointerEvents: 'auto',
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* 添加到对话 */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-blue-50 text-blue-600 hover:text-blue-700"
|
||
onClick={() => {
|
||
addPendingChatAttachment({
|
||
url: data.src!,
|
||
name: data.prompt || '画布图片',
|
||
contentType: 'image/jpeg',
|
||
});
|
||
toast.success('已添加到对话框');
|
||
}}
|
||
>
|
||
<MessageSquarePlus className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">添加到对话</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{/* 🆕 物品拾取 */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-orange-50 text-orange-600 hover:text-orange-700"
|
||
onClick={() => {
|
||
setPickerMode(true, node.id);
|
||
toast.info('点击图片中的物品进行拾取');
|
||
}}
|
||
>
|
||
<Crosshair className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">拾取物品</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{/* 分隔线 */}
|
||
<div className="h-5 w-px bg-gray-200 mx-0.5" />
|
||
|
||
{/* 放大/高清 */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-gray-100"
|
||
onClick={() => handleAction('enhance')}
|
||
>
|
||
<span className="text-[10px] font-medium text-gray-500 border border-gray-300 rounded px-1">HD</span>
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">高清放大</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{/* 移除背景/抠图 */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-gray-100"
|
||
onClick={() => handleAction('rmbg')}
|
||
>
|
||
<Scissors className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">移除背景</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{/* 场景融合 */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-gray-100"
|
||
onClick={() => handleAction('scene-fusion')}
|
||
>
|
||
<Layers className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">场景融合</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{/* 角度编辑 */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-gray-100"
|
||
onClick={() => handleAction('angle')}
|
||
>
|
||
<Move3D className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">角度编辑</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{/* 扩展 */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-gray-100"
|
||
onClick={() => handleAction('extend')}
|
||
>
|
||
<Expand className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">图片扩展</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{/* 尺寸调整 */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-gray-100"
|
||
onClick={() => handleAction('resize')}
|
||
>
|
||
<Maximize2 className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">尺寸调整</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{/* 图片标注 */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className={cn("h-8 w-8 rounded-full hover:bg-gray-100", data.hasAnnotations && "text-primary")}
|
||
onClick={() => handleAction('annotate')}
|
||
>
|
||
<Pencil className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">标注重点区域</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{/* 下载 */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-gray-100"
|
||
onClick={handleDownload}
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">下载图片</TooltipContent>
|
||
</Tooltip>
|
||
|
||
{/* 删除 */}
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 rounded-full hover:bg-red-50 text-red-500 hover:text-red-600"
|
||
onClick={() => deleteNode(node.id)}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom" className="text-xs">删除</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</TooltipProvider>,
|
||
document.body
|
||
)}
|
||
|
||
{/* 图片标注器 */}
|
||
{annotatorOpen && data.src && (
|
||
<ImageAnnotator
|
||
imageUrl={data.src}
|
||
onConfirm={handleAnnotateComplete}
|
||
onCancel={() => setAnnotatorOpen(false)}
|
||
/>
|
||
)}
|
||
|
||
{/* 双击放大预览弹窗 */}
|
||
{fullscreenPreviewOpen && data.src && createPortal(
|
||
<div
|
||
className="fixed inset-0 z-[9999] bg-black/90 flex items-center justify-center cursor-zoom-out"
|
||
onClick={() => setFullscreenPreviewOpen(false)}
|
||
>
|
||
{/* 关闭按钮 */}
|
||
<button
|
||
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
||
onClick={() => setFullscreenPreviewOpen(false)}
|
||
>
|
||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
|
||
{/* 图片 */}
|
||
<img
|
||
src={data.src}
|
||
alt={data.prompt || "图片预览"}
|
||
className="max-w-[95vw] max-h-[95vh] object-contain"
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
|
||
{/* 底部提示 */}
|
||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
|
||
点击任意位置关闭
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</>
|
||
);
|
||
}
|