2222 lines
77 KiB
TypeScript
2222 lines
77 KiB
TypeScript
"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-background">
|
||
{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>
|
||
);
|
||
}
|