feat: 添加去水印功能 - 图片工具栏新增去水印按钮,系统设置新增PicWish API配置

This commit is contained in:
Your Name 2026-02-03 10:32:54 +08:00
parent ff359fe03b
commit f2d7aff7c6
7 changed files with 459 additions and 5 deletions

View File

@ -447,6 +447,11 @@ export function AdminSettingsClient() {
yScale: "1.5", // Y轴放大倍数
});
// 去水印API配置状态
const [watermarkRemoveConfig, setWatermarkRemoveConfig] = useState({
apiKey: "", // PicWish API密钥
});
// 角度编辑API配置状态
const [angleEditConfig, setAngleEditConfig] = useState({
enabled: false,
@ -531,6 +536,19 @@ export function AdminSettingsClient() {
}
}, [extendConfigData]);
// 加载去水印API配置
const { data: watermarkRemoveConfigData } = useSWR("/api/admin/settings?key=watermark_remove_config", fetcher);
useEffect(() => {
if (watermarkRemoveConfigData?.setting?.value) {
const config = watermarkRemoveConfigData.setting.value as {
apiKey?: string;
};
setWatermarkRemoveConfig({
apiKey: config.apiKey || "",
});
}
}, [watermarkRemoveConfigData]);
// 加载角度编辑API配置
const { data: angleEditConfigData } = useSWR("/api/admin/settings?key=angle_edit_config", fetcher);
useEffect(() => {
@ -1013,6 +1031,32 @@ export function AdminSettingsClient() {
}
};
// --- 去水印API配置逻辑 ---
const handleSaveWatermarkRemoveConfig = async () => {
try {
if (!watermarkRemoveConfig.apiKey.trim()) {
toast.error("请输入PicWish API密钥");
return;
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: "watermark_remove_config",
value: {
apiKey: watermarkRemoveConfig.apiKey.trim(),
},
}),
});
if (!response.ok) throw new Error("保存失败");
toast.success("去水印API配置保存成功");
} catch (error) {
toast.error("保存去水印API配置失败");
}
};
// --- 角度编辑API配置逻辑 ---
const handleSaveAngleEditConfig = async () => {
try {
@ -4559,6 +4603,49 @@ export function AdminSettingsClient() {
</CardContent>
</Card>
{/* 去水印API配置 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
<line x1="4" y1="4" x2="20" y2="20" />
</svg>
去水印API配置
</CardTitle>
<CardDescription>
配置PicWish去水印API,用于自动去除图片中的水印
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="space-y-2">
<Label htmlFor="watermarkRemoveApiKey">API密钥 *</Label>
<Input
id="watermarkRemoveApiKey"
type="password"
value={watermarkRemoveConfig.apiKey}
onChange={(e) => setWatermarkRemoveConfig({ ...watermarkRemoveConfig, apiKey: e.target.value })}
placeholder="输入PicWish API密钥"
/>
<div className="text-xs text-muted-foreground">
在 PicWish 开发者平台获取API密钥
</div>
</div>
<div className="rounded-lg border border-purple-200 bg-purple-50 p-4 space-y-2">
<div className="text-sm font-medium text-purple-900">PicWish API 说明</div>
<div className="text-xs text-purple-800 space-y-1">
<p>• 使用 PicWish 云服务进行智能去水印</p>
<p>• 支持各种类型的水印去除(文字、图片水印)</p>
<p>• API文档:https://picwish.com/developer</p>
<p>• 处理时间:约10-30秒/张</p>
</div>
</div>
<Button onClick={handleSaveWatermarkRemoveConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</CardContent>
</Card>
{/* 角度编辑API配置 */}
<Card>
<CardHeader>

View File

@ -0,0 +1,219 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { systemSettings } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { uploadFile } from "@/lib/storage/service";
// 获取去水印API配置
async function getWatermarkRemoveConfig() {
try {
const setting = await db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, "watermark_remove_config"))
.limit(1);
if (setting.length > 0 && setting[0].value) {
return setting[0].value as { apiKey: string };
}
return null;
} catch (error) {
console.error("[WatermarkRemove] 获取配置失败:", error);
return null;
}
}
// 轮询获取处理结果
async function pollForResult(taskId: string, apiKey: string): Promise<{ success: boolean; imageUrl?: string; error?: string }> {
const maxAttempts = 30; // 最多轮询30次(30秒)
const pollInterval = 1000; // 每次间隔1秒
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const response = await fetch(
`https://techsz.aoscdn.com/api/tasks/visual/external/watermark-remove/${taskId}`,
{
method: "GET",
headers: {
"X-API-KEY": apiKey,
},
}
);
if (!response.ok) {
const errorText = await response.text();
console.error(`[WatermarkRemove] 轮询失败 (attempt ${attempt + 1}):`, errorText);
// 继续轮询
await new Promise(resolve => setTimeout(resolve, pollInterval));
continue;
}
const result = await response.json();
console.log(`[WatermarkRemove] 轮询结果 (attempt ${attempt + 1}):`, JSON.stringify(result).substring(0, 200));
// 检查任务状态
if (result.data?.state === 1) {
// 任务完成
const imageUrl = result.data?.image;
if (imageUrl) {
return { success: true, imageUrl };
}
return { success: false, error: "处理完成但未返回图片URL" };
} else if (result.data?.state === -1) {
// 任务失败
return { success: false, error: result.data?.error || "去水印处理失败" };
}
// state === 0 表示处理中,继续轮询
await new Promise(resolve => setTimeout(resolve, pollInterval));
} catch (error) {
console.error(`[WatermarkRemove] 轮询错误 (attempt ${attempt + 1}):`, error);
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
}
return { success: false, error: "处理超时,请稍后重试" };
}
export async function POST(request: NextRequest) {
try {
// 验证用户登录
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const startTime = Date.now();
// 获取请求参数
const body = await request.json();
const { image_url, image_base64 } = body;
if (!image_url && !image_base64) {
return NextResponse.json(
{ error: "请提供图片URL或Base64数据" },
{ status: 400 }
);
}
// 获取API配置
const config = await getWatermarkRemoveConfig();
if (!config?.apiKey) {
return NextResponse.json(
{ error: "去水印服务未配置,请联系管理员在系统设置中配置API密钥" },
{ status: 500 }
);
}
// 如果传入的是base64,需要先上传到S3获取URL
let imageUrl = image_url;
if (image_base64 && !image_url) {
try {
const buffer = Buffer.from(image_base64, "base64");
const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
const filename = `watermark-temp-${Date.now()}.jpeg`;
const uploadResult = await uploadFile(filename, arrayBuffer);
imageUrl = uploadResult.url;
console.log("[WatermarkRemove] Base64图片已上传到S3:", imageUrl);
} catch (uploadError) {
console.error("[WatermarkRemove] 上传图片失败:", uploadError);
return NextResponse.json(
{ error: "上传图片失败" },
{ status: 500 }
);
}
}
console.log("[WatermarkRemove] 开始处理图片:", imageUrl);
// 创建去水印任务
const formData = new FormData();
formData.append("sync", "0");
formData.append("url", imageUrl);
const createResponse = await fetch(
"https://techsz.aoscdn.com/api/tasks/visual/external/watermark-remove",
{
method: "POST",
headers: {
"X-API-KEY": config.apiKey,
},
body: formData,
}
);
if (!createResponse.ok) {
const errorText = await createResponse.text();
console.error("[WatermarkRemove] 创建任务失败:", errorText);
return NextResponse.json(
{ error: "创建去水印任务失败" },
{ status: 500 }
);
}
const createResult = await createResponse.json();
console.log("[WatermarkRemove] 创建任务结果:", JSON.stringify(createResult).substring(0, 200));
const taskId = createResult.data?.task_id;
if (!taskId) {
return NextResponse.json(
{ error: "未获取到任务ID" },
{ status: 500 }
);
}
// 轮询获取结果
const pollResult = await pollForResult(taskId, config.apiKey);
if (!pollResult.success) {
return NextResponse.json(
{ error: pollResult.error || "去水印处理失败" },
{ status: 500 }
);
}
// 下载处理后的图片并上传到S3
const processedImageUrl = pollResult.imageUrl!;
console.log("[WatermarkRemove] 处理完成,结果URL:", processedImageUrl);
try {
// 下载处理后的图片
const imageResponse = await fetch(processedImageUrl);
if (!imageResponse.ok) {
throw new Error("下载处理后的图片失败");
}
const imageBuffer = await imageResponse.arrayBuffer();
// 上传到S3
const filename = `watermark-removed-${Date.now()}.png`;
const uploadResult = await uploadFile(filename, imageBuffer);
const processingTime = (Date.now() - startTime) / 1000;
console.log(`[WatermarkRemove] 处理完成,耗时: ${processingTime}s`);
return NextResponse.json({
success: true,
original_url: uploadResult.url,
thumbnail_url: uploadResult.url,
processing_time: processingTime,
});
} catch (uploadError) {
console.error("[WatermarkRemove] 上传结果失败:", uploadError);
// 如果上传失败,直接返回原始URL
const processingTime = (Date.now() - startTime) / 1000;
return NextResponse.json({
success: true,
original_url: processedImageUrl,
thumbnail_url: processedImageUrl,
processing_time: processingTime,
});
}
} catch (error) {
console.error("[WatermarkRemove] 处理错误:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "去水印处理失败" },
{ status: 500 }
);
}
}

View File

@ -123,6 +123,7 @@ export function Chat({
const [isProcessingRmbg, setIsProcessingRmbg] = useState(false);
const [isProcessingExtend, setIsProcessingExtend] = useState(false);
const [isProcessingAngleEdit, setIsProcessingAngleEdit] = useState(false);
const [isProcessingWatermarkRemove, setIsProcessingWatermarkRemove] = useState(false);
const currentModelIdRef = useRef(currentModelId);
// 🆕 视频参数状态
@ -889,6 +890,110 @@ export function Chat({
}
};
// 处理图片去水印
const handleImageWatermarkRemove = async (imageUrl: string) => {
// 防止重复点击
if (isProcessingEnhance || isProcessingRmbg || isProcessingExtend || isProcessingWatermarkRemove) {
toast.info("正在处理中,请稍候...");
return;
}
setIsProcessingWatermarkRemove(true);
const messageId = generateUUID();
// 创建临时加载消息
const loadingMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: "正在去水印..." }],
createdAt: new Date().toISOString(),
} as ChatMessage;
setMessages((prev) => [...prev, loadingMessage]);
// 延迟滚动到底部,确保DOM已更新
setTimeout(() => {
const messagesContainer = document.querySelector('[role="log"]');
if (messagesContainer) {
messagesContainer.scrollTo({
top: messagesContainer.scrollHeight,
behavior: "smooth",
});
}
}, 100);
try {
// 调用去水印API
const watermarkRemoveResponse = await fetch("/api/watermark-remove", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_url: imageUrl,
}),
});
if (!watermarkRemoveResponse.ok) {
const error = await watermarkRemoveResponse.json();
throw new Error(error.error || "去水印处理失败");
}
const result = await watermarkRemoveResponse.json();
if (result.success && result.original_url) {
// 构造图片Markdown(使用与Gemini生成相同的格式)
const imageMarkdown = `![thumb|||${result.original_url}|||2K](${result.thumbnail_url || result.original_url})`;
// 创建最终消息(前端显示用)
const finalMessage: ChatMessage = {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
createdAt: new Date().toISOString(),
} as ChatMessage;
setMessages((prev) => {
const filtered = prev.filter((msg) => msg.id !== messageId);
return [...filtered, finalMessage];
});
// 保存消息到数据库
try {
const saveResponse = await fetch("/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chatId: id,
message: {
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
},
}),
});
if (!saveResponse.ok) {
console.error("[Chat] Failed to save message:", await saveResponse.text());
}
} catch (saveError) {
console.error("[Chat] Failed to save message:", saveError);
}
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 : "去水印失败,请稍后重试");
// 移除加载消息
setMessages((prev) => prev.filter((msg) => msg.id !== messageId));
} finally {
setIsProcessingWatermarkRemove(false);
}
};
// 处理图像拓展
const handleImageExtend = async (imageUrl: string) => {
// 防止重复点击
@ -1157,10 +1262,12 @@ export function Chat({
onImageRmbg={handleImageRmbg}
onImageExtend={handleImageExtend}
onImageAngleEdit={handleImageAngleEdit}
onImageWatermarkRemove={handleImageWatermarkRemove}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
isProcessingAngleEdit={isProcessingAngleEdit}
isProcessingWatermarkRemove={isProcessingWatermarkRemove}
pendingGeneration={pendingGeneration}
imageCount={imageParams?.imageCount || 1}
isGeneratingImageFlag={isGeneratingImageFlag}

View File

@ -13,10 +13,12 @@ type ResponseProps = ComponentProps<typeof Streamdown> & {
onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
onImageAngleEdit?: (imageUrl: string) => void;
onImageWatermarkRemove?: (imageUrl: string) => void;
isProcessingEnhance?: boolean;
isProcessingRmbg?: boolean;
isProcessingExtend?: boolean;
isProcessingAngleEdit?: boolean;
isProcessingWatermarkRemove?: boolean;
};
// 注意:移除了 memo 的自定义比较函数,确保 isProcessing* 状态变化时组件能正确重新渲染
@ -29,10 +31,12 @@ export const Response = ({
onImageRmbg,
onImageExtend,
onImageAngleEdit,
onImageWatermarkRemove,
isProcessingEnhance,
isProcessingRmbg,
isProcessingExtend,
isProcessingAngleEdit,
isProcessingWatermarkRemove,
...props
}: ResponseProps) => (
<Streamdown
@ -46,7 +50,7 @@ export const Response = ({
return null;
}
// 使用 key 强制在处理状态变化时重新渲染组件
const processingKey = `${isProcessingEnhance}-${isProcessingRmbg}-${isProcessingExtend}-${isProcessingAngleEdit}`;
const processingKey = `${isProcessingEnhance}-${isProcessingRmbg}-${isProcessingExtend}-${isProcessingAngleEdit}-${isProcessingWatermarkRemove}`;
return <ImagePreview
key={`${src}-${processingKey}`}
src={src}
@ -57,10 +61,12 @@ export const Response = ({
onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
onImageAngleEdit={onImageAngleEdit}
onImageWatermarkRemove={onImageWatermarkRemove}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
isProcessingAngleEdit={isProcessingAngleEdit}
isProcessingWatermarkRemove={isProcessingWatermarkRemove}
/>;
},
video: ({ src, alt }: { src?: string; alt?: string }) => {

View File

@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D } from "lucide-react";
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
import { toast } from "sonner";
import { ImageAnnotator } from "./image-annotator";
import { AngleEditDialog } from "./angle-edit-dialog";
@ -16,10 +16,12 @@ export function ImagePreview({
onImageRmbg,
onImageExtend,
onImageAngleEdit,
onImageWatermarkRemove,
isProcessingEnhance,
isProcessingRmbg,
isProcessingExtend,
isProcessingAngleEdit,
isProcessingWatermarkRemove,
imageIndex
}: {
src: string;
@ -35,10 +37,12 @@ export function ImagePreview({
onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
onImageAngleEdit?: (imageUrl: string) => void;
onImageWatermarkRemove?: (imageUrl: string) => void;
isProcessingEnhance?: boolean;
isProcessingRmbg?: boolean;
isProcessingExtend?: boolean;
isProcessingAngleEdit?: boolean;
isProcessingWatermarkRemove?: boolean;
imageIndex?: number;
}) {
const [isOpen, setIsOpen] = useState(false);
@ -55,9 +59,10 @@ export function ImagePreview({
isProcessingRmbg,
isProcessingExtend,
isProcessingAngleEdit,
isProcessingWatermarkRemove,
src: src?.substring(0, 50)
});
}, [isProcessingEnhance, isProcessingRmbg, isProcessingExtend, isProcessingAngleEdit, src]);
}, [isProcessingEnhance, isProcessingRmbg, isProcessingExtend, isProcessingAngleEdit, isProcessingWatermarkRemove, src]);
// 解析 alt 文本,提取原图 URL 和宽度
// 格式优先级:
@ -326,11 +331,11 @@ export function ImagePreview({
className="flex items-center gap-1 rounded-full bg-black/70 hover:bg-black/80 px-2 py-1 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
onClick={(e) => {
e.stopPropagation();
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend && !isProcessingAngleEdit) {
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend && !isProcessingAngleEdit && !isProcessingWatermarkRemove) {
setShowAngleEditDialog(true);
}
}}
disabled={isProcessingEnhance || isProcessingRmbg || isProcessingExtend || isProcessingAngleEdit}
disabled={isProcessingEnhance || isProcessingRmbg || isProcessingExtend || isProcessingAngleEdit || isProcessingWatermarkRemove}
type="button"
aria-label="角度调整"
title="角度调整"
@ -339,6 +344,24 @@ export function ImagePreview({
<span className="text-[10px] font-medium">角度调整</span>
</button>
)}
{onImageWatermarkRemove && (
<button
className="flex items-center gap-1 rounded-full bg-black/70 hover:bg-black/80 px-2 py-1 text-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
onClick={(e) => {
e.stopPropagation();
if (!isProcessingEnhance && !isProcessingRmbg && !isProcessingExtend && !isProcessingAngleEdit && !isProcessingWatermarkRemove) {
onImageWatermarkRemove(original);
}
}}
disabled={isProcessingEnhance || isProcessingRmbg || isProcessingExtend || isProcessingAngleEdit || isProcessingWatermarkRemove}
type="button"
aria-label="去水印"
title="去水印"
>
<Droplet size={12} />
<span className="text-[10px] font-medium">去水印</span>
</button>
)}
<button
className="flex items-center gap-1 rounded-full bg-black/70 hover:bg-black/80 px-2 py-1 text-white transition-colors shadow-lg whitespace-nowrap"
onClick={(e) => {

View File

@ -152,10 +152,12 @@ const PurePreviewMessage = ({
onImageRmbg,
onImageExtend,
onImageAngleEdit,
onImageWatermarkRemove,
isProcessingEnhance,
isProcessingRmbg,
isProcessingExtend,
isProcessingAngleEdit,
isProcessingWatermarkRemove,
}: {
chatId: string;
message: ChatMessage;
@ -172,10 +174,12 @@ const PurePreviewMessage = ({
onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
onImageAngleEdit?: (imageUrl: string) => void;
onImageWatermarkRemove?: (imageUrl: string) => void;
isProcessingEnhance?: boolean;
isProcessingRmbg?: boolean;
isProcessingExtend?: boolean;
isProcessingAngleEdit?: boolean;
isProcessingWatermarkRemove?: boolean;
}) => {
const [mode, setMode] = useState<"view" | "edit">("view");
@ -312,10 +316,12 @@ const PurePreviewMessage = ({
onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
onImageAngleEdit={onImageAngleEdit}
onImageWatermarkRemove={onImageWatermarkRemove}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
isProcessingAngleEdit={isProcessingAngleEdit}
isProcessingWatermarkRemove={isProcessingWatermarkRemove}
>
{sanitizeText(part.content)}
</Response>

View File

@ -42,10 +42,12 @@ type MessagesProps = {
onImageRmbg?: (imageUrl: string) => void;
onImageExtend?: (imageUrl: string) => void;
onImageAngleEdit?: (imageUrl: string) => void;
onImageWatermarkRemove?: (imageUrl: string) => void;
isProcessingEnhance?: boolean;
isProcessingRmbg?: boolean;
isProcessingExtend?: boolean;
isProcessingAngleEdit?: boolean;
isProcessingWatermarkRemove?: boolean;
pendingGeneration?: PendingGeneration | null; // 🆕 持久化的生成状态
imageCount?: number; // 🆕 用户选择的图片数量
isGeneratingImageFlag?: boolean; // 🆕 正在生成图片的标志(来自 Chat 组件)
@ -67,10 +69,12 @@ function PureMessages({
onImageRmbg,
onImageExtend,
onImageAngleEdit,
onImageWatermarkRemove,
isProcessingEnhance = false,
isProcessingRmbg = false,
isProcessingExtend = false,
isProcessingAngleEdit = false,
isProcessingWatermarkRemove = false,
pendingGeneration,
imageCount = 1,
isGeneratingImageFlag = false,
@ -402,10 +406,12 @@ function PureMessages({
onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
onImageAngleEdit={onImageAngleEdit}
onImageWatermarkRemove={onImageWatermarkRemove}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
isProcessingAngleEdit={isProcessingAngleEdit}
isProcessingWatermarkRemove={isProcessingWatermarkRemove}
/>
);
})}