ai-chatbot/components/image-annotator.tsx
Your Name bbf9768bc9
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
添加尺寸调整功能,统一图片处理按钮样式
- 新增尺寸调整功能:用户可以自定义画布尺寸、背景颜色、图片位置和缩放
- 创建/api/resize API端点:上传处理后的图片到存储桶并保存到数据库
- 统一图片处理按钮样式:将变清晰、抠图、图像拓展按钮底色改为70%透明黑色
- 支持透明背景和彩色背景的尺寸调整
- 图片可拖拽移动和滚轮缩放
- 处理后的图片自动保存到相册和生图记录
2025-12-29 23:02:30 +08:00

1245 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useRef, useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, Pencil, Circle, ArrowRight, Type, Check, Undo, Trash2, Scissors, Sparkles, Download, Expand, Move, Maximize2 } from "lucide-react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { toast } from "sonner";
type Tool = "pen" | "arrow" | "circle" | "text";
type DrawingAction = {
type: Tool;
color: string;
lineWidth: number;
points?: { x: number; y: number }[];
startX?: number;
startY?: number;
endX?: number;
endY?: number;
radius?: number;
text?: string;
fontSize?: number;
};
interface ImageAnnotatorProps {
imageUrl: string;
onConfirm: (annotatedImageUrl: string) => void;
onCancel: () => void;
}
const COLORS = [
"#FF0000", // 红色
"#FF6B00", // 橙色
"#FFCC00", // 黄色
"#00CC00", // 绿色
"#0099FF", // 蓝色
"#9933FF", // 紫色
"#FF0099", // 粉色
"#FFFFFF", // 白色
"#000000", // 黑色
];
const LINE_WIDTHS = [2, 4, 6, 8];
export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotatorProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [ctx, setCtx] = useState<CanvasRenderingContext2D | null>(null);
const [image, setImage] = useState<HTMLImageElement | null>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [tool, setTool] = useState<Tool>("pen");
const [color, setColor] = useState("#FF0000");
const [lineWidth, setLineWidth] = useState(4);
const [actions, setActions] = useState<DrawingAction[]>([]);
const [currentAction, setCurrentAction] = useState<DrawingAction | null>(null);
const [textInput, setTextInput] = useState("");
const [textPosition, setTextPosition] = useState<{ x: number; y: number } | null>(null);
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [isLoading, setIsLoading] = useState(true);
const [isRemovingBg, setIsRemovingBg] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [isExtending, setIsExtending] = useState(false);
const [originalImageUrl, setOriginalImageUrl] = useState<string>(imageUrl);
const [processedImageUrl, setProcessedImageUrl] = useState<string | null>(null);
const [processedImageBase64, setProcessedImageBase64] = useState<string | null>(null);
const [processType, setProcessType] = useState<"rmbg" | "enhance" | "extend" | "resize" | null>(null);
const [mounted, setMounted] = useState(false);
// 尺寸调整相关状态
const [isResizeMode, setIsResizeMode] = useState(false);
const [resizeCanvasWidth, setResizeCanvasWidth] = useState<number>(1920);
const [resizeCanvasHeight, setResizeCanvasHeight] = useState<number>(1080);
const [resizeBackgroundColor, setResizeBackgroundColor] = useState<string>("#FFFFFF");
const [resizeBackgroundTransparent, setResizeBackgroundTransparent] = useState<boolean>(false);
const [resizeImageScale, setResizeImageScale] = useState<number>(1);
const [resizeImagePosition, setResizeImagePosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [isDraggingResize, setIsDraggingResize] = useState(false);
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
const resizeCanvasRef = useRef<HTMLCanvasElement>(null);
const resizeContainerRef = useRef<HTMLDivElement>(null);
// 确保在客户端渲染
useEffect(() => {
setMounted(true);
}, []);
// 加载图片
useEffect(() => {
setIsLoading(true);
setOriginalImageUrl(imageUrl);
setProcessedImageUrl(null);
setProcessedImageBase64(null);
setProcessType(null);
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setImage(img);
setIsLoading(false);
};
img.onerror = () => {
setIsLoading(false);
};
img.src = imageUrl;
}, [imageUrl]);
// 初始化 canvas - 适配弹窗大小,固定编辑器大小,图片缩小显示
useEffect(() => {
if (!canvasRef.current || !image || !containerRef.current) return;
const canvas = canvasRef.current;
const container = containerRef.current;
const context = canvas.getContext("2d");
if (!context) return;
// 固定弹窗内画布区域大小(减去工具栏和边距,考虑并排显示)
// 弹窗固定为 1200px x 550px,画布区域约为 1100px x 350px(单图)或 550px x 350px(双图)
const containerWidth = processedImageBase64 ? 550 : 1100; // 如果有处理后图片,每个区域550px;否则1100px
const containerHeight = 350; // 550 - 工具栏高度(约200px)
const scaleX = containerWidth / image.width;
const scaleY = containerHeight / image.height;
const newScale = Math.min(scaleX, scaleY, 1); // 不放大,只缩小以适应固定容器
setScale(newScale);
canvas.width = image.width * newScale;
canvas.height = image.height * newScale;
// 计算居中偏移
const offsetX = (containerWidth - canvas.width) / 2;
const offsetY = (containerHeight - canvas.height) / 2;
setOffset({ x: Math.max(0, offsetX), y: Math.max(0, offsetY) });
setCtx(context);
redrawCanvas(context, image, newScale, []);
}, [image]);
// 重绘画布
const redrawCanvas = useCallback(
(context: CanvasRenderingContext2D, img: HTMLImageElement, currentScale: number, drawActions: DrawingAction[]) => {
const canvas = context.canvas;
context.clearRect(0, 0, canvas.width, canvas.height);
// 绘制图片
context.drawImage(img, 0, 0, canvas.width, canvas.height);
// 绘制所有动作
for (const action of drawActions) {
drawAction(context, action, currentScale);
}
},
[]
);
// 当 actions 变化时重绘
useEffect(() => {
if (ctx && image) {
redrawCanvas(ctx, image, scale, actions);
}
}, [actions, ctx, image, scale, redrawCanvas]);
// 绘制单个动作
const drawAction = (context: CanvasRenderingContext2D, action: DrawingAction, currentScale: number) => {
context.strokeStyle = action.color;
context.fillStyle = action.color;
context.lineWidth = action.lineWidth;
context.lineCap = "round";
context.lineJoin = "round";
switch (action.type) {
case "pen":
if (action.points && action.points.length > 0) {
context.beginPath();
context.moveTo(action.points[0].x * currentScale, action.points[0].y * currentScale);
for (let i = 1; i < action.points.length; i++) {
context.lineTo(action.points[i].x * currentScale, action.points[i].y * currentScale);
}
context.stroke();
}
break;
case "arrow":
if (action.startX !== undefined && action.startY !== undefined && action.endX !== undefined && action.endY !== undefined) {
const sx = action.startX * currentScale;
const sy = action.startY * currentScale;
const ex = action.endX * currentScale;
const ey = action.endY * currentScale;
// 绘制线条
context.beginPath();
context.moveTo(sx, sy);
context.lineTo(ex, ey);
context.stroke();
// 绘制箭头
const angle = Math.atan2(ey - sy, ex - sx);
const arrowLength = 15 + action.lineWidth * 2;
context.beginPath();
context.moveTo(ex, ey);
context.lineTo(ex - arrowLength * Math.cos(angle - Math.PI / 6), ey - arrowLength * Math.sin(angle - Math.PI / 6));
context.lineTo(ex - arrowLength * Math.cos(angle + Math.PI / 6), ey - arrowLength * Math.sin(angle + Math.PI / 6));
context.closePath();
context.fill();
}
break;
case "circle":
if (action.startX !== undefined && action.startY !== undefined && action.radius !== undefined) {
context.beginPath();
context.arc(action.startX * currentScale, action.startY * currentScale, action.radius * currentScale, 0, 2 * Math.PI);
context.stroke();
}
break;
case "text":
if (action.startX !== undefined && action.startY !== undefined && action.text) {
const fontSize = (action.fontSize || 20) * currentScale;
context.font = `bold ${fontSize}px sans-serif`;
context.fillText(action.text, action.startX * currentScale, action.startY * currentScale);
}
break;
}
};
// 获取鼠标/触摸位置(转换为原始图片坐标)
const getPosition = (e: React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
let clientX: number;
let clientY: number;
if ("touches" in e) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
// 转换为原始图片坐标
return {
x: (clientX - rect.left) / scale,
y: (clientY - rect.top) / scale,
};
};
// 开始绘制
const handleStart = (e: React.MouseEvent | React.TouchEvent) => {
if (tool === "text") {
const pos = getPosition(e);
setTextPosition(pos);
return;
}
setIsDrawing(true);
const pos = getPosition(e);
const newAction: DrawingAction = {
type: tool,
color,
lineWidth,
};
if (tool === "pen") {
newAction.points = [pos];
} else {
newAction.startX = pos.x;
newAction.startY = pos.y;
newAction.endX = pos.x;
newAction.endY = pos.y;
if (tool === "circle") {
newAction.radius = 0;
}
}
setCurrentAction(newAction);
};
// 绘制中
const handleMove = (e: React.MouseEvent | React.TouchEvent) => {
if (!isDrawing || !currentAction || !ctx || !image) return;
const pos = getPosition(e);
const updatedAction = { ...currentAction };
if (tool === "pen" && updatedAction.points) {
updatedAction.points = [...updatedAction.points, pos];
} else if (tool === "arrow") {
updatedAction.endX = pos.x;
updatedAction.endY = pos.y;
} else if (tool === "circle" && updatedAction.startX !== undefined && updatedAction.startY !== undefined) {
const dx = pos.x - updatedAction.startX;
const dy = pos.y - updatedAction.startY;
updatedAction.radius = Math.sqrt(dx * dx + dy * dy);
}
setCurrentAction(updatedAction);
// 实时重绘
redrawCanvas(ctx, image, scale, [...actions, updatedAction]);
};
// 结束绘制
const handleEnd = () => {
if (!isDrawing || !currentAction) return;
setIsDrawing(false);
setActions((prev) => [...prev, currentAction]);
setCurrentAction(null);
};
// 添加文字
const handleAddText = () => {
if (!textInput.trim() || !textPosition) return;
const textAction: DrawingAction = {
type: "text",
color,
lineWidth,
startX: textPosition.x,
startY: textPosition.y,
text: textInput,
fontSize: 20 + lineWidth * 2,
};
setActions((prev) => [...prev, textAction]);
setTextInput("");
setTextPosition(null);
};
// 撤销
const handleUndo = () => {
setActions((prev) => prev.slice(0, -1));
};
// 清空
const handleClear = () => {
setActions([]);
};
// 抠图功能
const handleRemoveBackground = async () => {
if (!image || !canvasRef.current) return;
setIsRemovingBg(true);
try {
// 获取当前画布的图片(包含标注)
const canvas = canvasRef.current;
const currentImageData = canvas.toDataURL("image/png");
// 提取Base64数据(去掉data:image/png;base64,前缀)
const base64Data = currentImageData.split(",")[1];
// 调用抠图API
const response = await fetch("/api/rmbg", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: "画图标注-抠图处理", // 可选:添加提示词
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "抠图失败");
}
const result = await response.json();
if (result.success && result.image_base64) {
// 保存处理后的图片URL和Base64(优先使用original_url,确保下载的是原图)
if (result.original_url) {
console.log("[抠图] 原图URL:", result.original_url);
setProcessedImageUrl(result.original_url);
} else if (result.thumbnail_url) {
console.warn("[抠图] ⚠️ 未找到original_url,使用thumbnail_url作为备选");
setProcessedImageUrl(result.thumbnail_url);
} else {
console.warn("[抠图] ⚠️ 未找到图片URL");
setProcessedImageUrl(null);
}
setProcessedImageBase64(result.image_base64);
setProcessType("rmbg");
toast.success(`抠图完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || "抠图处理失败");
}
} catch (error) {
console.error("[抠图] 错误:", error);
toast.error(error instanceof Error ? error.message : "抠图失败,请稍后重试");
} finally {
setIsRemovingBg(false);
}
};
// 变清晰处理
const handleEnhance = async () => {
if (!image || !canvasRef.current) return;
setIsEnhancing(true);
try {
// 获取当前画布的图片(包含标注)
const canvas = canvasRef.current;
const currentImageData = canvas.toDataURL("image/png");
// 提取Base64数据(去掉data:image/png;base64,前缀)
const base64Data = currentImageData.split(",")[1];
// 调用变清晰API
const response = await fetch("/api/enhance", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: "画图标注-变清晰处理", // 可选:添加提示词
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "变清晰处理失败");
}
const result = await response.json();
if (result.success && result.image_base64) {
// 保存处理后的图片URL和Base64(优先使用original_url,确保下载的是原图)
if (result.original_url) {
console.log("[变清晰] 原图URL:", result.original_url);
setProcessedImageUrl(result.original_url);
} else if (result.thumbnail_url) {
console.warn("[变清晰] ⚠️ 未找到original_url,使用thumbnail_url作为备选");
setProcessedImageUrl(result.thumbnail_url);
} else {
console.warn("[变清晰] ⚠️ 未找到图片URL");
setProcessedImageUrl(null);
}
setProcessedImageBase64(result.image_base64);
setProcessType("enhance");
toast.success(`变清晰完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || "变清晰处理失败");
}
} catch (error) {
console.error("[变清晰] 错误:", error);
toast.error(error instanceof Error ? error.message : "变清晰失败,请稍后重试");
} finally {
setIsEnhancing(false);
}
};
// 尺寸调整功能
const handleResize = () => {
if (!image) return;
setIsResizeMode(true);
// 初始化尺寸为原图尺寸
setResizeCanvasWidth(image.width);
setResizeCanvasHeight(image.height);
// 初始化图片位置为画布中心
setResizeImagePosition({ x: 0, y: 0 });
setResizeImageScale(1);
};
// 尺寸调整:处理图片拖拽
const handleResizeImageDragStart = (e: React.MouseEvent | React.TouchEvent) => {
if (!isResizeMode || !resizeCanvasRef.current) return;
e.stopPropagation();
setIsDraggingResize(true);
const canvas = resizeCanvasRef.current;
const rect = canvas.getBoundingClientRect();
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
// 计算相对于画布的坐标
const canvasX = ((clientX - rect.left) / rect.width) * canvas.width;
const canvasY = ((clientY - rect.top) / rect.height) * canvas.height;
// 计算画布中心到图片中心的偏移(考虑显示缩放)
const containerWidth = resizeContainerRef.current?.clientWidth || canvas.width;
const containerHeight = resizeContainerRef.current?.clientHeight || canvas.height;
const canvasAspect = resizeCanvasWidth / resizeCanvasHeight;
const containerAspect = containerWidth / containerHeight;
const displayScale = canvasAspect > containerAspect
? (containerWidth / resizeCanvasWidth)
: (containerHeight / resizeCanvasHeight);
// 保存拖拽起始点(相对于画布中心)
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const offsetX = (canvasX - centerX) / displayScale - resizeImagePosition.x;
const offsetY = (canvasY - centerY) / displayScale - resizeImagePosition.y;
setDragStart({ x: offsetX, y: offsetY });
};
const handleResizeImageDrag = (e: React.MouseEvent | React.TouchEvent) => {
if (!isDraggingResize || !dragStart || !resizeCanvasRef.current) return;
e.preventDefault();
e.stopPropagation();
const canvas = resizeCanvasRef.current;
const rect = canvas.getBoundingClientRect();
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
// 计算相对于画布的坐标
const canvasX = ((clientX - rect.left) / rect.width) * canvas.width;
const canvasY = ((clientY - rect.top) / rect.height) * canvas.height;
// 计算显示缩放
const containerWidth = resizeContainerRef.current?.clientWidth || canvas.width;
const containerHeight = resizeContainerRef.current?.clientHeight || canvas.height;
const canvasAspect = resizeCanvasWidth / resizeCanvasHeight;
const containerAspect = containerWidth / containerHeight;
const displayScale = canvasAspect > containerAspect
? (containerWidth / resizeCanvasWidth)
: (containerHeight / resizeCanvasHeight);
// 计算新的图片位置(相对于画布中心)
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const newX = ((canvasX - centerX) / displayScale) - dragStart.x;
const newY = ((canvasY - centerY) / displayScale) - dragStart.y;
setResizeImagePosition({ x: newX, y: newY });
};
const handleResizeImageDragEnd = () => {
setIsDraggingResize(false);
setDragStart(null);
};
// 尺寸调整:处理图片缩放(鼠标滚轮)
const handleResizeImageWheel = (e: React.WheelEvent) => {
if (!isResizeMode || !image) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
const newScale = Math.max(0.1, Math.min(5, resizeImageScale + delta));
setResizeImageScale(newScale);
};
// 尺寸调整:渲染预览
useEffect(() => {
if (!isResizeMode || !resizeCanvasRef.current || !image || !resizeContainerRef.current) return;
const canvas = resizeCanvasRef.current;
const container = resizeContainerRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// 计算画布显示尺寸(适应容器)
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
const canvasAspect = resizeCanvasWidth / resizeCanvasHeight;
const containerAspect = containerWidth / containerHeight;
let displayWidth: number;
let displayHeight: number;
let displayScale: number;
if (canvasAspect > containerAspect) {
displayWidth = containerWidth;
displayHeight = containerWidth / canvasAspect;
displayScale = displayWidth / resizeCanvasWidth;
} else {
displayHeight = containerHeight;
displayWidth = containerHeight * canvasAspect;
displayScale = displayHeight / resizeCanvasHeight;
}
canvas.width = displayWidth;
canvas.height = displayHeight;
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制背景(如果设置了背景颜色且不是透明)
if (!resizeBackgroundTransparent) {
ctx.fillStyle = resizeBackgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// 绘制图片(考虑缩放和位置)
const imageDisplayWidth = image.width * resizeImageScale * displayScale;
const imageDisplayHeight = image.height * resizeImageScale * displayScale;
const imageX = (canvas.width / 2) + (resizeImagePosition.x * displayScale);
const imageY = (canvas.height / 2) + (resizeImagePosition.y * displayScale);
// 绘制图片(不设置透明度,保持原图清晰度)
ctx.drawImage(
image,
imageX - imageDisplayWidth / 2,
imageY - imageDisplayHeight / 2,
imageDisplayWidth,
imageDisplayHeight
);
}, [isResizeMode, resizeCanvasWidth, resizeCanvasHeight, resizeBackgroundColor, resizeBackgroundTransparent, resizeImageScale, resizeImagePosition, image]);
// 尺寸调整:导出为指定尺寸的图片并上传到存储桶
const handleResizeConfirm = async () => {
if (!image || !isResizeMode) return;
try {
// 创建指定尺寸的画布
const exportCanvas = document.createElement("canvas");
exportCanvas.width = resizeCanvasWidth;
exportCanvas.height = resizeCanvasHeight;
const exportCtx = exportCanvas.getContext("2d");
if (!exportCtx) return;
// 绘制背景
if (!resizeBackgroundTransparent) {
exportCtx.fillStyle = resizeBackgroundColor;
exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
}
// 计算图片在画布上的实际尺寸和位置
const imageWidth = image.width * resizeImageScale;
const imageHeight = image.height * resizeImageScale;
const imageX = (exportCanvas.width / 2) + resizeImagePosition.x;
const imageY = (exportCanvas.height / 2) + resizeImagePosition.y;
// 绘制图片
exportCtx.drawImage(
image,
imageX - imageWidth / 2,
imageY - imageHeight / 2,
imageWidth,
imageHeight
);
// 导出为图片
const mimeType = resizeBackgroundTransparent ? "image/png" : "image/jpeg";
const dataUrl = exportCanvas.toDataURL(mimeType, 0.95);
const base64Data = dataUrl.split(",")[1];
// 调用API上传到存储桶并保存到数据库
const response = await fetch("/api/resize", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: `尺寸调整处理 - ${resizeCanvasWidth}x${resizeCanvasHeight}`,
canvasWidth: resizeCanvasWidth,
canvasHeight: resizeCanvasHeight,
mimeType: mimeType,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "上传失败");
}
const result = await response.json();
if (result.success) {
// 保存处理后的图片URL和Base64(优先使用original_url,确保下载的是原图)
if (result.original_url) {
console.log("[尺寸调整] 原图URL:", result.original_url);
setProcessedImageUrl(result.original_url);
} else {
setProcessedImageUrl(null);
}
setProcessedImageBase64(base64Data);
setProcessType("resize");
setIsResizeMode(false);
toast.success(`尺寸调整完成!图片已调整为 ${resizeCanvasWidth}x${resizeCanvasHeight} 像素,已保存到相册`);
} else {
throw new Error(result.error || "尺寸调整处理失败");
}
} catch (error) {
console.error("[尺寸调整] 错误:", error);
toast.error(error instanceof Error ? error.message : "尺寸调整失败,请稍后重试");
}
};
// 图像拓展处理
const handleExtend = async () => {
if (!image || !canvasRef.current) return;
setIsExtending(true);
try {
// 获取当前画布的图片(包含标注)
const canvas = canvasRef.current;
const currentImageData = canvas.toDataURL("image/png");
// 提取Base64数据(去掉data:image/png;base64,前缀)
const base64Data = currentImageData.split(",")[1];
// 调用图像拓展API
const response = await fetch("/api/extend", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: "画图标注-图像拓展处理", // 可选:添加提示词
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "图像拓展处理失败");
}
const result = await response.json();
if (result.success && result.base64) {
// 保存处理后的图片URL和Base64(优先使用original_url,确保下载的是原图)
if (result.original_url) {
console.log("[图像拓展] 原图URL:", result.original_url);
setProcessedImageUrl(result.original_url);
} else if (result.thumbnail_url) {
console.warn("[图像拓展] ⚠️ 未找到original_url,使用thumbnail_url作为备选");
setProcessedImageUrl(result.thumbnail_url);
} else {
console.warn("[图像拓展] ⚠️ 未找到图片URL");
setProcessedImageUrl(null);
}
setProcessedImageBase64(result.base64);
setProcessType("extend");
toast.success(`图像拓展完成!图片已保存到相册。处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`);
} else {
throw new Error(result.error || "图像拓展处理失败");
}
} catch (error) {
console.error("[图像拓展] 错误:", error);
toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试");
} finally {
setIsExtending(false);
}
};
// 下载处理后的图片(必须使用原图URL或Base64)
const handleDownloadProcessed = async () => {
try {
// 确定文件类型和MIME类型
const isPng = processType === "rmbg" || processType === "resize";
const fileExtension = isPng ? "png" : "jpg";
const mimeType = isPng ? "image/png" : "image/jpeg";
const fileName = processType === "rmbg"
? `抠图-${Date.now()}.${fileExtension}`
: processType === "enhance"
? `变清晰-${Date.now()}.${fileExtension}`
: processType === "resize"
? `尺寸调整-${resizeCanvasWidth}x${resizeCanvasHeight}-${Date.now()}.${fileExtension}`
: `图像拓展-${Date.now()}.${fileExtension}`;
// 如果尺寸调整使用Base64,直接下载(优先使用URL,如果没有URL则使用Base64)
if (processType === "resize") {
if (processedImageUrl) {
// 如果有URL,使用URL下载
const response = await fetch(processedImageUrl);
if (!response.ok) {
throw new Error(`下载失败: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast.success("图片下载成功");
return;
} else if (processedImageBase64) {
// 如果没有URL,使用Base64下载
const dataUrl = `data:${mimeType};base64,${processedImageBase64}`;
const link = document.createElement("a");
link.href = dataUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success("图片下载成功");
return;
} else {
toast.error("图片数据不可用,无法下载");
return;
}
}
// 其他情况使用URL下载
if (!processedImageUrl) {
toast.error("原图地址不可用,无法下载");
return;
}
// 确保使用的是原图URL(检查URL中是否包含thumb标识)
let downloadUrl = processedImageUrl;
if (downloadUrl.includes("-thumb") || downloadUrl.includes("/thumb/") || downloadUrl.includes("thumbnail")) {
// 如果URL包含thumb标识,尝试转换为原图URL
downloadUrl = downloadUrl
.replace(/-thumb-/g, "-orig-")
.replace(/\/thumb\//g, "/orig/")
.replace(/thumbnail/g, "original");
console.warn("[下载] ⚠️ 检测到缩略图URL,已转换为原图URL:", downloadUrl);
}
console.log("[下载] 下载URL:", downloadUrl);
// 从原图URL下载(原图已上传到S3)
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`下载原图失败: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
console.log("[下载] 下载的图片大小:", blob.size, "bytes");
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast.success("原图下载成功");
} catch (error) {
console.error("[下载] 错误:", error);
toast.error(`下载失败: ${error instanceof Error ? error.message : "请稍后重试"}`);
}
};
// 确认
const handleConfirm = () => {
// 优先使用处理后的图片(如果有)
if (processedImageUrl) {
// 如果有处理后的图片URL,直接使用它
onConfirm(processedImageUrl);
return;
}
if (processedImageBase64) {
// 如果有处理后的图片Base64,转换为data URL
const mimeType = processType === "rmbg" || processType === "resize" ? "image/png" : "image/jpeg";
const dataUrl = `data:${mimeType};base64,${processedImageBase64}`;
onConfirm(dataUrl);
return;
}
// 否则,导出标注后的图片
const canvas = canvasRef.current;
if (!canvas || !image) return;
// 创建一个新的 canvas 用于导出原始尺寸
const exportCanvas = document.createElement("canvas");
exportCanvas.width = image.width;
exportCanvas.height = image.height;
const exportCtx = exportCanvas.getContext("2d");
if (!exportCtx) return;
// 绘制原始图片
exportCtx.drawImage(image, 0, 0);
// 绘制所有动作(使用原始尺寸)
for (const action of actions) {
drawAction(exportCtx, action, 1);
}
// 导出为 data URL
const dataUrl = exportCanvas.toDataURL("image/png");
onConfirm(dataUrl);
};
// 弹窗内容
const modalContent = (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70 backdrop-blur-md p-4"
onClick={onCancel}
>
{/* 弹窗容器 - 使用与preview-attachment相同的居中方式 */}
<div
className="flex flex-col overflow-hidden rounded-xl border border-border bg-background shadow-2xl"
style={{
width: "1200px",
height: "550px",
maxWidth: "95vw",
maxHeight: "90vh"
}}
onClick={(e) => e.stopPropagation()}
>
{/* 顶部标题栏 */}
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-base font-medium">图片编辑</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleRemoveBackground}
disabled={isRemovingBg || isEnhancing || isExtending || isResizeMode || !image}
title="移除背景(抠图)"
>
<Scissors className={`mr-1 h-4 w-4 ${isRemovingBg ? "animate-spin" : ""}`} />
{isRemovingBg ? "处理中..." : "抠图"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleEnhance}
disabled={isEnhancing || isRemovingBg || isExtending || isResizeMode || !image}
title="图片变清晰"
>
<Sparkles className={`mr-1 h-4 w-4 ${isEnhancing ? "animate-spin" : ""}`} />
{isEnhancing ? "处理中..." : "变清晰"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleExtend}
disabled={isExtending || isRemovingBg || isEnhancing || isResizeMode || !image}
title="图像拓展"
>
<Expand className={`mr-1 h-4 w-4 ${isExtending ? "animate-spin" : ""}`} />
{isExtending ? "处理中..." : "图像拓展"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleResize}
disabled={isResizeMode || isRemovingBg || isEnhancing || isExtending || !image}
title="尺寸调整"
>
<Maximize2 className="mr-1 h-4 w-4" />
尺寸调整
</Button>
<Button variant="ghost" size="sm" onClick={handleUndo} disabled={actions.length === 0}>
<Undo className="mr-1 h-4 w-4" />
撤销
</Button>
<Button variant="ghost" size="sm" onClick={handleClear} disabled={actions.length === 0}>
<Trash2 className="mr-1 h-4 w-4" />
清空
</Button>
<Button variant="ghost" size="sm" onClick={onCancel}>
<X className="mr-1 h-4 w-4" />
取消
</Button>
<Button size="sm" onClick={handleConfirm} className="bg-primary text-primary-foreground hover:bg-primary/90">
<Check className="mr-1 h-4 w-4" />
发送到新对话
</Button>
</div>
</div>
{/* 工具选择栏 */}
<div className="flex flex-wrap items-center justify-center gap-3 border-b px-4 py-2">
{/* 工具按钮 */}
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "pen" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("pen")}
title="画笔"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "arrow" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("arrow")}
title="箭头"
>
<ArrowRight className="h-4 w-4" />
</button>
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "circle" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("circle")}
title="圆圈"
>
<Circle className="h-4 w-4" />
</button>
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${tool === "text" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"}`}
onClick={() => setTool("text")}
title="文字"
>
<Type className="h-4 w-4" />
</button>
</div>
{/* 颜色选择 */}
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
{COLORS.map((c) => (
<button
key={c}
type="button"
className={`h-6 w-6 rounded-full border-2 transition-transform ${color === c ? "scale-110 border-primary" : "border-transparent hover:scale-105"}`}
style={{ backgroundColor: c }}
onClick={() => setColor(c)}
title={c}
/>
))}
</div>
{/* 线宽选择 */}
<div className="flex items-center gap-1 rounded-lg bg-muted p-1">
{LINE_WIDTHS.map((w) => (
<button
key={w}
type="button"
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${lineWidth === w ? "bg-primary/20" : "hover:bg-muted-foreground/10"}`}
onClick={() => setLineWidth(w)}
title={`线宽 ${w}px`}
>
<div className="rounded-full bg-foreground" style={{ width: w + 2, height: w + 2 }} />
</button>
))}
</div>
</div>
{/* 画布区域 - 并排显示原图和处理后的图片 */}
<div ref={containerRef} className="flex flex-1 items-center justify-center gap-4 overflow-hidden bg-muted/30 p-4">
{isLoading ? (
/* 加载动画 */
<div className="flex flex-col items-center justify-center gap-3">
<div className="flex items-center gap-1">
<span
className="h-2 w-2 rounded-full bg-primary animate-bounce"
style={{ animationDelay: "0ms" }}
/>
<span
className="h-2 w-2 rounded-full bg-primary animate-bounce"
style={{ animationDelay: "150ms" }}
/>
<span
className="h-2 w-2 rounded-full bg-primary animate-bounce"
style={{ animationDelay: "300ms" }}
/>
</div>
<span className="text-sm text-muted-foreground">正在加载图片...</span>
</div>
) : isResizeMode ? (
/* 尺寸调整模式 */
<div className="flex flex-1 flex-col items-center justify-center gap-4 w-full h-full">
{/* 尺寸调整控制面板 */}
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg w-full max-w-2xl">
<div className="flex items-center gap-2">
<label className="text-sm font-medium">宽度:</label>
<Input
type="number"
value={resizeCanvasWidth}
onChange={(e) => setResizeCanvasWidth(Number.parseInt(e.target.value) || 1)}
className="w-24 h-8"
min={1}
max={10000}
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium">高度:</label>
<Input
type="number"
value={resizeCanvasHeight}
onChange={(e) => setResizeCanvasHeight(Number.parseInt(e.target.value) || 1)}
className="w-24 h-8"
min={1}
max={10000}
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium">背景:</label>
<input
type="color"
value={resizeBackgroundColor}
onChange={(e) => setResizeBackgroundColor(e.target.value)}
disabled={resizeBackgroundTransparent}
className="w-12 h-8 rounded border cursor-pointer disabled:opacity-50"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium flex items-center gap-1">
<input
type="checkbox"
checked={resizeBackgroundTransparent}
onChange={(e) => setResizeBackgroundTransparent(e.target.checked)}
className="w-4 h-4"
/>
透明
</label>
</div>
<div className="flex items-center gap-2 ml-auto">
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsResizeMode(false);
setResizeImageScale(1);
setResizeImagePosition({ x: 0, y: 0 });
}}
>
取消
</Button>
<Button
size="sm"
onClick={handleResizeConfirm}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
确认调整
</Button>
</div>
</div>
{/* 尺寸调整预览画布 */}
<div
ref={resizeContainerRef}
className="flex-1 w-full max-w-4xl border-2 border-dashed border-primary/50 rounded-lg bg-muted/30 p-4 overflow-auto"
onWheel={handleResizeImageWheel}
>
<div className="relative inline-block">
<canvas
ref={resizeCanvasRef}
className="cursor-move rounded-lg shadow-lg"
onMouseDown={handleResizeImageDragStart}
onMouseMove={handleResizeImageDrag}
onMouseUp={handleResizeImageDragEnd}
onMouseLeave={handleResizeImageDragEnd}
onTouchStart={handleResizeImageDragStart}
onTouchMove={handleResizeImageDrag}
onTouchEnd={handleResizeImageDragEnd}
/>
<div className="absolute top-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded">
画布: {resizeCanvasWidth} x {resizeCanvasHeight} | 缩放: {(resizeImageScale * 100).toFixed(0)}% | 拖拽移动图片,滚轮缩放
</div>
</div>
</div>
</div>
) : (
<>
{/* 原图画布 */}
<div className="flex flex-1 flex-col items-center justify-center">
<div className="mb-2 text-xs text-muted-foreground">原图</div>
<canvas
ref={canvasRef}
className="cursor-crosshair rounded-lg shadow-lg max-w-full"
onMouseDown={handleStart}
onMouseMove={handleMove}
onMouseUp={handleEnd}
onMouseLeave={handleEnd}
onTouchStart={handleStart}
onTouchMove={handleMove}
onTouchEnd={handleEnd}
/>
</div>
{/* 处理后的图片展示区域 */}
{processedImageBase64 && (
<div className="flex flex-1 flex-col items-center justify-center">
<div className="mb-2 flex items-center justify-between gap-2 w-full">
<span className="text-xs text-muted-foreground">
{processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : processType === "resize" ? "尺寸调整结果" : "拓展结果"}
</span>
<Button
variant="default"
size="sm"
onClick={handleDownloadProcessed}
className="h-8 px-3 bg-primary text-primary-foreground hover:bg-primary/90 shadow-md flex items-center gap-1.5 font-medium"
title="下载处理后的图片"
>
<Download className="h-4 w-4" />
<span className="text-xs">下载</span>
</Button>
</div>
<div className="relative rounded-lg border-2 border-primary/50 bg-background p-2 shadow-lg">
{processedImageUrl ? (
<img
src={processedImageUrl}
alt={processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : processType === "resize" ? "尺寸调整结果" : "拓展结果"}
className="max-h-[350px] max-w-full rounded-lg"
style={{ maxWidth: canvasRef.current?.width || "400px" }}
/>
) : processedImageBase64 ? (
<img
src={`data:image/${processType === "rmbg" || processType === "resize" ? "png" : "jpeg"};base64,${processedImageBase64}`}
alt={processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : processType === "resize" ? "尺寸调整结果" : "拓展结果"}
className="max-h-[350px] max-w-full rounded-lg"
style={{ maxWidth: canvasRef.current?.width || "400px" }}
/>
) : null}
</div>
</div>
)}
</>
)}
</div>
</div>
{/* 文字输入弹窗 */}
{textPosition && (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={() => setTextPosition(null)}>
<div className="rounded-lg border bg-background p-4 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="mb-3 text-sm font-medium">输入标注文字</div>
<input
type="text"
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddText();
}}
className="mb-3 w-64 rounded-md border bg-muted px-3 py-2 text-foreground outline-none focus:border-primary"
placeholder="输入文字..."
autoFocus
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setTextPosition(null)}>
取消
</Button>
<Button size="sm" onClick={handleAddText} disabled={!textInput.trim()}>
添加
</Button>
</div>
</div>
</div>
)}
</div>
);
// 使用 Portal 渲染到 document.body,避免受父元素定位影响
if (!mounted) {
return null;
}
return createPortal(modalContent, document.body);
}