ai-chatbot/components/preview-attachment.tsx
Your Name 7526a0159c
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: 完善图片编辑功能和UI优化
- 修复浏览器favicon未更新问题(使用settings.favicon)
- 将'图片标注'组件名改为'图片编辑'(所有相关文本和注释)
- 在顶部导航栏添加'图片编辑'按钮,支持上传图片进行编辑
- 图片编辑完成后自动添加到聊天输入框(通过sessionStorage传递)
- 优化图片编辑组件下载按钮UI(更明显的样式,添加文字标签)
- 图片处理结果(变清晰/抠图/图像拓展)自动保存到相册和生图记录
- 优化图片编辑确认逻辑,优先使用处理后的图片
2025-12-29 11:24:47 +08:00

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)}
/>
)}
</>
);
};