381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Image from "next/image";
|
|
import type { Attachment } from "@/lib/types";
|
|
import { Loader } from "./elements/loader";
|
|
import { CrossSmallIcon } from "./icons";
|
|
import { Button } from "./ui/button";
|
|
import { X, Download, Target, Paintbrush } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { ImageAnnotator } from "./image-annotator";
|
|
|
|
export const PreviewAttachment = ({
|
|
attachment,
|
|
isUploading = false,
|
|
onRemove,
|
|
onDragStart,
|
|
onDragEnd,
|
|
onDragOver,
|
|
onDrop,
|
|
draggable = false,
|
|
onAnnotate,
|
|
onDrawingComplete,
|
|
imageIndex,
|
|
}: {
|
|
attachment: Attachment;
|
|
isUploading?: boolean;
|
|
onRemove?: () => void;
|
|
onDragStart?: (e: React.DragEvent) => void;
|
|
onDragEnd?: (e: React.DragEvent) => void;
|
|
onDragOver?: (e: React.DragEvent) => void;
|
|
onDrop?: (e: React.DragEvent) => void;
|
|
draggable?: boolean;
|
|
onAnnotate?: (annotation: {
|
|
imageIndex: number;
|
|
description: string;
|
|
formattedText: string;
|
|
}) => void;
|
|
onDrawingComplete?: (annotatedImageUrl: string) => void;
|
|
imageIndex?: number;
|
|
}) => {
|
|
const { name, url, contentType } = attachment;
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [isAnnotationMode, setIsAnnotationMode] = useState(false);
|
|
const [isDetecting, setIsDetecting] = useState(false);
|
|
const [showAnnotator, setShowAnnotator] = useState(false);
|
|
const [annotationPoints, setAnnotationPoints] = useState<Array<{
|
|
x: number;
|
|
y: number;
|
|
description: string;
|
|
}>>([]);
|
|
|
|
// 下载图片函数
|
|
const handleDownload = async () => {
|
|
try {
|
|
const response = await fetch(url);
|
|
const blob = await response.blob();
|
|
const downloadUrl = window.URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = downloadUrl;
|
|
link.download = name ?? `image-${Date.now()}.jpg`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
window.URL.revokeObjectURL(downloadUrl);
|
|
} catch (error) {
|
|
console.error("下载失败:", error);
|
|
}
|
|
};
|
|
|
|
// 处理图片点击(标注模式)
|
|
const handleImageClick = async (e: React.MouseEvent<HTMLImageElement>) => {
|
|
if (!isAnnotationMode || isDetecting || !onAnnotate) return;
|
|
|
|
const img = e.currentTarget;
|
|
const rect = img.getBoundingClientRect();
|
|
|
|
// 计算点击位置在显示区域的坐标
|
|
const displayClickX = e.clientX - rect.left;
|
|
const displayClickY = e.clientY - rect.top;
|
|
|
|
// 计算缩放比例
|
|
const scaleX = img.naturalWidth / rect.width;
|
|
const scaleY = img.naturalHeight / rect.height;
|
|
|
|
// 转换为原始图片坐标
|
|
const clickX = Math.round(displayClickX * scaleX);
|
|
const clickY = Math.round(displayClickY * scaleY);
|
|
|
|
console.log('[标注调试]', {
|
|
display: { x: displayClickX, y: displayClickY, width: rect.width, height: rect.height },
|
|
original: { x: clickX, y: clickY, width: img.naturalWidth, height: img.naturalHeight },
|
|
scale: { x: scaleX, y: scaleY }
|
|
});
|
|
|
|
setIsDetecting(true);
|
|
toast.info("正在识别物体...");
|
|
|
|
try {
|
|
const response = await fetch("/api/detect-object", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
imageUrl: url,
|
|
clickX,
|
|
clickY,
|
|
imageIndex: imageIndex || 1,
|
|
imageWidth: img.naturalWidth,
|
|
imageHeight: img.naturalHeight,
|
|
}),
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success && result.data) {
|
|
toast.success(`识别成功:${result.data.description}`);
|
|
|
|
// 添加标注点到预览图
|
|
setAnnotationPoints(prev => [...prev, {
|
|
x: displayClickX,
|
|
y: displayClickY,
|
|
description: result.data.description,
|
|
}]);
|
|
|
|
onAnnotate({
|
|
imageIndex: result.data.imageIndex,
|
|
description: result.data.description,
|
|
formattedText: result.data.formattedText,
|
|
});
|
|
|
|
// 退出标注模式但保持弹窗打开,让用户看到标注点
|
|
setIsAnnotationMode(false);
|
|
} else {
|
|
toast.error(result.error || "识别失败,请重试");
|
|
}
|
|
} catch (error) {
|
|
console.error("物体检测失败:", error);
|
|
toast.error("识别失败,请检查网络连接");
|
|
} finally {
|
|
setIsDetecting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={`group relative size-16 overflow-hidden rounded-lg border bg-muted transition-all ${
|
|
draggable && !isUploading ? "cursor-move hover:scale-105 hover:shadow-lg" : "cursor-pointer hover:opacity-90"
|
|
} ${isDragging ? "opacity-50 scale-95" : ""}`}
|
|
data-testid="input-attachment-preview"
|
|
onClick={() => !isUploading && contentType?.startsWith("image") && setIsOpen(true)}
|
|
draggable={draggable && !isUploading}
|
|
onDragStart={(e) => {
|
|
if (draggable && !isUploading) {
|
|
setIsDragging(true);
|
|
onDragStart?.(e);
|
|
}
|
|
}}
|
|
onDragEnd={(e) => {
|
|
setIsDragging(false);
|
|
onDragEnd?.(e);
|
|
}}
|
|
onDragOver={onDragOver}
|
|
onDrop={onDrop}
|
|
>
|
|
{contentType?.startsWith("image") ? (
|
|
<Image
|
|
alt={name ?? "图片附件"}
|
|
className="size-full object-cover"
|
|
height={64}
|
|
src={url}
|
|
width={64}
|
|
/>
|
|
) : (
|
|
<div className="flex size-full items-center justify-center text-muted-foreground text-xs">
|
|
文件
|
|
</div>
|
|
)}
|
|
|
|
{isUploading && (
|
|
<div
|
|
className="absolute inset-0 flex items-center justify-center bg-black/50"
|
|
data-testid="input-attachment-loader"
|
|
>
|
|
<Loader size={16} />
|
|
</div>
|
|
)}
|
|
|
|
{onRemove && !isUploading && (
|
|
<Button
|
|
className="absolute top-0.5 right-0.5 size-4 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRemove();
|
|
}}
|
|
size="sm"
|
|
variant="destructive"
|
|
>
|
|
<CrossSmallIcon size={8} />
|
|
</Button>
|
|
)}
|
|
|
|
<div className="absolute inset-x-0 bottom-0 truncate bg-linear-to-t from-black/80 to-transparent px-1 py-0.5 text-[10px] text-white">
|
|
{name}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 图片放大预览弹窗 */}
|
|
{isOpen && contentType?.startsWith("image") && (
|
|
<div
|
|
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/20 backdrop-blur-sm p-4"
|
|
onClick={() => {
|
|
setIsOpen(false);
|
|
setIsAnnotationMode(false);
|
|
setAnnotationPoints([]);
|
|
}}
|
|
>
|
|
{/* 弹窗卡片 */}
|
|
<div
|
|
className="relative bg-white rounded-xl shadow-2xl overflow-hidden"
|
|
style={{ width: "600px", maxWidth: "90vw" }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* 按钮组 */}
|
|
<div className="absolute right-3 top-3 flex gap-2 z-10">
|
|
{/* 定位标注按钮 */}
|
|
{onAnnotate && (
|
|
<button
|
|
className={`rounded-lg p-2 transition-colors shadow-lg ${
|
|
isAnnotationMode
|
|
? "bg-blue-500 text-white hover:bg-blue-600"
|
|
: "bg-white text-gray-700 hover:bg-gray-100"
|
|
}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsAnnotationMode(!isAnnotationMode);
|
|
if (!isAnnotationMode) {
|
|
toast.info("标注模式:点击图片中的物体进行识别");
|
|
}
|
|
}}
|
|
disabled={isDetecting}
|
|
type="button"
|
|
aria-label="定位标注"
|
|
title="定位标注"
|
|
>
|
|
<Target size={20} />
|
|
</button>
|
|
)}
|
|
|
|
{/* 画图标注按钮 */}
|
|
{onDrawingComplete && (
|
|
<button
|
|
className="rounded-lg bg-white p-2 text-gray-700 shadow-lg transition-colors hover:bg-gray-100"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setShowAnnotator(true);
|
|
setIsOpen(false);
|
|
}}
|
|
type="button"
|
|
aria-label="画图标注"
|
|
title="画图标注"
|
|
>
|
|
<Paintbrush size={20} />
|
|
</button>
|
|
)}
|
|
|
|
{/* 下载按钮 */}
|
|
<button
|
|
className="rounded-lg bg-white p-2 text-gray-700 shadow-lg transition-colors hover:bg-gray-100"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDownload();
|
|
}}
|
|
type="button"
|
|
aria-label="下载图片"
|
|
title="下载图片"
|
|
>
|
|
<Download size={20} />
|
|
</button>
|
|
|
|
{/* 关闭按钮 */}
|
|
<button
|
|
className="rounded-lg bg-white p-2 text-gray-700 shadow-lg transition-colors hover:bg-gray-100"
|
|
onClick={() => {
|
|
setIsOpen(false);
|
|
setIsAnnotationMode(false);
|
|
setAnnotationPoints([]);
|
|
}}
|
|
type="button"
|
|
aria-label="关闭"
|
|
title="关闭"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 标注模式提示 */}
|
|
{isAnnotationMode && (
|
|
<div className="absolute left-1/2 top-16 -translate-x-1/2 transform z-20">
|
|
<div className="rounded-lg bg-blue-500 px-4 py-2 text-sm text-white shadow-lg">
|
|
💡 点击图片中的物体进行标注
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 图片容器 */}
|
|
<div
|
|
className={`relative bg-gray-50 ${
|
|
isDetecting ? "opacity-50" : ""
|
|
}`}
|
|
style={{ height: "500px" }}
|
|
>
|
|
<img
|
|
src={url}
|
|
alt={name ?? "图片附件"}
|
|
className={`w-full h-full object-contain ${
|
|
isAnnotationMode ? "cursor-crosshair" : ""
|
|
}`}
|
|
onClick={handleImageClick}
|
|
/>
|
|
|
|
{/* 标注点显示 */}
|
|
{annotationPoints.map((point, index) => (
|
|
<div
|
|
key={index}
|
|
className="absolute pointer-events-none"
|
|
style={{
|
|
left: `${point.x}px`,
|
|
top: `${point.y}px`,
|
|
transform: 'translate(-50%, -100%)',
|
|
}}
|
|
>
|
|
<div className="relative">
|
|
{/* 标注图标 */}
|
|
<div className="text-3xl drop-shadow-lg animate-bounce">
|
|
📍
|
|
</div>
|
|
{/* 标注描述 */}
|
|
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1 whitespace-nowrap rounded-lg bg-blue-500 px-3 py-1.5 text-xs text-white shadow-lg">
|
|
{point.description}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* 加载状态 */}
|
|
{isDetecting && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-white/80">
|
|
<div className="flex items-center gap-3 rounded-lg bg-gray-900 px-6 py-3 text-white shadow-xl">
|
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
|
<span>正在识别物体...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 底部信息栏 */}
|
|
<div className="px-4 py-3 bg-gray-100 border-t border-gray-200">
|
|
<p className="text-sm text-gray-600 truncate">{name}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 图片标注器 */}
|
|
{showAnnotator && onDrawingComplete && (
|
|
<ImageAnnotator
|
|
imageUrl={url}
|
|
onConfirm={(annotatedImageUrl) => {
|
|
onDrawingComplete(annotatedImageUrl);
|
|
setShowAnnotator(false);
|
|
toast.success("标注图片已添加到聊天");
|
|
}}
|
|
onCancel={() => setShowAnnotator(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|