ai-chatbot/components/scene-fusion-view.tsx
Your Name 4104b68f23
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix(batch): prevent status overwrite and stagger concurrent requests
2026-03-05 20:01:14 +08:00

1600 lines
60 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, { useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import {
CheckCircle2,
Download,
Image as ImageIcon,
Layers,
Loader2,
Plus,
RefreshCw,
Sparkles,
Trash2,
Upload,
Wand2,
Eraser,
} from "lucide-react";
import { toast } from "sonner";
import { requestLongTaskResult } from "@/lib/client/long-task";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const MAX_BATCH_ITEMS = 10;
const BATCH_REQUEST_STAGGER_MS = 700;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
type BatchMode = "scene-fission" | "multi-product-scene" | "random-bg" | "rmbg" | "enhance" | "watermark";
type ItemStatus = "pending" | "processing" | "completed" | "failed";
interface ProcessItem {
id: string;
image: string;
status: ItemStatus;
result?: string;
originalUrl?: string;
error?: string;
targetName?: string;
customPrompt?: string;
}
interface SceneFusionViewProps {
onBack: () => void;
initialProductImage?: string | null;
initialProductName?: string;
}
const aspectRatioOptions = [
{ label: "自动", value: "auto" },
{ label: "1:1", value: "1:1" },
{ label: "4:3", value: "4:3" },
{ label: "3:4", value: "3:4" },
{ label: "16:9", value: "16:9" },
{ label: "9:16", value: "9:16" },
];
const imageSizeOptions = [
{ label: "标准 (1K)", value: "1K" },
{ label: "高清 (2K)", value: "2K" },
{ label: "超清 (4K)", value: "4K" },
];
const randomBackgroundStyles = [
"现代简约客厅",
"北欧卧室",
"轻奢会客厅",
"咖啡馆窗边",
"自然森林步道",
"城市夜景街道",
"艺术展厅空间",
"暖色书房环境",
"阳光厨房空间",
"高端商业橱窗",
];
const modeOptions: Array<{ id: BatchMode; label: string; description: string; icon: React.ComponentType<{ className?: string }> }> = [
{ id: "scene-fission", label: "裂变场景", description: "1张产品图 + 多张参考图", icon: Layers },
{ id: "multi-product-scene", label: "多产品替换", description: "10张产品图 + 1张参考图", icon: ImageIcon },
{ id: "random-bg", label: "随机换背景", description: "1张图随机生成多种背景", icon: Wand2 },
{ id: "rmbg", label: "批量抠图", description: "多图同步去背景", icon: Eraser },
{ id: "enhance", label: "批量变清晰", description: "多图同步增强画质", icon: Sparkles },
{ id: "watermark", label: "批量去水印", description: "多图同步去除水印", icon: RefreshCw },
];
function getModeActionLabel(mode: BatchMode, count: number) {
switch (mode) {
case "scene-fission":
return `开始裂变 (${count} 张)`;
case "multi-product-scene":
return `开始多产品替换 (${count} 张)`;
case "random-bg":
return `开始换背景 (${count} 张)`;
case "rmbg":
return `批量抠图 (${count} 张)`;
case "enhance":
return `批量变清晰 (${count} 张)`;
case "watermark":
return `批量去水印 (${count} 张)`;
}
}
function getModePrompt(mode: BatchMode) {
switch (mode) {
case "rmbg":
return "批量处理-抠图";
case "enhance":
return "批量处理-变清晰";
case "watermark":
return "批量处理-去水印";
default:
return "批量处理";
}
}
export function SceneFusionView({ onBack, initialProductImage, initialProductName }: SceneFusionViewProps) {
const { data: configData } = useSWR("/api/scene-fusion/config", fetcher);
const config = configData as {
enabled?: boolean;
defaultPrompt?: string;
maxScenes?: number;
} | undefined;
const maxScenes = Math.min(config?.maxScenes ?? MAX_BATCH_ITEMS, MAX_BATCH_ITEMS);
const [mode, setMode] = useState<BatchMode>("scene-fission");
const [isProcessing, setIsProcessing] = useState(false);
const [aspectRatio, setAspectRatio] = useState("auto");
const [imageSize, setImageSize] = useState("2K");
const [productImage, setProductImage] = useState<string | null>(null);
const [productImagePreview, setProductImagePreview] = useState<string | null>(null);
const [productName, setProductName] = useState(initialProductName || "");
const [sceneItems, setSceneItems] = useState<ProcessItem[]>([]);
const [multiProductItems, setMultiProductItems] = useState<ProcessItem[]>([]);
const [referenceSceneImage, setReferenceSceneImage] = useState<string | null>(null);
const [referenceTargetName, setReferenceTargetName] = useState("");
const [referenceCustomPrompt, setReferenceCustomPrompt] = useState("");
const [randomItems, setRandomItems] = useState<ProcessItem[]>([]);
const [randomCount, setRandomCount] = useState<number>(4);
const [randomPrompt, setRandomPrompt] = useState("");
const [batchItems, setBatchItems] = useState<ProcessItem[]>([]);
const productInputRef = useRef<HTMLInputElement>(null);
const sceneInputRef = useRef<HTMLInputElement>(null);
const multiProductInputRef = useRef<HTMLInputElement>(null);
const referenceSceneInputRef = useRef<HTMLInputElement>(null);
const batchInputRef = useRef<HTMLInputElement>(null);
const productPreviewObjectUrlRef = useRef<string | null>(null);
const resultItems = useMemo(() => {
if (mode === "scene-fission") return sceneItems;
if (mode === "multi-product-scene") return multiProductItems;
if (mode === "random-bg") return randomItems;
return batchItems;
}, [batchItems, mode, multiProductItems, randomItems, sceneItems]);
const modeCanStart = useMemo(() => {
if (mode === "scene-fission") return Boolean(productImage) && sceneItems.length > 0;
if (mode === "multi-product-scene") return multiProductItems.length > 0 && Boolean(referenceSceneImage);
if (mode === "random-bg") return Boolean(productImage) && randomCount > 0;
return batchItems.length > 0;
}, [batchItems.length, mode, multiProductItems.length, productImage, randomCount, referenceSceneImage, sceneItems.length]);
const updateProductPreview = (previewUrl: string | null, isObjectUrl = false) => {
if (productPreviewObjectUrlRef.current) {
URL.revokeObjectURL(productPreviewObjectUrlRef.current);
productPreviewObjectUrlRef.current = null;
}
setProductImagePreview(previewUrl);
if (isObjectUrl && previewUrl) {
productPreviewObjectUrlRef.current = previewUrl;
}
};
const clearProductImage = () => {
setProductImage(null);
updateProductPreview(null);
};
useEffect(() => {
if (initialProductImage) {
setProductImage(initialProductImage);
updateProductPreview(initialProductImage);
}
}, [initialProductImage]);
useEffect(() => {
return () => {
if (productPreviewObjectUrlRef.current) {
URL.revokeObjectURL(productPreviewObjectUrlRef.current);
}
};
}, []);
const handleProductUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
return;
}
setProductImage(null);
const previewUrl = URL.createObjectURL(file);
updateProductPreview(previewUrl, true);
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result;
if (typeof result === "string") {
setProductImage(result);
} else {
toast.error("图片读取失败,请重试");
}
};
reader.onerror = () => toast.error("图片读取失败,请重试");
reader.readAsDataURL(file);
e.target.value = "";
};
const handleSceneUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
if (sceneItems.length + files.length > maxScenes) {
toast.error(`单次最多上传 ${maxScenes} 张参考图`);
return;
}
files.forEach((file) => {
if (file.size > 50 * 1024 * 1024) {
toast.error(`图片 ${file.name} 大小不能超过 50MB`);
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const image = event.target?.result as string;
setSceneItems((prev) => [
...prev,
{
id: Math.random().toString(36).slice(2, 10),
image,
targetName: "",
customPrompt: "",
status: "pending",
},
]);
};
reader.readAsDataURL(file);
});
e.target.value = "";
};
const handleMultiProductUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
if (multiProductItems.length + files.length > MAX_BATCH_ITEMS) {
toast.error(`单次最多上传 ${MAX_BATCH_ITEMS} 张产品图`);
return;
}
files.forEach((file) => {
if (file.size > 50 * 1024 * 1024) {
toast.error(`图片 ${file.name} 大小不能超过 50MB`);
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const image = event.target?.result as string;
setMultiProductItems((prev) => [
...prev,
{
id: Math.random().toString(36).slice(2, 10),
image,
status: "pending",
},
]);
};
reader.readAsDataURL(file);
});
e.target.value = "";
};
const handleReferenceSceneUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const image = event.target?.result as string;
setReferenceSceneImage(image);
};
reader.readAsDataURL(file);
e.target.value = "";
};
const handleBatchUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
if (batchItems.length + files.length > MAX_BATCH_ITEMS) {
toast.error(`单次最多上传 ${MAX_BATCH_ITEMS} 张图片`);
return;
}
files.forEach((file) => {
if (file.size > 50 * 1024 * 1024) {
toast.error(`图片 ${file.name} 大小不能超过 50MB`);
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const image = event.target?.result as string;
setBatchItems((prev) => [
...prev,
{
id: Math.random().toString(36).slice(2, 10),
image,
status: "pending",
},
]);
};
reader.readAsDataURL(file);
});
e.target.value = "";
};
const updateSceneItem = (id: string, patch: Partial<ProcessItem>) => {
setSceneItems((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item)));
};
const removeSceneItem = (id: string) => {
setSceneItems((prev) => prev.filter((item) => item.id !== id));
};
const removeMultiProductItem = (id: string) => {
setMultiProductItems((prev) => prev.filter((item) => item.id !== id));
};
const removeBatchItem = (id: string) => {
setBatchItems((prev) => prev.filter((item) => item.id !== id));
};
const updateBatchItemsStatus = (ids: string[], status: ItemStatus) => {
setBatchItems((prev) =>
prev.map((item) =>
ids.includes(item.id) ? { ...item, status, error: status === "processing" ? undefined : item.error } : item
)
);
};
const updateFusionItemsStatus = (
setter: React.Dispatch<React.SetStateAction<ProcessItem[]>>,
ids: string[],
status: ItemStatus
) => {
setter((prev) =>
prev.map((item) =>
ids.includes(item.id) ? { ...item, status, error: status === "processing" ? undefined : item.error } : item
)
);
};
const runSceneFusion = async (
scenesPayload: Array<{ id: string; image: string; targetName?: string; customPrompt?: string }>,
setter: React.Dispatch<React.SetStateAction<ProcessItem[]>>
) => {
if (!productImage) return;
if (scenesPayload.length === 0) return;
if (config?.enabled === false) {
toast.error("批量处理功能已被管理员禁用");
return;
}
setIsProcessing(true);
updateFusionItemsStatus(
setter,
scenesPayload.map((item) => item.id),
"processing"
);
try {
const response = await fetch("/api/scene-fusion", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
productImage,
productName: productName || undefined,
aspectRatio,
imageSize,
scenes: scenesPayload,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.error || "处理失败");
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("读取流式响应失败");
}
const decoder = new TextDecoder();
let buffer = "";
let success = 0;
let failed = 0;
let streamErrorMessage: string | null = null;
const handleParsedMessage = (msg: {
type: string;
data?: {
id: string;
status: "completed" | "failed";
result?: string;
originalUrl?: string;
error?: string;
};
error?: string;
}) => {
if (msg.type === "result" && msg.data) {
const data = msg.data;
if (data.status === "completed") {
success += 1;
setter((prev) =>
prev.map((item) =>
item.id === data.id
? {
...item,
status: "completed",
result: data.result,
originalUrl: data.originalUrl,
error: undefined,
}
: item
)
);
return;
}
failed += 1;
setter((prev) =>
prev.map((item) =>
item.id === data.id
? {
...item,
status: "failed",
error: data.error || "生成失败",
}
: item
)
);
return;
}
if (msg.type === "error") {
streamErrorMessage = msg.error || "处理失败";
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line) as {
type: string;
data?: {
id: string;
status: "completed" | "failed";
result?: string;
originalUrl?: string;
error?: string;
};
error?: string;
};
handleParsedMessage(msg);
} catch (parseError) {
console.warn("[SceneFusionView] Ignore invalid stream line:", parseError);
}
}
}
if (buffer.trim()) {
try {
const msg = JSON.parse(buffer.trim()) as {
type: string;
data?: {
id: string;
status: "completed" | "failed";
result?: string;
originalUrl?: string;
error?: string;
};
error?: string;
};
handleParsedMessage(msg);
} catch (parseError) {
console.warn("[SceneFusionView] Ignore trailing stream buffer:", parseError);
}
}
if (streamErrorMessage) {
setter((prev) =>
prev.map((item) =>
scenesPayload.some((scene) => scene.id === item.id) && item.status === "processing"
? { ...item, status: "failed", error: streamErrorMessage }
: item
)
);
toast.warning(streamErrorMessage);
}
if (success > 0 && failed === 0) {
toast.success(`批量处理完成:${success} 张`);
} else if (success > 0 || failed > 0) {
toast.warning(`批量处理完成:${success} 成功,${failed} 失败`);
}
} catch (error) {
const message = error instanceof Error ? error.message : "处理失败";
toast.error(message);
setter((prev) =>
prev.map((item) =>
scenesPayload.some((scene) => scene.id === item.id) && item.status === "processing"
? { ...item, status: "failed", error: message }
: item
)
);
} finally {
setIsProcessing(false);
}
};
const runSingleSceneFusionRequest = async (params: {
productImageInput: string;
sceneImageInput: string;
requestId: string;
targetName?: string;
customPrompt?: string;
}) => {
const response = await fetch("/api/scene-fusion", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
productImage: params.productImageInput,
productName: productName || undefined,
aspectRatio,
imageSize,
scenes: [
{
id: params.requestId,
image: params.sceneImageInput,
targetName: params.targetName || undefined,
customPrompt: params.customPrompt || undefined,
},
],
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.error || "处理失败");
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("读取流式响应失败");
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
const msg = JSON.parse(line) as {
type: string;
data?: {
id: string;
status: "completed" | "failed";
result?: string;
originalUrl?: string;
error?: string;
};
error?: string;
};
if (msg.type === "result" && msg.data?.id === params.requestId) {
if (msg.data.status === "completed") {
return {
result: msg.data.result || params.sceneImageInput,
originalUrl: msg.data.originalUrl || msg.data.result || params.sceneImageInput,
};
}
throw new Error(msg.data.error || "生成失败");
}
if (msg.type === "error") {
throw new Error(msg.error || "处理失败");
}
}
}
throw new Error("未收到生成结果");
};
const runMultiProductSceneMode = async () => {
if (multiProductItems.length === 0) {
toast.error("请先上传产品图");
return;
}
if (!referenceSceneImage) {
toast.error("请先上传参考图");
return;
}
if (config?.enabled === false) {
toast.error("批量处理功能已被管理员禁用");
return;
}
let itemsToProcess = multiProductItems.filter((item) => item.status === "pending" || item.status === "failed");
if (itemsToProcess.length === 0) {
itemsToProcess = multiProductItems;
}
if (itemsToProcess.length > MAX_BATCH_ITEMS) {
toast.error(`单次最多处理 ${MAX_BATCH_ITEMS} 张产品图`);
return;
}
setIsProcessing(true);
setMultiProductItems((prev) =>
prev.map((item) =>
itemsToProcess.some((target) => target.id === item.id)
? { ...item, status: "processing", error: undefined }
: item
)
);
const queue = [...itemsToProcess];
const workerCount = Math.max(1, Math.min(2, queue.length));
let successCount = 0;
let failedCount = 0;
const workers = Array.from({ length: workerCount }, async () => {
while (queue.length > 0) {
const current = queue.shift();
if (!current) break;
const requestId = `multi-product-${current.id}-${Date.now()}`;
try {
const result = await runSingleSceneFusionRequest({
productImageInput: current.image,
sceneImageInput: referenceSceneImage,
requestId,
targetName: referenceTargetName || undefined,
customPrompt: referenceCustomPrompt || undefined,
});
successCount += 1;
setMultiProductItems((prev) =>
prev.map((item) =>
item.id === current.id
? {
...item,
status: "completed",
result: result.result,
originalUrl: result.originalUrl,
error: undefined,
}
: item
)
);
} catch (error) {
const message = error instanceof Error ? error.message : "处理失败";
failedCount += 1;
setMultiProductItems((prev) =>
prev.map((item) =>
item.id === current.id
? {
...item,
status: "failed",
error: message,
}
: item
)
);
}
if (queue.length > 0) {
await sleep(BATCH_REQUEST_STAGGER_MS);
}
}
});
await Promise.all(workers);
setIsProcessing(false);
if (successCount > 0 && failedCount === 0) {
toast.success(`多产品替换完成:${successCount} 张`);
} else {
toast.warning(`多产品替换完成:${successCount} 成功,${failedCount} 失败`);
}
};
const buildRandomItems = () => {
const count = Math.max(1, Math.min(MAX_BATCH_ITEMS, randomCount));
const stylePool = [...randomBackgroundStyles].sort(() => Math.random() - 0.5);
return Array.from({ length: count }).map((_, index) => {
const style = stylePool[index % stylePool.length];
const customPrompt = `请保持主体和构图尽量不变,仅替换背景为“${style}”风格,确保光影自然。${randomPrompt ? `补充要求:${randomPrompt}` : ""}`;
return {
id: `rand-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 6)}`,
image: productImage || "",
customPrompt,
status: "pending" as ItemStatus,
};
});
};
const processBatchImageItem = async (item: ProcessItem) => {
if (mode === "rmbg") {
const response = await fetch("/api/rmbg", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
imageUrl: item.image,
prompt: getModePrompt(mode),
}),
});
const payload = await response.json();
if (!response.ok || !payload?.success) {
throw new Error(payload?.error || payload?.detail || "抠图失败");
}
return {
result: payload.thumbnail_url || payload.original_url || item.image,
originalUrl: payload.original_url || payload.thumbnail_url || item.image,
};
}
if (mode === "enhance") {
const payload = await requestLongTaskResult({
endpoint: "/api/enhance",
payload: {
imageUrl: item.image,
prompt: getModePrompt(mode),
},
pollingParams: {
prompt: getModePrompt(mode),
},
});
if (!payload?.success) {
throw new Error(payload?.error || payload?.detail || "变清晰失败");
}
return {
result: payload.thumbnail_url || payload.original_url || item.image,
originalUrl: payload.original_url || payload.thumbnail_url || item.image,
};
}
if (mode === "watermark") {
const payload = await requestLongTaskResult({
endpoint: "/api/watermark-remove",
payload: {
image_url: item.image,
},
});
if (!payload?.success) {
throw new Error(payload?.error || payload?.detail || "去水印失败");
}
return {
result: payload.thumbnail_url || payload.original_url || item.image,
originalUrl: payload.original_url || payload.thumbnail_url || item.image,
};
}
throw new Error("当前模式不支持此操作");
};
const runBatchImageMode = async () => {
if (batchItems.length === 0) {
toast.error("请先上传图片");
return;
}
let itemsToProcess = batchItems.filter((item) => item.status === "pending" || item.status === "failed");
if (itemsToProcess.length === 0) {
itemsToProcess = batchItems;
}
if (itemsToProcess.length > MAX_BATCH_ITEMS) {
toast.error(`单次最多处理 ${MAX_BATCH_ITEMS} 张图片`);
return;
}
setIsProcessing(true);
updateBatchItemsStatus(
itemsToProcess.map((item) => item.id),
"processing"
);
const queue = [...itemsToProcess];
const workerCount = Math.max(1, Math.min(3, queue.length));
let successCount = 0;
let failedCount = 0;
const workers = Array.from({ length: workerCount }, async () => {
while (queue.length > 0) {
const current = queue.shift();
if (!current) break;
try {
const result = await processBatchImageItem(current);
successCount += 1;
setBatchItems((prev) =>
prev.map((item) =>
item.id === current.id
? {
...item,
status: "completed",
result: result.result,
originalUrl: result.originalUrl,
error: undefined,
}
: item
)
);
} catch (error) {
const message = error instanceof Error ? error.message : "处理失败";
failedCount += 1;
setBatchItems((prev) =>
prev.map((item) =>
item.id === current.id
? {
...item,
status: "failed",
error: message,
}
: item
)
);
}
if (queue.length > 0) {
await sleep(BATCH_REQUEST_STAGGER_MS);
}
}
});
await Promise.all(workers);
setIsProcessing(false);
if (successCount > 0 && failedCount === 0) {
toast.success(`批量处理完成:${successCount} 张`);
} else {
toast.warning(`批量处理完成:${successCount} 成功,${failedCount} 失败`);
}
};
const startProcessing = async () => {
if (mode === "scene-fission") {
if (!productImage) {
toast.error("请先上传产品图");
return;
}
if (sceneItems.length === 0) {
toast.error("请至少上传一张场景参考图");
return;
}
let scenesToProcess = sceneItems.filter((item) => item.status === "pending" || item.status === "failed");
if (scenesToProcess.length === 0) {
scenesToProcess = sceneItems;
}
await runSceneFusion(
scenesToProcess.map((item) => ({
id: item.id,
image: item.image,
targetName: item.targetName || undefined,
customPrompt: item.customPrompt || undefined,
})),
setSceneItems
);
return;
}
if (mode === "random-bg") {
if (!productImage) {
toast.error("请先上传主图");
return;
}
const items = buildRandomItems();
setRandomItems(items);
await runSceneFusion(
items.map((item) => ({
id: item.id,
image: item.image,
customPrompt: item.customPrompt || undefined,
})),
setRandomItems
);
return;
}
if (mode === "multi-product-scene") {
await runMultiProductSceneMode();
return;
}
await runBatchImageMode();
};
const retrySingleItem = async (item: ProcessItem) => {
if (isProcessing) return;
if (mode === "scene-fission") {
await runSceneFusion(
[
{
id: item.id,
image: item.image,
targetName: item.targetName || undefined,
customPrompt: item.customPrompt || undefined,
},
],
setSceneItems
);
return;
}
if (mode === "random-bg") {
await runSceneFusion(
[
{
id: item.id,
image: item.image,
customPrompt: item.customPrompt || undefined,
},
],
setRandomItems
);
return;
}
if (mode === "multi-product-scene") {
if (!referenceSceneImage) {
toast.error("请先上传参考图");
return;
}
setMultiProductItems((prev) =>
prev.map((productItem) =>
productItem.id === item.id ? { ...productItem, status: "pending", error: undefined } : productItem
)
);
await runMultiProductSceneMode();
return;
}
setBatchItems((prev) =>
prev.map((batchItem) =>
batchItem.id === item.id ? { ...batchItem, status: "pending", error: undefined } : batchItem
)
);
await runBatchImageMode();
};
const downloadImage = async (url: string, filename: string) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
} catch {
window.open(url, "_blank");
}
};
const downloadAll = async () => {
const completed = resultItems.filter((item) => item.status === "completed" && (item.originalUrl || item.result));
if (completed.length === 0) {
toast.error("没有可下载的图片");
return;
}
for (let i = 0; i < completed.length; i += 1) {
const item = completed[i]!;
await downloadImage(item.originalUrl || item.result!, `batch-result-${i + 1}.png`);
await new Promise((resolve) => setTimeout(resolve, 400));
}
toast.success(`已下载 ${completed.length} 张图片`);
};
return (
<div className="flex h-full min-h-0 w-full flex-col bg-white dark:bg-zinc-950">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row">
<div className="w-full md:w-[420px] border-r flex min-h-0 flex-col bg-white dark:bg-zinc-950 shrink-0">
<div className="border-b px-4 py-3 shrink-0 bg-white dark:bg-zinc-950">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-primary" />
<span className="font-medium">批量处理</span>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={onBack}>
返回
</Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain px-4 py-4">
<div className="space-y-6">
<div className="space-y-2">
<Label className="text-base font-medium">处理模式</Label>
<div className="grid grid-cols-1 gap-2">
{modeOptions.map((option) => (
<button
key={option.id}
type="button"
onClick={() => setMode(option.id)}
aria-pressed={mode === option.id}
className={cn(
"rounded-lg border px-3 py-2 text-left transition-all duration-150 active:scale-[0.99]",
mode === option.id
? "border-primary bg-primary/12 shadow-[0_0_0_1px_hsl(var(--primary)/0.35)]"
: "border-border bg-white dark:bg-zinc-950 hover:bg-muted/60 hover:border-primary/40"
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
<option.icon
className={cn(
"mt-0.5 h-4 w-4",
mode === option.id ? "text-primary" : "text-muted-foreground"
)}
/>
<div>
<div className="text-sm font-medium leading-5">{option.label}</div>
<div className="text-xs text-muted-foreground mt-0.5">{option.description}</div>
</div>
</div>
{mode === option.id && <CheckCircle2 className="h-4 w-4 text-primary shrink-0" />}
</div>
</button>
))}
</div>
<p className="text-xs text-muted-foreground">单次处理硬限制:最多 {MAX_BATCH_ITEMS} 张图片。</p>
</div>
{(mode === "scene-fission" || mode === "random-bg") && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">主图 A (必须)</Label>
{(productImagePreview || productImage) && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs text-muted-foreground hover:text-destructive"
onClick={clearProductImage}
>
清除
</Button>
)}
</div>
<div
className={cn(
"border-2 border-dashed rounded-lg p-4 transition-colors text-center cursor-pointer relative group",
productImagePreview || productImage
? "border-primary/50 bg-primary/5"
: "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50"
)}
onClick={() => !(productImagePreview || productImage) && productInputRef.current?.click()}
>
<input
type="file"
ref={productInputRef}
className="hidden"
accept="image/png,image/jpeg,image/webp"
onChange={handleProductUpload}
/>
{productImagePreview || productImage ? (
<div className="relative aspect-square w-full max-w-[200px] mx-auto">
<img
src={productImagePreview || productImage || ""}
alt="主图"
className="w-full h-full object-contain rounded-md"
/>
</div>
) : (
<div className="py-8 flex flex-col items-center gap-2 text-muted-foreground">
<Upload className="w-8 h-8 opacity-50" />
<span className="text-sm">点击上传主图</span>
</div>
)}
</div>
{(productImagePreview || productImage) && (
<Input
placeholder="主图名称(如:沙发、椅子)"
value={productName}
onChange={(e) => setProductName(e.target.value)}
className="h-9 text-sm"
disabled={isProcessing}
/>
)}
</div>
)}
{(mode === "scene-fission" || mode === "random-bg" || mode === "multi-product-scene") && (
<div className="space-y-3">
<Label className="text-base font-medium">输出设置</Label>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">图片比例</label>
<div className="flex flex-wrap gap-1.5">
{aspectRatioOptions.map((option) => (
<button
key={option.value}
onClick={() => setAspectRatio(option.value)}
disabled={isProcessing}
className={cn(
"px-2.5 py-1 text-xs rounded-md border transition-colors",
aspectRatio === option.value
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-muted border-input"
)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">输出分辨率</label>
<div className="flex flex-wrap gap-1.5">
{imageSizeOptions.map((option) => (
<button
key={option.value}
onClick={() => setImageSize(option.value)}
disabled={isProcessing}
className={cn(
"px-2.5 py-1 text-xs rounded-md border transition-colors",
imageSize === option.value
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-muted border-input"
)}
>
{option.label}
</button>
))}
</div>
</div>
</div>
)}
{mode === "scene-fission" && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
场景参考图 ({sceneItems.length}/{maxScenes})
</Label>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => sceneInputRef.current?.click()}
disabled={sceneItems.length >= maxScenes || isProcessing}
>
<Plus className="w-3 h-3" /> 批量添加
</Button>
<input
type="file"
ref={sceneInputRef}
className="hidden"
multiple
accept="image/png,image/jpeg,image/webp"
onChange={handleSceneUpload}
/>
</div>
<div className="space-y-3">
{sceneItems.length === 0 && (
<div
className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => sceneInputRef.current?.click()}
>
<ImageIcon className="w-8 h-8 mx-auto opacity-20 mb-2" />
<p className="text-sm text-muted-foreground">点击添加场景参考图</p>
<p className="text-xs text-muted-foreground/60 mt-1">最多 {maxScenes} 张</p>
</div>
)}
{sceneItems.map((item, index) => (
<div key={item.id} className="border rounded-lg p-3 bg-background relative group">
<div className="flex gap-3">
<div className="w-20 h-20 shrink-0 bg-muted rounded-md overflow-hidden border">
<img src={item.image} alt={`场景 ${index + 1}`} className="w-full h-full object-cover" />
</div>
<div className="flex-1 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">场景 {index + 1}</span>
{!isProcessing && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => removeSceneItem(item.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
<Input
placeholder="被替换物体(如:椅子、桌子)"
value={item.targetName || ""}
onChange={(e) => updateSceneItem(item.id, { targetName: e.target.value })}
className="h-7 text-xs"
disabled={isProcessing}
/>
<Input
placeholder="修改要求(可选)"
value={item.customPrompt || ""}
onChange={(e) => updateSceneItem(item.id, { customPrompt: e.target.value })}
className="h-7 text-xs"
disabled={isProcessing}
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
{mode === "multi-product-scene" && (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">产品图列表 ({multiProductItems.length}/{MAX_BATCH_ITEMS})</Label>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => multiProductInputRef.current?.click()}
disabled={multiProductItems.length >= MAX_BATCH_ITEMS || isProcessing}
>
<Plus className="w-3 h-3" /> 批量添加
</Button>
<input
type="file"
ref={multiProductInputRef}
className="hidden"
multiple
accept="image/png,image/jpeg,image/webp"
onChange={handleMultiProductUpload}
/>
</div>
{multiProductItems.length === 0 ? (
<div
className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => multiProductInputRef.current?.click()}
>
<ImageIcon className="w-8 h-8 mx-auto opacity-20 mb-2" />
<p className="text-sm text-muted-foreground">点击上传产品图(可多张)</p>
<p className="text-xs text-muted-foreground/60 mt-1">最多 {MAX_BATCH_ITEMS} 张</p>
</div>
) : (
<div className="space-y-3">
{multiProductItems.map((item, index) => (
<div key={item.id} className="border rounded-lg p-3 bg-background relative group">
<div className="flex gap-3 items-center">
<div className="w-20 h-20 shrink-0 bg-muted rounded-md overflow-hidden border">
<img src={item.image} alt={`产品 ${index + 1}`} className="w-full h-full object-cover" />
</div>
<div className="flex-1">
<div className="text-xs font-medium text-muted-foreground">产品 {index + 1}</div>
<div className="text-xs text-muted-foreground mt-1">
状态:{item.status === "pending" ? "等待" : item.status === "processing" ? "处理中" : item.status === "completed" ? "完成" : "失败"}
</div>
{item.error && <div className="text-xs text-destructive mt-1 line-clamp-2">{item.error}</div>}
</div>
{!isProcessing && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => removeMultiProductItem(item.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">参考图 B(统一替换目标)</Label>
{referenceSceneImage && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs text-muted-foreground hover:text-destructive"
onClick={() => setReferenceSceneImage(null)}
>
清除
</Button>
)}
</div>
<div
className={cn(
"border-2 border-dashed rounded-lg p-4 transition-colors text-center cursor-pointer relative group",
referenceSceneImage
? "border-primary/50 bg-primary/5"
: "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50"
)}
onClick={() => !referenceSceneImage && referenceSceneInputRef.current?.click()}
>
<input
type="file"
ref={referenceSceneInputRef}
className="hidden"
accept="image/png,image/jpeg,image/webp"
onChange={handleReferenceSceneUpload}
/>
{referenceSceneImage ? (
<div className="relative aspect-square w-full max-w-[220px] mx-auto">
<img
src={referenceSceneImage}
alt="参考图"
className="w-full h-full object-cover rounded-md"
/>
</div>
) : (
<div className="py-8 flex flex-col items-center gap-2 text-muted-foreground">
<Upload className="w-8 h-8 opacity-50" />
<span className="text-sm">点击上传参考图</span>
</div>
)}
</div>
<Input
placeholder="被替换物体(可选,如:椅子、花瓶)"
value={referenceTargetName}
onChange={(e) => setReferenceTargetName(e.target.value)}
className="h-8 text-xs"
disabled={isProcessing}
/>
<Input
placeholder="补充要求(可选)"
value={referenceCustomPrompt}
onChange={(e) => setReferenceCustomPrompt(e.target.value)}
className="h-8 text-xs"
disabled={isProcessing}
/>
</div>
</div>
)}
{mode === "random-bg" && (
<div className="space-y-3">
<Label className="text-base font-medium">随机背景参数</Label>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">生成张数(最多10)</label>
<Input
type="number"
min={1}
max={MAX_BATCH_ITEMS}
value={randomCount}
disabled={isProcessing}
onChange={(e) => setRandomCount(Math.max(1, Math.min(MAX_BATCH_ITEMS, Number(e.target.value) || 1)))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">补充要求(可选)</label>
<Input
value={randomPrompt}
onChange={(e) => setRandomPrompt(e.target.value)}
disabled={isProcessing}
placeholder="例如:暖色调、真实摄影感、商业海报风"
className="h-9 text-sm"
/>
</div>
<p className="text-xs text-muted-foreground">
使用与“裂变场景”同一套生图模型池,自动随机背景风格生成。
</p>
</div>
)}
{(mode === "rmbg" || mode === "enhance" || mode === "watermark") && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
待处理图片 ({batchItems.length}/{MAX_BATCH_ITEMS})
</Label>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => batchInputRef.current?.click()}
disabled={batchItems.length >= MAX_BATCH_ITEMS || isProcessing}
>
<Plus className="w-3 h-3" /> 批量添加
</Button>
<input
type="file"
ref={batchInputRef}
className="hidden"
multiple
accept="image/png,image/jpeg,image/webp"
onChange={handleBatchUpload}
/>
</div>
<div className="space-y-3">
{batchItems.length === 0 && (
<div
className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => batchInputRef.current?.click()}
>
<ImageIcon className="w-8 h-8 mx-auto opacity-20 mb-2" />
<p className="text-sm text-muted-foreground">点击上传待处理图片</p>
<p className="text-xs text-muted-foreground/60 mt-1">最多 {MAX_BATCH_ITEMS} 张</p>
</div>
)}
{batchItems.map((item, index) => (
<div key={item.id} className="border rounded-lg p-3 bg-background relative group">
<div className="flex gap-3 items-center">
<div className="w-20 h-20 shrink-0 bg-muted rounded-md overflow-hidden border">
<img src={item.image} alt={`图片 ${index + 1}`} className="w-full h-full object-cover" />
</div>
<div className="flex-1">
<div className="text-xs font-medium text-muted-foreground">图片 {index + 1}</div>
<div className="text-xs text-muted-foreground mt-1">
状态:{item.status === "pending" ? "等待" : item.status === "processing" ? "处理中" : item.status === "completed" ? "完成" : "失败"}
</div>
{item.error && <div className="text-xs text-destructive mt-1 line-clamp-2">{item.error}</div>}
</div>
{!isProcessing && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => removeBatchItem(item.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
<div className="p-4 border-t bg-background shrink-0">
<Button className="w-full" size="lg" onClick={startProcessing} disabled={isProcessing || !modeCanStart}>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
处理中...
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
{getModeActionLabel(mode, mode === "random-bg" ? randomCount : resultItems.length)}
</>
)}
</Button>
</div>
</div>
<div className="flex h-full min-h-0 flex-1 flex-col bg-white dark:bg-zinc-950">
<div className="p-4 border-b flex items-center justify-between bg-white dark:bg-zinc-950 shrink-0">
<h3 className="font-medium">处理结果</h3>
{resultItems.some((item) => item.status === "completed") && (
<Button variant="outline" size="sm" onClick={downloadAll}>
<Download className="w-4 h-4 mr-2" />
全部下载
</Button>
)}
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="p-4">
{resultItems.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground opacity-50 min-h-[300px]">
<Wand2 className="w-16 h-16 mb-4" />
<p>选择模式并上传图片后,结果会显示在这里</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
{resultItems.map((item, index) => (
<div key={item.id} className="border rounded-lg overflow-hidden bg-white dark:bg-zinc-950 shadow-sm flex flex-col">
<div className="p-2 border-b text-xs text-muted-foreground bg-muted/30 flex justify-between items-center">
<span>任务 {index + 1}</span>
<span
className={cn(
"capitalize px-1.5 py-0.5 rounded text-[10px]",
item.status === "completed" && "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
item.status === "failed" && "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
item.status === "processing" &&
"bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
item.status === "pending" && "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
)}
>
{item.status === "completed"
? "完成"
: item.status === "failed"
? "失败"
: item.status === "processing"
? "处理中"
: "等待"}
</span>
</div>
<div className="aspect-[3/4] bg-muted/20 relative group">
{item.result ? (
<img src={item.result} alt="处理结果" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex flex-col items-center justify-center p-0 relative overflow-hidden">
<img src={item.image} alt="原图" className="absolute inset-0 w-full h-full object-cover opacity-35" />
{item.status === "processing" ? (
<div className="relative z-10 flex flex-col items-center gap-2">
<Loader2 className="w-7 h-7 animate-spin text-primary" />
<span className="text-xs text-white/95 bg-black/50 px-2 py-1 rounded">处理中...</span>
</div>
) : item.status === "failed" ? (
<div className="relative z-10 text-center px-3">
<p className="text-xs text-white bg-black/55 px-2 py-1 rounded line-clamp-2">
{item.error || "处理失败"}
</p>
</div>
) : (
<span className="relative z-10 text-xs font-medium bg-black/50 text-white px-2 py-1 rounded">
等待处理
</span>
)}
</div>
)}
{item.status === "completed" && (
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button
size="icon"
variant="secondary"
onClick={() => downloadImage(item.originalUrl || item.result!, `batch-${index + 1}.png`)}
>
<Download className="w-4 h-4" />
</Button>
<Button size="icon" variant="secondary" onClick={() => retrySingleItem(item)}>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
</div>
);
}