"use client"; import React, { useState, useRef, useCallback } from "react"; import { createPortal } from "react-dom"; import { X, Loader2, Check, RefreshCw, 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"; // 图片压缩配置 const PICKER_IMAGE_CONFIG = { maxSize: 600, quality: 0.85, }; // 压缩图片 async function compressImageForPicker(imageUrl: string): Promise<{ compressedDataUrl: string; scale: number; originalWidth: number; originalHeight: number; }> { const img = new Image(); img.crossOrigin = 'anonymous'; return new Promise((resolve, reject) => { img.onload = () => { const { naturalWidth, naturalHeight } = img; let scale = 1; let targetWidth = naturalWidth; let targetHeight = naturalHeight; // 计算缩放比例 if (naturalWidth > PICKER_IMAGE_CONFIG.maxSize || naturalHeight > PICKER_IMAGE_CONFIG.maxSize) { scale = Math.min( PICKER_IMAGE_CONFIG.maxSize / naturalWidth, PICKER_IMAGE_CONFIG.maxSize / naturalHeight ); targetWidth = Math.round(naturalWidth * scale); targetHeight = Math.round(naturalHeight * scale); } // Canvas 压缩 const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight; const ctx = canvas.getContext('2d')!; ctx.drawImage(img, 0, 0, targetWidth, targetHeight); const compressedDataUrl = canvas.toDataURL('image/jpeg', PICKER_IMAGE_CONFIG.quality); resolve({ compressedDataUrl, scale, originalWidth: naturalWidth, originalHeight: naturalHeight, }); }; img.onerror = () => reject(new Error('图片加载失败')); img.src = imageUrl; }); } export function ObjectPickerOverlay() { const isPickerMode = useCanvasStore((state) => state.isPickerMode); const pickerTargetNodeId = useCanvasStore((state) => state.pickerTargetNodeId); const isPickerLoading = useCanvasStore((state) => state.isPickerLoading); const pickedObject = useCanvasStore((state) => state.pickedObject); const setPickerMode = useCanvasStore((state) => state.setPickerMode); const setPickerLoading = useCanvasStore((state) => state.setPickerLoading); const setPickedObject = useCanvasStore((state) => state.setPickedObject); 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(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) => { 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 { // 压缩图片 const { compressedDataUrl } = await compressImageForPicker(imageUrl); // 调用 API const response = await fetch('/api/pick-object', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ imageData: compressedDataUrl, clickX, clickY, originalImageUrl: imageUrl, }), }); 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, }; setPickedObject(picked); toast.success(`识别成功: ${result.object.name}`); } else { toast.error(result.error || '未能识别物品'); setClickPosition(null); } } catch (error) { console.error('物品拾取失败:', error); toast.error('识别失败,请重试'); setClickPosition(null); } finally { setPickerLoading(false); } }, [imageUrl, isPickerLoading, pickerTargetNodeId, setPickerLoading, setPickedObject]); // 确认添加到对话 const handleConfirm = useCallback(() => { if (pickedObject) { addPendingPickedObject(pickedObject); toast.success('已添加到对话框'); setPickerMode(false); setClickPosition(null); } }, [pickedObject, addPendingPickedObject, setPickerMode]); // 重新选择 const handleRetry = useCallback(() => { setPickedObject(null); setClickPosition(null); }, [setPickedObject]); // 关闭拾取模式 const handleClose = useCallback(() => { setPickerMode(false); setClickPosition(null); }, [setPickerMode]); if (!isPickerMode || !imageUrl) return null; return createPortal(
{ // 点击背景关闭 if (e.target === overlayRef.current) { handleClose(); } }} > {/* 顶部提示栏 */}
{isPickerLoading ? '正在识别...' : pickedObject ? '识别完成' : '点击图片中的物品进行拾取'}
{/* 图片容器 */}
{/* 图片 */} 拾取目标 {/* 点击位置标记(脉冲动画) */} {clickPosition && (
{/* 脉冲圆点 */}
)} {/* 边界框显示(识别成功后) */} {pickedObject && (
{/* 物品名称标签 */}
{pickedObject.objectName}
)} {/* 加载状态 */} {isPickerLoading && (
正在识别...
)}
{/* 识别结果确认框 */} {pickedObject && !isPickerLoading && (

{pickedObject.objectName}

{pickedObject.objectPosition}

置信度: {Math.round(pickedObject.confidence * 100)}%

)}
, document.body ); }