ai-chatbot/components/scene-fusion-view.tsx
Your Name 21497ae57c
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: align scene fusion output params with model capabilities
2026-03-07 11:09:51 +08:00

1885 lines
71 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 {
Download,
Image as ImageIcon,
Layers,
Loader2,
Plus,
RefreshCw,
Sparkles,
Trash2,
Upload,
Wand2,
Eraser,
X,
} from "lucide-react";
import { toast } from "sonner";
import { requestLongTaskResult } from "@/lib/client/long-task";
import { cn } from "@/lib/utils";
import {
SCENE_FUSION_ASPECT_RATIO_OPTIONS,
SCENE_FUSION_IMAGE_SIZE_OPTIONS,
type SceneFusionCapabilitySummary,
} from "@/lib/scene-fusion/output-capabilities";
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";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const MAX_BATCH_ITEMS = 10;
const BATCH_REQUEST_STAGGER_MS = 700;
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(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),
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(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} 张图片`);
};
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-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 h-16 px-4 shrink-0 bg-white dark:bg-zinc-950 flex items-center">
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-primary" />
<span className="font-medium">批量处理</span>
</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>
<Select value={mode} onValueChange={(value) => setMode(value as BatchMode)} disabled={isProcessing}>
<SelectTrigger className="h-10 bg-white dark:bg-zinc-950">
<span className="truncate">
{selectedModeOption?.label || "请选择处理模式"}
</span>
</SelectTrigger>
<SelectContent className="z-[30000] !bg-white !dark:bg-zinc-950">
{modeOptions.map((option) => (
<SelectItem key={option.id} value={option.id} className="items-start py-2">
<div className="flex flex-col">
<span className="text-sm">{option.label}</span>
<span className="text-xs text-muted-foreground">{option.description}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<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>
)}
{showOutputSettings && (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-base font-medium">输出设置</Label>
{outputCapabilities?.strategyLabel ? (
<p className="text-xs text-muted-foreground">{outputCapabilities.strategyLabel}</p>
) : null}
{outputCapabilities?.note ? (
<p className="text-xs text-muted-foreground">{outputCapabilities.note}</p>
) : null}
</div>
{showAspectRatioSetting ? (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">图片比例</label>
<div className="flex flex-wrap gap-1.5">
{availableAspectRatioOptions.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">
<label className="text-xs text-muted-foreground">图片比例</label>
<p className="text-xs text-muted-foreground">
当前模型链路不支持自定义图片比例,将按模型默认比例生成。
</p>
</div>
)}
{showImageSizeSetting ? (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">输出分辨率</label>
<div className="flex flex-wrap gap-1.5">
{availableImageSizeOptions.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 className="space-y-1">
<label className="text-xs text-muted-foreground">输出分辨率</label>
<p className="text-xs text-muted-foreground">
当前模型链路不支持自定义分辨率,将按模型默认清晰度生成。
</p>
</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 h-12 text-base font-semibold shadow-sm bg-primary text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100"
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="h-16 px-4 pr-14 border-b flex items-center justify-between bg-white dark:bg-zinc-950 shrink-0">
<h3 className="font-medium">处理结果</h3>
{(completedResultItems.length > 0 || onBatchAddToCanvas) && (
<div className="flex items-center gap-2">
{onBatchAddToCanvas && completedResultItems.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleBatchAddToCanvas}
disabled={!resultAllFinished}
title={resultAllFinished ? "将处理结果批量添加到画布" : "请等待所有任务处理完成"}
>
<Plus className="w-4 h-4 mr-2" />
批量添加到画布
</Button>
)}
{completedResultItems.length > 0 && (
<Button variant="outline" size="sm" onClick={downloadAll}>
<Download className="w-4 h-4 mr-2" />
全部下载
</Button>
)}
</div>
)}
</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={cn(
"aspect-[3/4] bg-muted/20 relative group",
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 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={(event) => {
event.stopPropagation();
downloadImage(item.originalUrl || item.result!, `batch-${index + 1}.png`);
}}
>
<Download className="w-4 h-4" />
</Button>
<Button
size="icon"
variant="secondary"
onClick={(event) => {
event.stopPropagation();
retrySingleItem(item);
}}
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
{previewImageUrl && (
<div
className="fixed inset-0 z-[31000] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
onClick={closeResultPreview}
>
<div
className="relative w-full max-w-6xl max-h-[90vh] bg-background rounded-lg border shadow-xl overflow-hidden"
onClick={(event) => event.stopPropagation()}
>
<div className="flex h-12 items-center justify-between border-b px-4">
<span className="text-sm font-medium">{previewImageTitle}</span>
<Button size="icon" variant="ghost" onClick={closeResultPreview}>
<X className="w-4 h-4" />
</Button>
</div>
<div className="flex items-center justify-center bg-black/5 p-3">
<img
src={previewImageUrl}
alt={previewImageTitle}
className="max-h-[calc(90vh-72px)] w-auto max-w-full object-contain rounded"
/>
</div>
</div>
</div>
)}
</div>
);
}