"use client"; import React, { useState, useRef, useCallback } from "react"; import { createPortal } from "react-dom"; import { X, Loader2, Crosshair, Check } 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"; import { buildImageProxyUrl } from "@/lib/image-delivery"; // 已标注的物品(本地状态,用于显示标记) interface PickedMarker { clickX: number; clickY: number; objectName: string; index: number; } /** * 在图片上绘制标记点,返回带标记的图片 base64 * AI 通过看到这个红色标记来识别用户点击的位置 */ async function drawMarkerOnImage( imageUrl: string, clickX: number, clickY: number ): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; const loadUrl = buildImageProxyUrl(imageUrl, { delivery: "proxy" }); img.onload = () => { // 创建 canvas const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) { reject(new Error('无法创建 canvas')); return; } // 压缩图片尺寸(最大 800px),加快 AI 分析速度 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(); // 绘制十字线(帮助 AI 定位) 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(); // 转为 base64 const markedImageBase64 = canvas.toDataURL('image/jpeg', 0.85); resolve(markedImageBase64); }; img.onerror = () => { reject(new Error('图片加载失败')); }; img.src = loadUrl; }); } 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 [currentClick, setCurrentClick] = useState<{ x: number; y: number } | null>(null); // 已成功标注的物品列表(本次会话中) const [pickedMarkers, setPickedMarkers] = useState([]); 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; // 显示点击位置 setCurrentClick({ x: clickX, y: clickY }); setPickerLoading(true); try { // 在图片上绘制红色标记点,让 AI 能看到用户点击的位置 const markedImageBase64 = await drawMarkerOnImage(imageUrl, clickX, clickY); // 发送带标记的图片给 AI 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) { // 构建拾取结果(保存原始图片 URL,不是带标记的) 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); // 添加到已标注列表(用于显示标记) setPickedMarkers(prev => [...prev, { clickX, clickY, objectName: result.object.name, index: prev.length + 1, }]); toast.success(`已添加: ${result.object.name},可继续标注其他物品`); // 清除当前点击位置,但不关闭拾取模式 setCurrentClick(null); } else { toast.error(result.error || '未能识别物品'); setCurrentClick(null); } } catch (error) { console.error('物品拾取失败:', error); toast.error('识别失败,请重试'); setCurrentClick(null); } finally { setPickerLoading(false); } }, [imageUrl, isPickerLoading, pickerTargetNodeId, setPickerLoading, addPendingPickedObject]); // 完成标注,关闭拾取模式 const handleFinish = useCallback(() => { setPickerMode(false); setCurrentClick(null); setPickedMarkers([]); if (pickedMarkers.length > 0) { toast.success(`已完成 ${pickedMarkers.length} 个物品标注`); } }, [setPickerMode, pickedMarkers.length]); // 关闭拾取模式(不保存) const handleClose = useCallback(() => { setPickerMode(false); setCurrentClick(null); setPickedMarkers([]); }, [setPickerMode]); // 🆕 改为直接在 ImageNode 中标注,不再使用全屏覆盖层 // 保留此组件以便将来可能需要切换回覆盖层模式 return null; // 原覆盖层代码(已禁用) if (!isPickerMode || !imageUrl) return null; return createPortal(
{ // 点击背景不关闭,需要点击完成按钮 if (e.target === overlayRef.current && pickedMarkers.length === 0) { handleClose(); } }} > {/* 顶部提示栏 */}
{isPickerLoading ? '正在识别...' : pickedMarkers.length > 0 ? `已标注 ${pickedMarkers.length} 个物品` : '点击图片中的物品进行标注'}
{/* 图片容器 */}
{/* 图片 - 原始图片,无任何标记 */} 拾取目标 {/* 已标注的物品标记 */} {pickedMarkers.map((marker) => (
{/* 序号标记 */}
{marker.index}
{/* 物品名称 */}
{marker.objectName}
))} {/* 当前正在识别的点击位置标记(脉冲动画) */} {currentClick && (
{/* 脉冲圆点 */}
)} {/* 加载状态 */} {isPickerLoading && (
正在识别...
)}
{/* 底部工具栏:已标注物品列表 + 完成按钮 */} {pickedMarkers.length > 0 && (
已标注: {pickedMarkers.map((marker) => ( {marker.index} {marker.objectName} ))}
{/* 完成按钮 - 黑色风格 */}
)}
, document.body ); }