179 lines
6.3 KiB
TypeScript
179 lines
6.3 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useRef, useCallback } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { X, Loader2, Crosshair } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { useCanvasStore, type PickedObject } from "@/store/canvas";
|
||
import { toast } from "sonner";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
|
||
export function ObjectPickerOverlay() {
|
||
const isPickerMode = useCanvasStore((state) => state.isPickerMode);
|
||
const pickerTargetNodeId = useCanvasStore((state) => state.pickerTargetNodeId);
|
||
const isPickerLoading = useCanvasStore((state) => state.isPickerLoading);
|
||
const setPickerMode = useCanvasStore((state) => state.setPickerMode);
|
||
const setPickerLoading = useCanvasStore((state) => state.setPickerLoading);
|
||
const addPendingPickedObject = useCanvasStore((state) => state.addPendingPickedObject);
|
||
const getNode = useCanvasStore((state) => state.getNode);
|
||
|
||
const [clickPosition, setClickPosition] = useState<{ x: number; y: number } | null>(null);
|
||
const overlayRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 获取目标节点
|
||
const targetNode = pickerTargetNodeId ? getNode(pickerTargetNodeId) : null;
|
||
const imageUrl = targetNode?.type === 'image' ? (targetNode.data as any)?.src as string | undefined : undefined;
|
||
|
||
// 处理图片点击 - 识别成功后直接添加到对话框
|
||
const handleImageClick = useCallback(async (e: React.MouseEvent<HTMLImageElement>) => {
|
||
if (isPickerLoading || !imageUrl || !pickerTargetNodeId) return;
|
||
|
||
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;
|
||
|
||
// 显示点击位置
|
||
setClickPosition({ x: clickX, y: clickY });
|
||
setPickerLoading(true);
|
||
|
||
try {
|
||
// 直接使用原始图片 URL 调用 API(无任何标记)
|
||
const response = await fetch('/api/pick-object', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
imageUrl, // 原始图片 URL,无标记
|
||
clickX,
|
||
clickY,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('识别失败');
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success && result.object) {
|
||
// 构建拾取结果
|
||
const picked: PickedObject = {
|
||
imageUrl,
|
||
imageNodeId: pickerTargetNodeId,
|
||
clickX,
|
||
clickY,
|
||
objectName: result.object.name,
|
||
objectPosition: result.object.position,
|
||
confidence: result.object.confidence,
|
||
boundingBox: result.object.boundingBox,
|
||
};
|
||
|
||
// 直接添加到对话框,不询问确认
|
||
addPendingPickedObject(picked);
|
||
toast.success(`已添加: ${result.object.name}`);
|
||
|
||
// 关闭拾取模式
|
||
setPickerMode(false);
|
||
setClickPosition(null);
|
||
} else {
|
||
toast.error(result.error || '未能识别物品');
|
||
setClickPosition(null);
|
||
}
|
||
} catch (error) {
|
||
console.error('物品拾取失败:', error);
|
||
toast.error('识别失败,请重试');
|
||
setClickPosition(null);
|
||
} finally {
|
||
setPickerLoading(false);
|
||
}
|
||
}, [imageUrl, isPickerLoading, pickerTargetNodeId, setPickerLoading, addPendingPickedObject, setPickerMode]);
|
||
|
||
// 关闭拾取模式
|
||
const handleClose = useCallback(() => {
|
||
setPickerMode(false);
|
||
setClickPosition(null);
|
||
}, [setPickerMode]);
|
||
|
||
if (!isPickerMode || !imageUrl) return null;
|
||
|
||
return createPortal(
|
||
<div
|
||
ref={overlayRef}
|
||
className="fixed inset-0 z-[9998] bg-black/60 flex items-center justify-center"
|
||
onClick={(e) => {
|
||
// 点击背景关闭
|
||
if (e.target === overlayRef.current) {
|
||
handleClose();
|
||
}
|
||
}}
|
||
>
|
||
{/* 顶部提示栏 */}
|
||
<div className="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-3 bg-white rounded-full px-4 py-2 shadow-lg">
|
||
<Crosshair className="h-5 w-5 text-orange-500" />
|
||
<span className="text-sm font-medium">
|
||
{isPickerLoading ? '正在识别...' : '点击图片中的物品进行拾取'}
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 rounded-full"
|
||
onClick={handleClose}
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 图片容器 */}
|
||
<div className="relative max-w-[80vw] max-h-[70vh]">
|
||
{/* 图片 - 原始图片,无任何标记 */}
|
||
<img
|
||
src={imageUrl}
|
||
alt="拾取目标"
|
||
className={cn(
|
||
"max-w-full max-h-[70vh] object-contain rounded-lg border-4 transition-all",
|
||
isPickerLoading ? "border-orange-400 opacity-80" : "border-orange-500",
|
||
!isPickerLoading && "cursor-crosshair"
|
||
)}
|
||
onClick={!isPickerLoading ? handleImageClick : undefined}
|
||
draggable={false}
|
||
/>
|
||
|
||
{/* 点击位置标记(脉冲动画)- 仅在前端显示,不影响发送给 AI 的图片 */}
|
||
{clickPosition && (
|
||
<div
|
||
className="absolute pointer-events-none"
|
||
style={{
|
||
left: `${clickPosition.x * 100}%`,
|
||
top: `${clickPosition.y * 100}%`,
|
||
transform: 'translate(-50%, -50%)',
|
||
}}
|
||
>
|
||
{/* 脉冲圆点 */}
|
||
<div className={cn(
|
||
"relative w-8 h-8 rounded-full border-2 border-orange-500 bg-orange-500/30",
|
||
isPickerLoading && "animate-pulse"
|
||
)}>
|
||
<div className="absolute inset-0 rounded-full bg-orange-500 animate-ping opacity-30" />
|
||
<div className="absolute inset-2 rounded-full bg-orange-500" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 加载状态 */}
|
||
{isPickerLoading && (
|
||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded-lg">
|
||
<div className="bg-white rounded-lg px-4 py-3 flex items-center gap-2 shadow-lg">
|
||
<Loader2 className="h-5 w-5 animate-spin text-orange-500" />
|
||
<span className="text-sm">正在识别...</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
);
|
||
}
|