ai-chatbot/components/scene-fusion-view.tsx
Your Name 8d2066a3bf
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: expand agent workflow and project management
2026-03-19 18:33:42 +08:00

2222 lines
77 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 {
Download,
Eraser,
Image as ImageIcon,
Layers,
Loader2,
Plus,
RefreshCw,
Sparkles,
Trash2,
Upload,
Wand2,
X,
} from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import useSWR from "swr";
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";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { requestLongTaskResult } from "@/lib/client/long-task";
import {
SCENE_FUSION_ASPECT_RATIO_OPTIONS,
SCENE_FUSION_IMAGE_SIZE_OPTIONS,
type SceneFusionCapabilitySummary,
} from "@/lib/scene-fusion/output-capabilities";
import { cn } from "@/lib/utils";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const MAX_BATCH_ITEMS = 10;
const BATCH_REQUEST_STAGGER_MS = 700;
const BATCH_SERIAL_WORKER_COUNT = 1;
const MULTI_PRODUCT_FAILOVER_MAX_ROUNDS = 5;
const MULTI_PRODUCT_FAILOVER_RETRY_DELAY_MS = 1500;
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 {
initialProductImage?: string | null;
initialProductName?: string;
onBatchAddToCanvas?: (imageUrls: string[]) => void | Promise<void>;
onBack?: () => void;
}
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({
initialProductImage,
initialProductName,
onBatchAddToCanvas,
}: SceneFusionViewProps) {
const { data: configData } = useSWR("/api/scene-fusion/config", fetcher);
const config = configData as
| {
enabled?: boolean;
defaultPrompt?: string;
maxScenes?: number;
outputCapabilities?: SceneFusionCapabilitySummary;
}
| undefined;
const maxScenes = Math.min(
config?.maxScenes ?? MAX_BATCH_ITEMS,
MAX_BATCH_ITEMS
);
const outputCapabilities = config?.outputCapabilities;
const availableAspectRatioOptions = useMemo(
() =>
SCENE_FUSION_ASPECT_RATIO_OPTIONS.filter((option) =>
outputCapabilities?.aspectRatio?.enabled === false
? false
: !outputCapabilities?.aspectRatio?.options?.length ||
outputCapabilities.aspectRatio.options.includes(option.value)
),
[
outputCapabilities?.aspectRatio?.enabled,
outputCapabilities?.aspectRatio?.options,
]
);
const availableImageSizeOptions = useMemo(
() =>
SCENE_FUSION_IMAGE_SIZE_OPTIONS.filter((option) =>
outputCapabilities?.imageSize?.enabled === false
? false
: !outputCapabilities?.imageSize?.options?.length ||
outputCapabilities.imageSize.options.includes(option.value)
),
[
outputCapabilities?.imageSize?.enabled,
outputCapabilities?.imageSize?.options,
]
);
const showAspectRatioSetting = availableAspectRatioOptions.length > 0;
const showImageSizeSetting = availableImageSizeOptions.length > 0;
const [mode, setMode] = useState<BatchMode>("scene-fission");
const [isProcessing, setIsProcessing] = useState(false);
const [aspectRatio, setAspectRatio] = useState("auto");
const [imageSize, setImageSize] = useState("2K");
const showOutputSettings =
mode === "scene-fission" ||
mode === "random-bg" ||
mode === "multi-product-scene";
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 [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
const [previewImageTitle, setPreviewImageTitle] =
useState<string>("处理结果预览");
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 selectedModeOption = useMemo(
() => modeOptions.find((option) => option.id === mode),
[mode]
);
const completedResultItems = useMemo(
() =>
resultItems.filter(
(item) =>
item.status === "completed" && (item.originalUrl || item.result)
),
[resultItems]
);
const resultAllFinished = useMemo(
() =>
resultItems.length > 0 &&
resultItems.every(
(item) => item.status !== "processing" && item.status !== "pending"
),
[resultItems]
);
const sceneFusionOutputPayload = useMemo(
() => ({
aspectRatio: showAspectRatioSetting ? aspectRatio : undefined,
imageSize: showImageSizeSetting ? imageSize : undefined,
}),
[aspectRatio, imageSize, showAspectRatioSetting, showImageSizeSetting]
);
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);
}
};
}, []);
useEffect(() => {
if (showAspectRatioSetting) {
const nextAspectRatio =
availableAspectRatioOptions.find(
(option) => option.value === aspectRatio
)?.value ||
outputCapabilities?.aspectRatio?.defaultValue ||
availableAspectRatioOptions[0]?.value ||
"auto";
if (nextAspectRatio !== aspectRatio) {
setAspectRatio(nextAspectRatio);
}
return;
}
if (aspectRatio !== "auto") {
setAspectRatio("auto");
}
}, [
aspectRatio,
availableAspectRatioOptions,
outputCapabilities?.aspectRatio?.defaultValue,
showAspectRatioSetting,
]);
useEffect(() => {
if (showImageSizeSetting) {
const nextImageSize =
availableImageSizeOptions.find((option) => option.value === imageSize)
?.value ||
outputCapabilities?.imageSize?.defaultValue ||
availableImageSizeOptions[0]?.value ||
"2K";
if (nextImageSize !== imageSize) {
setImageSize(nextImageSize);
}
return;
}
if (imageSize !== "2K") {
setImageSize("2K");
}
}, [
availableImageSizeOptions,
imageSize,
outputCapabilities?.imageSize?.defaultValue,
showImageSizeSetting,
]);
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,
sceneMode: mode,
...sceneFusionOutputPayload,
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;
productName?: string;
productSource?: "user" | "agent" | "none";
sceneTargets?: Array<{
id: string;
targetName?: string;
source?: "user" | "agent";
confidence?: number;
model?: string;
}>;
};
error?: string;
}) => {
if (msg.type === "agent_meta" && msg.data) {
const detectedProductName =
typeof msg.data.productName === "string"
? msg.data.productName.trim()
: "";
if (!productName.trim() && detectedProductName) {
setProductName(detectedProductName);
}
if (
Array.isArray(msg.data.sceneTargets) &&
msg.data.sceneTargets.length > 0
) {
setter((prev) =>
prev.map((item) => {
const detected = msg.data?.sceneTargets?.find(
(entry) => entry.id === item.id
);
if (!detected?.targetName) return item;
const currentTargetName = (item.targetName || "").trim();
if (currentTargetName) return item;
return {
...item,
targetName: detected.targetName,
};
})
);
}
return;
}
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;
productName?: string;
productSource?: "user" | "agent" | "none";
sceneTargets?: Array<{
id: string;
targetName?: string;
source?: "user" | "agent";
confidence?: number;
model?: 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;
productName?: string;
productSource?: "user" | "agent" | "none";
sceneTargets?: Array<{
id: string;
targetName?: string;
source?: "user" | "agent";
confidence?: number;
model?: 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 runSingleSceneFusionAttempt = 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,
sceneMode: mode,
...sceneFusionOutputPayload,
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 runSingleSceneFusionRequest = async (params: {
productImageInput: string;
sceneImageInput: string;
requestId: string;
targetName?: string;
customPrompt?: string;
}) => {
let lastError: Error | null = null;
for (
let round = 1;
round <= MULTI_PRODUCT_FAILOVER_MAX_ROUNDS;
round += 1
) {
try {
return await runSingleSceneFusionAttempt({
...params,
requestId: `${params.requestId}-r${round}`,
});
} catch (error) {
lastError = error instanceof Error ? error : new Error("处理失败");
const isLastRound = round === MULTI_PRODUCT_FAILOVER_MAX_ROUNDS;
console.warn(
`[SceneFusionView] 多产品替换第 ${round}/${MULTI_PRODUCT_FAILOVER_MAX_ROUNDS} 轮失败,准备故障迁移`,
lastError.message
);
if (!isLastRound) {
await sleep(MULTI_PRODUCT_FAILOVER_RETRY_DELAY_MS);
}
}
}
throw new Error(
`故障迁移已重试 ${MULTI_PRODUCT_FAILOVER_MAX_ROUNDS} 轮仍失败:${lastError?.message || "处理失败"}`
);
};
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(BATCH_SERIAL_WORKER_COUNT, 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),
skipGallerySave: false,
}),
});
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(BATCH_SERIAL_WORKER_COUNT, 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} 张图片`);
};
const handleBatchAddToCanvas = async () => {
if (!onBatchAddToCanvas) return;
const imageUrls = completedResultItems
.map((item) => item.originalUrl || item.result)
.filter((url): url is string => Boolean(url));
if (imageUrls.length === 0) {
toast.error("没有可添加到画布的图片");
return;
}
try {
await onBatchAddToCanvas(imageUrls);
toast.success(`已批量添加 ${imageUrls.length} 张到画布`);
} catch (error) {
const message = error instanceof Error ? error.message : "添加到画布失败";
toast.error(message);
}
};
const openResultPreview = (imageUrl: string, title: string) => {
setPreviewImageUrl(imageUrl);
setPreviewImageTitle(title);
};
const closeResultPreview = () => {
setPreviewImageUrl(null);
};
return (
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row">
<div className="flex min-h-0 w-full shrink-0 flex-col border-r bg-background md:w-[420px]">
<div className="flex h-16 shrink-0 items-center border-b bg-background px-4">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-primary" />
<span className="font-medium">批量处理</span>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4">
<div className="space-y-6">
<div className="space-y-2">
<Label className="font-medium text-base">处理模式</Label>
<Select
disabled={isProcessing}
onValueChange={(value) => setMode(value as BatchMode)}
value={mode}
>
<SelectTrigger className="h-10 bg-background">
<span className="truncate">
{selectedModeOption?.label || "请选择处理模式"}
</span>
</SelectTrigger>
<SelectContent className="z-[30000] !bg-white dark:!bg-zinc-950">
{modeOptions.map((option) => (
<SelectItem
className="items-start py-2"
key={option.id}
value={option.id}
>
<div className="flex flex-col">
<span className="text-sm">{option.label}</span>
<span className="text-muted-foreground text-xs">
{option.description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
单次处理硬限制:最多 {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="font-medium text-base">
主图 A (必须)
</Label>
{(productImagePreview || productImage) && (
<Button
className="h-6 text-muted-foreground text-xs hover:text-destructive"
onClick={clearProductImage}
size="sm"
variant="ghost"
>
清除
</Button>
)}
</div>
<div
className={cn(
"group relative cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors",
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
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={handleProductUpload}
ref={productInputRef}
type="file"
/>
{productImagePreview || productImage ? (
<div className="relative mx-auto aspect-square w-full max-w-[200px]">
<img
alt="主图"
className="h-full w-full rounded-md object-contain"
src={productImagePreview || productImage || ""}
/>
</div>
) : (
<div className="flex flex-col items-center gap-2 py-8 text-muted-foreground">
<Upload className="h-8 w-8 opacity-50" />
<span className="text-sm">点击上传主图</span>
</div>
)}
</div>
{(productImagePreview || productImage) && (
<Input
className="h-9 text-sm"
disabled={isProcessing}
onChange={(e) => setProductName(e.target.value)}
placeholder="主图名称(如:沙发、椅子)"
value={productName}
/>
)}
</div>
)}
{showOutputSettings && (
<div className="space-y-3">
<div className="space-y-1">
<Label className="font-medium text-base">输出设置</Label>
{outputCapabilities?.strategyLabel ? (
<p className="text-muted-foreground text-xs">
{outputCapabilities.strategyLabel}
</p>
) : null}
{outputCapabilities?.note ? (
<p className="text-muted-foreground text-xs">
{outputCapabilities.note}
</p>
) : null}
</div>
{showAspectRatioSetting ? (
<div className="space-y-1.5">
<label className="text-muted-foreground text-xs">
图片比例
</label>
<div className="flex flex-wrap gap-1.5">
{availableAspectRatioOptions.map((option) => (
<button
className={cn(
"rounded-md border px-2.5 py-1 text-xs transition-colors",
aspectRatio === option.value
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background hover:bg-muted"
)}
disabled={isProcessing}
key={option.value}
onClick={() => setAspectRatio(option.value)}
>
{option.label}
</button>
))}
</div>
</div>
) : (
<div className="space-y-1">
<label className="text-muted-foreground text-xs">
图片比例
</label>
<p className="text-muted-foreground text-xs">
当前模型链路不支持自定义图片比例,将按模型默认比例生成。
</p>
</div>
)}
{showImageSizeSetting ? (
<div className="space-y-1.5">
<label className="text-muted-foreground text-xs">
输出分辨率
</label>
<div className="flex flex-wrap gap-1.5">
{availableImageSizeOptions.map((option) => (
<button
className={cn(
"rounded-md border px-2.5 py-1 text-xs transition-colors",
imageSize === option.value
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background hover:bg-muted"
)}
disabled={isProcessing}
key={option.value}
onClick={() => setImageSize(option.value)}
>
{option.label}
</button>
))}
</div>
</div>
) : (
<div className="space-y-1">
<label className="text-muted-foreground text-xs">
输出分辨率
</label>
<p className="text-muted-foreground text-xs">
当前模型链路不支持自定义分辨率,将按模型默认清晰度生成。
</p>
</div>
)}
</div>
)}
{mode === "scene-fission" && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="font-medium text-base">
场景参考图 ({sceneItems.length}/{maxScenes})
</Label>
<Button
className="h-7 gap-1 text-xs"
disabled={sceneItems.length >= maxScenes || isProcessing}
onClick={() => sceneInputRef.current?.click()}
size="sm"
variant="outline"
>
<Plus className="h-3 w-3" /> 批量添加
</Button>
<input
accept="image/png,image/jpeg,image/webp"
className="hidden"
multiple
onChange={handleSceneUpload}
ref={sceneInputRef}
type="file"
/>
</div>
<div className="space-y-3">
{sceneItems.length === 0 && (
<div
className="cursor-pointer rounded-lg border-2 border-muted-foreground/25 border-dashed p-8 text-center transition-colors hover:bg-muted/50"
onClick={() => sceneInputRef.current?.click()}
>
<ImageIcon className="mx-auto mb-2 h-8 w-8 opacity-20" />
<p className="text-muted-foreground text-sm">
点击添加场景参考图
</p>
<p className="mt-1 text-muted-foreground/60 text-xs">
最多 {maxScenes} 张
</p>
</div>
)}
{sceneItems.map((item, index) => (
<div
className="group relative rounded-lg border bg-background p-3"
key={item.id}
>
<div className="flex gap-3">
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-md border bg-muted">
<img
alt={`场景 ${index + 1}`}
className="h-full w-full object-cover"
src={item.image}
/>
</div>
<div className="flex-1 space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-medium text-muted-foreground text-xs">
场景 {index + 1}
</span>
{!isProcessing && (
<Button
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => removeSceneItem(item.id)}
size="icon"
variant="ghost"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
<Input
className="h-7 text-xs"
disabled={isProcessing}
onChange={(e) =>
updateSceneItem(item.id, {
targetName: e.target.value,
})
}
placeholder="被替换物体(如:椅子、桌子)"
value={item.targetName || ""}
/>
<Input
className="h-7 text-xs"
disabled={isProcessing}
onChange={(e) =>
updateSceneItem(item.id, {
customPrompt: e.target.value,
})
}
placeholder="修改要求(可选)"
value={item.customPrompt || ""}
/>
</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="font-medium text-base">
产品图列表 ({multiProductItems.length}/{MAX_BATCH_ITEMS}
)
</Label>
<Button
className="h-7 gap-1 text-xs"
disabled={
multiProductItems.length >= MAX_BATCH_ITEMS ||
isProcessing
}
onClick={() => multiProductInputRef.current?.click()}
size="sm"
variant="outline"
>
<Plus className="h-3 w-3" /> 批量添加
</Button>
<input
accept="image/png,image/jpeg,image/webp"
className="hidden"
multiple
onChange={handleMultiProductUpload}
ref={multiProductInputRef}
type="file"
/>
</div>
{multiProductItems.length === 0 ? (
<div
className="cursor-pointer rounded-lg border-2 border-muted-foreground/25 border-dashed p-8 text-center transition-colors hover:bg-muted/50"
onClick={() => multiProductInputRef.current?.click()}
>
<ImageIcon className="mx-auto mb-2 h-8 w-8 opacity-20" />
<p className="text-muted-foreground text-sm">
点击上传产品图(可多张)
</p>
<p className="mt-1 text-muted-foreground/60 text-xs">
最多 {MAX_BATCH_ITEMS} 张
</p>
</div>
) : (
<div className="space-y-3">
{multiProductItems.map((item, index) => (
<div
className="group relative rounded-lg border bg-background p-3"
key={item.id}
>
<div className="flex items-center gap-3">
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-md border bg-muted">
<img
alt={`产品 ${index + 1}`}
className="h-full w-full object-cover"
src={item.image}
/>
</div>
<div className="flex-1">
<div className="font-medium text-muted-foreground text-xs">
产品 {index + 1}
</div>
<div className="mt-1 text-muted-foreground text-xs">
状态:
{item.status === "pending"
? "等待"
: item.status === "processing"
? "处理中"
: item.status === "completed"
? "完成"
: "失败"}
</div>
{item.error && (
<div className="mt-1 line-clamp-2 text-destructive text-xs">
{item.error}
</div>
)}
</div>
{!isProcessing && (
<Button
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() =>
removeMultiProductItem(item.id)
}
size="icon"
variant="ghost"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="font-medium text-base">
参考图 B(统一替换目标)
</Label>
{referenceSceneImage && (
<Button
className="h-6 text-muted-foreground text-xs hover:text-destructive"
onClick={() => setReferenceSceneImage(null)}
size="sm"
variant="ghost"
>
清除
</Button>
)}
</div>
<div
className={cn(
"group relative cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors",
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
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={handleReferenceSceneUpload}
ref={referenceSceneInputRef}
type="file"
/>
{referenceSceneImage ? (
<div className="relative mx-auto aspect-square w-full max-w-[220px]">
<img
alt="参考图"
className="h-full w-full rounded-md object-cover"
src={referenceSceneImage}
/>
</div>
) : (
<div className="flex flex-col items-center gap-2 py-8 text-muted-foreground">
<Upload className="h-8 w-8 opacity-50" />
<span className="text-sm">点击上传参考图</span>
</div>
)}
</div>
<Input
className="h-8 text-xs"
disabled={isProcessing}
onChange={(e) => setReferenceTargetName(e.target.value)}
placeholder="被替换物体(可选,如:椅子、花瓶)"
value={referenceTargetName}
/>
<Input
className="h-8 text-xs"
disabled={isProcessing}
onChange={(e) => setReferenceCustomPrompt(e.target.value)}
placeholder="补充要求(可选)"
value={referenceCustomPrompt}
/>
</div>
</div>
)}
{mode === "random-bg" && (
<div className="space-y-3">
<Label className="font-medium text-base">随机背景参数</Label>
<div className="space-y-1.5">
<label className="text-muted-foreground text-xs">
生成张数(最多10)
</label>
<Input
className="h-9 text-sm"
disabled={isProcessing}
max={MAX_BATCH_ITEMS}
min={1}
onChange={(e) =>
setRandomCount(
Math.max(
1,
Math.min(
MAX_BATCH_ITEMS,
Number(e.target.value) || 1
)
)
)
}
type="number"
value={randomCount}
/>
</div>
<div className="space-y-1.5">
<label className="text-muted-foreground text-xs">
补充要求(可选)
</label>
<Input
className="h-9 text-sm"
disabled={isProcessing}
onChange={(e) => setRandomPrompt(e.target.value)}
placeholder="例如:暖色调、真实摄影感、商业海报风"
value={randomPrompt}
/>
</div>
<p className="text-muted-foreground text-xs">
使用与“裂变场景”同一套生图模型池,自动随机背景风格生成。
</p>
</div>
)}
{(mode === "rmbg" ||
mode === "enhance" ||
mode === "watermark") && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="font-medium text-base">
待处理图片 ({batchItems.length}/{MAX_BATCH_ITEMS})
</Label>
<Button
className="h-7 gap-1 text-xs"
disabled={
batchItems.length >= MAX_BATCH_ITEMS || isProcessing
}
onClick={() => batchInputRef.current?.click()}
size="sm"
variant="outline"
>
<Plus className="h-3 w-3" /> 批量添加
</Button>
<input
accept="image/png,image/jpeg,image/webp"
className="hidden"
multiple
onChange={handleBatchUpload}
ref={batchInputRef}
type="file"
/>
</div>
<div className="space-y-3">
{batchItems.length === 0 && (
<div
className="cursor-pointer rounded-lg border-2 border-muted-foreground/25 border-dashed p-8 text-center transition-colors hover:bg-muted/50"
onClick={() => batchInputRef.current?.click()}
>
<ImageIcon className="mx-auto mb-2 h-8 w-8 opacity-20" />
<p className="text-muted-foreground text-sm">
点击上传待处理图片
</p>
<p className="mt-1 text-muted-foreground/60 text-xs">
最多 {MAX_BATCH_ITEMS} 张
</p>
</div>
)}
{batchItems.map((item, index) => (
<div
className="group relative rounded-lg border bg-background p-3"
key={item.id}
>
<div className="flex items-center gap-3">
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-md border bg-muted">
<img
alt={`图片 ${index + 1}`}
className="h-full w-full object-cover"
src={item.image}
/>
</div>
<div className="flex-1">
<div className="font-medium text-muted-foreground text-xs">
图片 {index + 1}
</div>
<div className="mt-1 text-muted-foreground text-xs">
状态:
{item.status === "pending"
? "等待"
: item.status === "processing"
? "处理中"
: item.status === "completed"
? "完成"
: "失败"}
</div>
{item.error && (
<div className="mt-1 line-clamp-2 text-destructive text-xs">
{item.error}
</div>
)}
</div>
{!isProcessing && (
<Button
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => removeBatchItem(item.id)}
size="icon"
variant="ghost"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
<div className="shrink-0 border-t bg-background p-4">
<Button
className="h-12 w-full bg-primary font-semibold text-base text-primary-foreground shadow-sm hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100"
disabled={isProcessing || !modeCanStart}
onClick={startProcessing}
size="lg"
>
{isProcessing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
处理中...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
{getModeActionLabel(
mode,
mode === "random-bg" ? randomCount : resultItems.length
)}
</>
)}
</Button>
</div>
</div>
<div className="flex h-full min-h-0 flex-1 flex-col bg-background">
<div className="flex h-16 shrink-0 items-center justify-between border-b bg-background px-4 pr-14">
<h3 className="font-medium">处理结果</h3>
{(completedResultItems.length > 0 || onBatchAddToCanvas) && (
<div className="flex items-center gap-2">
{onBatchAddToCanvas && completedResultItems.length > 0 && (
<Button
disabled={!resultAllFinished}
onClick={handleBatchAddToCanvas}
size="sm"
title={
resultAllFinished
? "将处理结果批量添加到画布"
: "请等待所有任务处理完成"
}
variant="outline"
>
<Plus className="mr-2 h-4 w-4" />
批量添加到画布
</Button>
)}
{completedResultItems.length > 0 && (
<Button onClick={downloadAll} size="sm" variant="outline">
<Download className="mr-2 h-4 w-4" />
全部下载
</Button>
)}
</div>
)}
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{resultItems.length === 0 ? (
<div className="flex h-full min-h-[300px] flex-col items-center justify-center text-muted-foreground opacity-50">
<Wand2 className="mb-4 h-16 w-16" />
<p>选择模式并上传图片后,结果会显示在这里</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
{resultItems.map((item, index) => (
<div
className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm"
key={item.id}
>
<div className="flex items-center justify-between border-b bg-muted/30 p-2 text-muted-foreground text-xs">
<span>任务 {index + 1}</span>
<span
className={cn(
"rounded px-1.5 py-0.5 text-[10px] capitalize",
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={cn(
"group relative aspect-[3/4] bg-muted/20",
item.status === "completed" &&
(item.result || item.originalUrl) &&
"cursor-zoom-in"
)}
onClick={() => {
const previewUrl = item.originalUrl || item.result;
if (item.status === "completed" && previewUrl) {
openResultPreview(
previewUrl,
`任务 ${index + 1} 预览`
);
}
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ")
return;
const previewUrl = item.originalUrl || item.result;
if (item.status === "completed" && previewUrl) {
event.preventDefault();
openResultPreview(
previewUrl,
`任务 ${index + 1} 预览`
);
}
}}
role="button"
tabIndex={
item.status === "completed" &&
(item.result || item.originalUrl)
? 0
: -1
}
>
{item.result ? (
<img
alt="处理结果"
className="h-full w-full object-cover"
src={item.result}
/>
) : (
<div className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden p-0">
<img
alt="原图"
className="absolute inset-0 h-full w-full object-cover opacity-35"
src={item.image}
/>
{item.status === "processing" ? (
<div className="relative z-10 flex flex-col items-center gap-2">
<Loader2 className="h-7 w-7 animate-spin text-primary" />
<span className="rounded bg-black/50 px-2 py-1 text-white/95 text-xs">
处理中...
</span>
</div>
) : item.status === "failed" ? (
<div className="relative z-10 px-3 text-center">
<p className="line-clamp-2 rounded bg-black/55 px-2 py-1 text-white text-xs">
{item.error || "处理失败"}
</p>
</div>
) : (
<span className="relative z-10 rounded bg-black/50 px-2 py-1 font-medium text-white text-xs">
等待处理
</span>
)}
</div>
)}
{item.status === "completed" && (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 opacity-0 transition-opacity group-hover:opacity-100">
<Button
onClick={(event) => {
event.stopPropagation();
downloadImage(
item.originalUrl || item.result!,
`batch-${index + 1}.png`
);
}}
size="icon"
variant="secondary"
>
<Download className="h-4 w-4" />
</Button>
<Button
onClick={(event) => {
event.stopPropagation();
retrySingleItem(item);
}}
size="icon"
variant="secondary"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
{previewImageUrl && (
<div
className="fixed inset-0 z-[31000] flex items-center justify-center bg-black/80 p-4 backdrop-blur-sm"
onClick={closeResultPreview}
>
<div
className="relative max-h-[90vh] w-full max-w-6xl overflow-hidden rounded-lg border bg-background shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex h-12 items-center justify-between border-b px-4">
<span className="font-medium text-sm">{previewImageTitle}</span>
<Button onClick={closeResultPreview} size="icon" variant="ghost">
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center justify-center bg-black/5 p-3">
<img
alt={previewImageTitle}
className="max-h-[calc(90vh-72px)] w-auto max-w-full rounded object-contain"
src={previewImageUrl}
/>
</div>
</div>
</div>
)}
</div>
);
}