添加尺寸调整功能,统一图片处理按钮样式

- 新增尺寸调整功能:用户可以自定义画布尺寸、背景颜色、图片位置和缩放
- 创建/api/resize API端点:上传处理后的图片到存储桶并保存到数据库
- 统一图片处理按钮样式:将变清晰、抠图、图像拓展按钮底色改为70%透明黑色
- 支持透明背景和彩色背景的尺寸调整
- 图片可拖拽移动和滚轮缩放
- 处理后的图片自动保存到相册和生图记录
This commit is contained in:
Your Name 2025-12-29 23:02:30 +08:00
parent f85d1732c4
commit bbf9768bc9
3 changed files with 641 additions and 26 deletions

199
app/api/resize/route.ts Normal file
View File

@ -0,0 +1,199 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
/**
* 压缩图片生成缩略图(使用 sharp)
*/
async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer> {
try {
const sharp = await import("sharp");
// 创建缩略图(宽度 500px,高度按比例,质量 80%)
const thumbnailBuffer = await sharp.default(buffer)
.resize(500, null, {
fit: "inside",
withoutEnlargement: true,
})
.png({ quality: 80 }) // PNG格式保持透明背景
.toBuffer();
console.log(`[Resize] Thumbnail created, size: ${thumbnailBuffer.length} bytes (original: ${buffer.length} bytes)`);
return thumbnailBuffer;
} catch (error) {
console.error("[Resize] Failed to create thumbnail:", error);
// 如果失败,返回原图
return buffer;
}
}
/**
* 将 base64 图片上传到 S3,返回缩略图和原图的 URL
*/
async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/png"): Promise<{ thumbnail: string; original: string }> {
try {
// 解析 base64 数据
const buffer = Buffer.from(base64Data, "base64");
// 生成文件名
const ext = mimeType.split("/")[1] || "png";
const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `resize-thumb-${timestamp}-${randomStr}.${ext}`;
const originalFilename = `resize-orig-${timestamp}-${randomStr}.${ext}`;
console.log(`[Resize] Original size: ${buffer.length} bytes`);
// 生成缩略图
const thumbnailBuffer = await createThumbnail(buffer, mimeType);
// 转换为 ArrayBuffer
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
// 并行上传缩略图和原图到 S3
console.log(`[Resize] Uploading thumbnail and original to S3...`);
const [thumbnailResult, originalResult] = await Promise.all([
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
uploadFile(originalFilename, originalArrayBuffer),
]);
console.log(`[Resize] Uploaded to S3:`);
console.log(` - Thumbnail: ${thumbnailResult.url}`);
console.log(` - Original: ${originalResult.url}`);
return {
thumbnail: thumbnailResult.url,
original: originalResult.url,
};
} catch (error) {
console.error("[Resize] Failed to upload to S3:", error);
throw new Error(`图片上传到存储桶失败: ${error instanceof Error ? error.message : "未知错误"}。请检查系统设置中的存储配置。`);
}
}
/**
* 尺寸调整API路由
* 接收前端处理后的Base64图片,上传到S3并保存到数据库
*/
export async function POST(request: NextRequest) {
try {
// 0. 获取用户会话
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "未授权,请先登录" },
{ status: 401 }
);
}
const userId = session.user.id;
// 1. 获取请求体
const contentType = request.headers.get("content-type") || "";
let imageBase64: string;
let prompt = "尺寸调整处理";
let chatId: string | null = null;
let messageId: string | null = null;
let canvasWidth: number;
let canvasHeight: number;
let mimeType = "image/png";
if (contentType.includes("application/json")) {
const body = await request.json();
imageBase64 = body.image_base64;
prompt = body.prompt || "尺寸调整处理";
chatId = body.chatId || null;
messageId = body.messageId || null;
canvasWidth = body.canvasWidth;
canvasHeight = body.canvasHeight;
mimeType = body.mimeType || "image/png";
if (!imageBase64) {
return NextResponse.json(
{ error: "缺少image_base64参数" },
{ status: 400 }
);
}
if (!canvasWidth || !canvasHeight) {
return NextResponse.json(
{ error: "缺少canvasWidth或canvasHeight参数" },
{ status: 400 }
);
}
} else {
return NextResponse.json(
{ error: "不支持的Content-Type,请使用application/json" },
{ status: 400 }
);
}
console.log(`[Resize API] 图片Base64长度: ${imageBase64.length} 字符`);
console.log(`[Resize API] 画布尺寸: ${canvasWidth}x${canvasHeight}`);
// 2. 上传处理后的图片到S3存储桶
let thumbnailUrl: string;
let originalUrl: string;
try {
const uploadResult = await uploadBase64ToS3(imageBase64, mimeType);
thumbnailUrl = uploadResult.thumbnail;
originalUrl = uploadResult.original;
console.log(`[Resize] ✅ Images uploaded to S3 successfully`);
} catch (uploadError) {
console.error("[Resize] ❌ Failed to upload to S3:", uploadError);
return NextResponse.json(
{
error: "图片上传到存储桶失败",
detail: uploadError instanceof Error ? uploadError.message : "未知错误"
},
{ status: 500 }
);
}
// 3. 保存图片记录到数据库(相册和生图记录)
try {
const [savedImage] = await db.insert(generatedImage).values({
userId,
chatId: chatId || null,
messageId: messageId || null,
thumbnailUrl,
originalUrl,
imageSize: `${canvasWidth}x${canvasHeight}`, // 使用实际尺寸
prompt: prompt || "尺寸调整处理",
modelId: "resize-1.0", // 标记为尺寸调整处理
}).returning();
console.log(`[Resize] ✅ 图片已保存到相册和生图记录,ID: ${savedImage.id}`);
} catch (dbError) {
console.error("[Resize] ❌ 保存到数据库失败:", dbError);
// 数据库保存失败时仍然返回成功,但记录错误(图片已上传到S3,用户可以访问)
}
// 4. 返回结果(包含S3 URL和Base64,前端可以选择使用)
return NextResponse.json({
success: true,
image_base64: imageBase64, // 保留Base64用于前端立即显示
thumbnail_url: thumbnailUrl, // S3缩略图URL
original_url: originalUrl, // S3原图URL
canvas_width: canvasWidth,
canvas_height: canvasHeight,
});
} catch (error) {
console.error("[Resize API] 处理错误:", error);
return NextResponse.json(
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
{ status: 500 }
);
}
}

View File

@ -2,8 +2,9 @@
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 } from "lucide-react";
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";
@ -64,8 +65,21 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
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" | 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(() => {
@ -361,10 +375,15 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
const result = await response.json();
if (result.success && result.image_base64) {
// 保存处理后的图片URL和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);
@ -415,10 +434,15 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
const result = await response.json();
if (result.success && result.image_base64) {
// 保存处理后的图片URL和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);
@ -436,6 +460,233 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
}
};
// 尺寸调整功能
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;
@ -471,8 +722,13 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
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);
@ -490,29 +746,83 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
}
};
// 下载处理后的图片(必须使用原图URL)
// 下载处理后的图片(必须使用原图URL或Base64)
const handleDownloadProcessed = async () => {
if (!processedImageUrl) {
toast.error("原图地址不可用,无法下载");
return;
}
try {
// 确定文件类型和MIME类型
const isPng = processType === "rmbg";
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(processedImageUrl);
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error("下载原图失败");
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;
@ -525,7 +835,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
toast.success("原图下载成功");
} catch (error) {
console.error("[下载] 错误:", error);
toast.error("下载失败,请稍后重试");
toast.error(`下载失败: ${error instanceof Error ? error.message : "请稍后重试"}`);
}
};
@ -540,7 +850,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
if (processedImageBase64) {
// 如果有处理后的图片Base64,转换为data URL
const mimeType = processType === "rmbg" ? "image/png" : "image/jpeg";
const mimeType = processType === "rmbg" || processType === "resize" ? "image/png" : "image/jpeg";
const dataUrl = `data:${mimeType};base64,${processedImageBase64}`;
onConfirm(dataUrl);
return;
@ -595,7 +905,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
variant="ghost"
size="sm"
onClick={handleRemoveBackground}
disabled={isRemovingBg || isEnhancing || isExtending || !image}
disabled={isRemovingBg || isEnhancing || isExtending || isResizeMode || !image}
title="移除背景(抠图)"
>
<Scissors className={`mr-1 h-4 w-4 ${isRemovingBg ? "animate-spin" : ""}`} />
@ -605,7 +915,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
variant="ghost"
size="sm"
onClick={handleEnhance}
disabled={isEnhancing || isRemovingBg || isExtending || !image}
disabled={isEnhancing || isRemovingBg || isExtending || isResizeMode || !image}
title="图片变清晰"
>
<Sparkles className={`mr-1 h-4 w-4 ${isEnhancing ? "animate-spin" : ""}`} />
@ -615,12 +925,22 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
variant="ghost"
size="sm"
onClick={handleExtend}
disabled={isExtending || isRemovingBg || isEnhancing || !image}
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" />
撤销
@ -635,7 +955,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
</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>
@ -729,6 +1049,102 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
</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>
) : (
<>
{/* 原图画布 */}
@ -752,7 +1168,7 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
<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 === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : processType === "resize" ? "尺寸调整结果" : "拓展结果"}
</span>
<Button
variant="default"
@ -769,14 +1185,14 @@ export function ImageAnnotator({ imageUrl, onConfirm, onCancel }: ImageAnnotator
{processedImageUrl ? (
<img
src={processedImageUrl}
alt={processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : "拓展结果"}
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" ? "png" : "jpeg"};base64,${processedImageBase64}`}
alt={processType === "rmbg" ? "抠图结果" : processType === "enhance" ? "清晰结果" : "拓展结果"}
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" }}
/>

View File

@ -232,7 +232,7 @@ export function ImagePreview({
<div className="absolute top-2 left-2 flex gap-1.5 z-10">
{onImageEnhance && (
<button
className="flex items-center gap-1.5 rounded-full bg-blue-600/90 hover:bg-blue-700 px-2.5 py-1.5 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
className="flex items-center gap-1.5 rounded-full bg-black/70 hover:bg-black/80 px-2.5 py-1.5 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => {
e.stopPropagation();
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend) {
@ -250,7 +250,7 @@ export function ImagePreview({
)}
{onImageRmbg && (
<button
className="flex items-center gap-1.5 rounded-full bg-purple-600/90 hover:bg-purple-700 px-2.5 py-1.5 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
className="flex items-center gap-1.5 rounded-full bg-black/70 hover:bg-black/80 px-2.5 py-1.5 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => {
e.stopPropagation();
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend) {
@ -268,7 +268,7 @@ export function ImagePreview({
)}
{onImageExtend && (
<button
className="flex items-center gap-1.5 rounded-full bg-orange-600/90 hover:bg-orange-700 px-2.5 py-1.5 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
className="flex items-center gap-1.5 rounded-full bg-black/70 hover:bg-black/80 px-2.5 py-1.5 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
onClick={(e) => {
e.stopPropagation();
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend) {