"use client"; import { ArrowLeft, Check, 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 { useRouter } from "next/navigation"; import { toast } from "sonner"; import useSWR from "swr"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { createCanvasPsdTemplateLaunchToken, saveCanvasPsdTemplateLaunchPayload } from "@/lib/canvas/psd-template-launch"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; 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"; import type { AgentWorkflowProductDetailChannel } from "@/types/agent"; const fetcher = (url: string) => fetch(url).then((res) => res.json()); const MAX_BATCH_ITEMS = 10; const BATCH_REQUEST_STAGGER_MS = 700; const BATCH_PARALLEL_WORKER_COUNT = 4; const MULTI_PRODUCT_FAILOVER_MAX_ROUNDS = 5; const MULTI_PRODUCT_FAILOVER_RETRY_DELAY_MS = 1500; const PERSISTED_IMAGE_MAX_LENGTH = 200_000; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); function getLocalBatchQueueMeta(items: ProcessItem[], itemId: string) { const activeQueue = items.filter( (item) => item.status === "processing" || item.status === "pending" ); const position = activeQueue.findIndex((item) => item.id === itemId); if (position < 0) { return null; } return { position: position + 1, total: activeQueue.length, }; } function formatQueueWaitLabel(estimatedWaitMs?: number | null) { const waitMs = Number(estimatedWaitMs); if (!Number.isFinite(waitMs) || waitMs <= 0) { return "预计即将开始"; } const totalSeconds = Math.max(1, Math.round(waitMs / 1000)); if (totalSeconds < 60) { return `预计 ${totalSeconds} 秒`; } const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return seconds > 0 ? `预计 ${minutes} 分 ${seconds} 秒` : `预计 ${minutes} 分钟`; } 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; queueStatus?: { queuePosition?: number; sameModelPendingCount?: number; sameModelRunningCount?: number; concurrencyLimit?: number; estimatedWaitMs?: number; source?: "model_queue" | "batch_queue"; }; } interface SceneFusionViewProps { initialProductImage?: string | null; initialProductName?: string; onBatchAddToCanvas?: (imageUrls: string[]) => void | Promise; onBack?: () => void; canvasId?: string | null; persistenceKey?: string | null; } type SceneFusionPersistedState = { mode: BatchMode; aspectRatio: string; imageSize: string; productImage: string | null; productName: string; sceneItems: ProcessItem[]; multiProductItems: ProcessItem[]; referenceSceneImage: string | null; referenceTargetName: string; referenceCustomPrompt: string; randomItems: ProcessItem[]; randomCount: number; randomPrompt: string; batchItems: ProcessItem[]; selectedResultIds: string[]; }; type ReferenceSearchResponse = { success?: boolean; error?: { message?: string; }; data?: { references?: Array<{ id?: string; imageUrl?: string; label?: string; }>; channel?: AgentWorkflowProductDetailChannel; }; }; function parseBatchProductIds(raw: string, maxCount: number) { return Array.from( new Set( raw .split(/[\n,,;\s]+/) .map((item) => item.trim()) .filter(Boolean) ) ).slice(0, maxCount); } const PRODUCT_DETAIL_CHANNEL_OPTIONS: Array<{ value: AgentWorkflowProductDetailChannel; label: string; }> = [ { value: "taobao", label: "淘宝" }, { value: "1688", label: "1688" }, { value: "douyin", label: "抖音" }, { value: "jd", label: "京东" }, ]; function isPersistableImageValue(value: unknown): value is string { if (typeof value !== "string") return false; const normalized = value.trim(); if (!normalized) return false; if (normalized.startsWith("blob:")) return false; if (normalized.startsWith("data:")) return false; return normalized.length <= PERSISTED_IMAGE_MAX_LENGTH; } function toPersistableImageValue(value: unknown) { return isPersistableImageValue(value) ? value.trim() : ""; } function toPersistableItems(items: ProcessItem[]) { const persistableItems: ProcessItem[] = []; for (const item of items) { const image = toPersistableImageValue(item.image); const result = toPersistableImageValue(item.result); const originalUrl = toPersistableImageValue(item.originalUrl); if (!image && !result && !originalUrl) continue; const persistableItem: ProcessItem = { ...item, image: image || result || originalUrl, }; if (result) { persistableItem.result = result; } else { delete persistableItem.result; } if (originalUrl) { persistableItem.originalUrl = originalUrl; } else { delete persistableItem.originalUrl; } persistableItems.push(persistableItem); } return persistableItems; } function createPersistableSnapshot(snapshot: SceneFusionPersistedState): SceneFusionPersistedState { const sceneItems = toPersistableItems(snapshot.sceneItems); const multiProductItems = toPersistableItems(snapshot.multiProductItems); const randomItems = toPersistableItems(snapshot.randomItems); const batchItems = toPersistableItems(snapshot.batchItems); const persistedIds = new Set( [...sceneItems, ...multiProductItems, ...randomItems, ...batchItems].map( (item) => item.id ) ); return { ...snapshot, productImage: toPersistableImageValue(snapshot.productImage) || null, sceneItems, multiProductItems, referenceSceneImage: toPersistableImageValue(snapshot.referenceSceneImage) || null, randomItems, batchItems, selectedResultIds: snapshot.selectedResultIds.filter((id) => persistedIds.has(id) ), }; } function getUploadedImageUrl(payload: unknown) { if (!payload || typeof payload !== "object" || Array.isArray(payload)) { return ""; } const raw = payload as Record; return String( raw.url || raw.originalUrl || raw.original_url || raw.previewUrl || raw.preview_url || raw.thumbnailUrl || raw.thumbnail_url || "" ).trim(); } 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, onBack, canvasId, persistenceKey, }: SceneFusionViewProps) { const router = useRouter(); 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("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(null); const [productImagePreview, setProductImagePreview] = useState( null ); const [productName, setProductName] = useState(initialProductName || ""); const [sceneItems, setSceneItems] = useState([]); const [multiProductItems, setMultiProductItems] = useState([]); const [referenceSceneImage, setReferenceSceneImage] = useState( null ); const [referenceTargetName, setReferenceTargetName] = useState(""); const [referenceCustomPrompt, setReferenceCustomPrompt] = useState(""); const [referenceProductIdQuery, setReferenceProductIdQuery] = useState(""); const [sceneReferenceProductIdBatchInput, setSceneReferenceProductIdBatchInput] = useState(""); const [referenceProductDetailChannel, setReferenceProductDetailChannel] = useState("taobao"); const [isReferenceProductSearchLoading, setIsReferenceProductSearchLoading] = useState(false); const [randomItems, setRandomItems] = useState([]); const [randomCount, setRandomCount] = useState(4); const [randomPrompt, setRandomPrompt] = useState(""); const [batchItems, setBatchItems] = useState([]); const [previewImageUrl, setPreviewImageUrl] = useState(null); const [previewImageTitle, setPreviewImageTitle] = useState("处理结果预览"); const [selectedResultIds, setSelectedResultIds] = useState([]); const [psdBrandDialogOpen, setPsdBrandDialogOpen] = useState(false); const [psdBrandName, setPsdBrandName] = useState(""); const productInputRef = useRef(null); const sceneInputRef = useRef(null); const multiProductInputRef = useRef(null); const referenceSceneInputRef = useRef(null); const batchInputRef = useRef(null); const productPreviewObjectUrlRef = useRef(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 resultQueueMetaById = useMemo( () => new Map( resultItems.map((item) => [ item.id, getLocalBatchQueueMeta(resultItems, item.id), ]) ), [resultItems] ); 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 { data: psdTemplateData } = useSWR<{ success?: boolean; templates?: Array<{ brandName?: string | null }>; }>( psdBrandDialogOpen ? "/api/psd-templates?summary=1" : null, fetcher ); const availablePsdBrands = useMemo( () => Array.from( new Set( (psdTemplateData?.templates || []) .map((template) => String(template?.brandName || "").trim()) .filter(Boolean) ) ).sort((left, right) => left.localeCompare(right, "zh-CN")), [psdTemplateData?.templates] ); const selectedCompletedResultItems = useMemo( () => completedResultItems.filter((item) => selectedResultIds.includes(item.id)), [completedResultItems, selectedResultIds] ); const hasCanvasPsdLaunch = Boolean(canvasId); 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); }; const uploadImageFile = async (file: File) => { const formData = new FormData(); formData.append("file", file, file.name || `scene-fusion-${Date.now()}.png`); const response = await fetchUploadWithRetry(formData); const payload = await response.json().catch(() => ({})); const imageUrl = getUploadedImageUrl(payload); if (!response.ok || !imageUrl) { throw new Error( String( payload?.error || payload?.details || payload?.message || `${file.name || "图片"}上传失败` ) ); } return imageUrl; }; const validateImageFiles = (files: File[]) => { const validFiles: File[] = []; for (const file of files) { if (!file.type.startsWith("image/")) { toast.error(`${file.name || "文件"}不是图片文件`); continue; } if (file.size > 50 * 1024 * 1024) { toast.error(`图片 ${file.name} 大小不能超过 50MB`); continue; } validFiles.push(file); } return validFiles; }; const uploadImageFiles = async (files: File[]) => { const uploadedItems = await Promise.all( files.map(async (file, index) => ({ file, index, imageUrl: await uploadImageFile(file), })) ); return uploadedItems.sort((left, right) => left.index - right.index); }; useEffect(() => { if (initialProductImage) { setProductImage(initialProductImage); updateProductPreview(initialProductImage); } }, [initialProductImage]); useEffect(() => { if (!persistenceKey || typeof window === "undefined") { return; } const raw = window.sessionStorage.getItem(persistenceKey); if (!raw) { return; } try { const parsed = JSON.parse(raw) as Partial | null; if (!parsed) { return; } if (parsed.mode) { setMode(parsed.mode); } if (typeof parsed.aspectRatio === "string") { setAspectRatio(parsed.aspectRatio); } if (typeof parsed.imageSize === "string") { setImageSize(parsed.imageSize); } const restoredProductImage = toPersistableImageValue(parsed.productImage); if (restoredProductImage) { setProductImage(restoredProductImage); updateProductPreview(restoredProductImage); } if (typeof parsed.productName === "string") { setProductName(parsed.productName); } if (Array.isArray(parsed.sceneItems)) { setSceneItems(toPersistableItems(parsed.sceneItems)); } if (Array.isArray(parsed.multiProductItems)) { setMultiProductItems(toPersistableItems(parsed.multiProductItems)); } const restoredReferenceSceneImage = toPersistableImageValue( parsed.referenceSceneImage ); if (restoredReferenceSceneImage) { setReferenceSceneImage(restoredReferenceSceneImage); } if (typeof parsed.referenceTargetName === "string") { setReferenceTargetName(parsed.referenceTargetName); } if (typeof parsed.referenceCustomPrompt === "string") { setReferenceCustomPrompt(parsed.referenceCustomPrompt); } if (Array.isArray(parsed.randomItems)) { setRandomItems(toPersistableItems(parsed.randomItems)); } if (typeof parsed.randomCount === "number" && Number.isFinite(parsed.randomCount)) { setRandomCount(Math.max(1, Math.min(MAX_BATCH_ITEMS, parsed.randomCount))); } if (typeof parsed.randomPrompt === "string") { setRandomPrompt(parsed.randomPrompt); } if (Array.isArray(parsed.batchItems)) { setBatchItems(toPersistableItems(parsed.batchItems)); } if (Array.isArray(parsed.selectedResultIds)) { setSelectedResultIds(parsed.selectedResultIds.map((value) => String(value || "").trim()).filter(Boolean)); } } catch (error) { console.warn("[SceneFusionView] Failed to restore persisted state:", error); } }, [persistenceKey]); 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, ]); useEffect(() => { const validCompletedIds = new Set(completedResultItems.map((item) => item.id)); setSelectedResultIds((current) => current.filter((id) => validCompletedIds.has(id))); }, [completedResultItems]); useEffect(() => { if (!persistenceKey || typeof window === "undefined") { return; } const snapshot: SceneFusionPersistedState = { mode, aspectRatio, imageSize, productImage, productName, sceneItems, multiProductItems, referenceSceneImage, referenceTargetName, referenceCustomPrompt, randomItems, randomCount, randomPrompt, batchItems, selectedResultIds, }; try { window.sessionStorage.setItem( persistenceKey, JSON.stringify(createPersistableSnapshot(snapshot)) ); } catch (error) { console.warn("[SceneFusionView] Failed to persist state:", error); try { window.sessionStorage.removeItem(persistenceKey); } catch { // Ignore storage cleanup failures. } } }, [ aspectRatio, batchItems, imageSize, mode, multiProductItems, persistenceKey, productImage, productName, randomCount, randomItems, randomPrompt, referenceCustomPrompt, referenceSceneImage, referenceTargetName, sceneItems, selectedResultIds, ]); const handleProductUpload = async (e: React.ChangeEvent) => { const input = e.currentTarget; const file = input.files?.[0]; if (!file) return; const [validFile] = validateImageFiles([file]); if (!validFile) { input.value = ""; return; } setProductImage(null); const previewUrl = URL.createObjectURL(validFile); updateProductPreview(previewUrl, true); try { const imageUrl = await uploadImageFile(validFile); setProductImage(imageUrl); updateProductPreview(imageUrl); toast.success("产品图上传成功"); } catch (error) { updateProductPreview(null); toast.error(error instanceof Error ? error.message : "产品图上传失败"); } finally { input.value = ""; } }; const handleSceneUpload = async (e: React.ChangeEvent) => { const input = e.currentTarget; const files = validateImageFiles(Array.from(input.files || [])); if (files.length === 0) { input.value = ""; return; } if (sceneItems.length + files.length > maxScenes) { toast.error(`单次最多上传 ${maxScenes} 张参考图`); input.value = ""; return; } try { const uploadedItems = await uploadImageFiles(files); setSceneItems((prev) => [ ...prev, ...uploadedItems.map((item) => ({ id: Math.random().toString(36).slice(2, 10), image: item.imageUrl, targetName: "", customPrompt: "", status: "pending" as ItemStatus, })), ]); toast.success(`已上传 ${uploadedItems.length} 张参考图`); } catch (error) { toast.error(error instanceof Error ? error.message : "参考图上传失败"); } finally { input.value = ""; } }; const handleMultiProductUpload = async (e: React.ChangeEvent) => { const input = e.currentTarget; const files = validateImageFiles(Array.from(input.files || [])); if (files.length === 0) { input.value = ""; return; } if (multiProductItems.length + files.length > MAX_BATCH_ITEMS) { toast.error(`单次最多上传 ${MAX_BATCH_ITEMS} 张产品图`); input.value = ""; return; } try { const uploadedItems = await uploadImageFiles(files); setMultiProductItems((prev) => [ ...prev, ...uploadedItems.map((item) => ({ id: Math.random().toString(36).slice(2, 10), image: item.imageUrl, status: "pending" as ItemStatus, })), ]); toast.success(`已上传 ${uploadedItems.length} 张产品图`); } catch (error) { toast.error(error instanceof Error ? error.message : "产品图上传失败"); } finally { input.value = ""; } }; const handleReferenceSceneUpload = async ( e: React.ChangeEvent ) => { const input = e.currentTarget; const file = input.files?.[0]; if (!file) return; const [validFile] = validateImageFiles([file]); if (!validFile) { input.value = ""; return; } try { const imageUrl = await uploadImageFile(validFile); setReferenceSceneImage(imageUrl); toast.success("参考图上传成功"); } catch (error) { toast.error(error instanceof Error ? error.message : "参考图上传失败"); } finally { input.value = ""; } }; const fetchReferenceProductMainImage = async ( productId: string, channel: AgentWorkflowProductDetailChannel ) => { const searchParams = new URLSearchParams({ keyword: productId, queryType: "productId", target: "main", channel, }); const response = await fetch( `/api/agent-workflow/reference-search?${searchParams.toString()}`, { cache: "no-store" } ); const result = (await response.json().catch(() => ({}))) as ReferenceSearchResponse; if (!response.ok || !result?.success) { throw new Error(result?.error?.message || `商品ID ${productId} 主图查询失败`); } const firstReferenceUrl = String( result?.data?.references?.[0]?.imageUrl || "" ).trim(); if (!firstReferenceUrl) { throw new Error(`商品ID ${productId} 未返回可用主图`); } return { imageUrl: firstReferenceUrl, channel: result?.data?.channel || channel, }; }; const handleReferenceProductIdSearch = async () => { const normalizedProductId = referenceProductIdQuery.trim(); if (!normalizedProductId) { toast.error("请输入商品ID后再查询主图"); return; } setIsReferenceProductSearchLoading(true); try { const result = await fetchReferenceProductMainImage( normalizedProductId, referenceProductDetailChannel ); setReferenceSceneImage(result.imageUrl); setReferenceProductDetailChannel(result.channel); toast.success("已将商品ID首张主图填充到参考图"); } catch (error) { toast.error(error instanceof Error ? error.message : "商品ID主图查询失败"); } finally { setIsReferenceProductSearchLoading(false); } }; const handleSceneReferenceProductIdBatchSearch = async () => { const remainingSlots = maxScenes - sceneItems.length; if (remainingSlots <= 0) { toast.error(`场景参考图最多 ${maxScenes} 张,请先删除后再批量查询`); return; } const productIds = parseBatchProductIds( sceneReferenceProductIdBatchInput, remainingSlots ); if (productIds.length === 0) { toast.error("请输入至少一个商品ID后再批量查询"); return; } setIsReferenceProductSearchLoading(true); try { const resolvedItems: ProcessItem[] = []; const failedIds: string[] = []; for (const productId of productIds) { try { const result = await fetchReferenceProductMainImage( productId, referenceProductDetailChannel ); setReferenceProductDetailChannel(result.channel); resolvedItems.push({ id: Math.random().toString(36).slice(2, 10), image: result.imageUrl, targetName: "", customPrompt: "", status: "pending", }); } catch { failedIds.push(productId); } } if (resolvedItems.length === 0) { throw new Error("商品ID批量查询失败,未返回可用主图"); } setSceneItems((prev) => [...prev, ...resolvedItems]); setSceneReferenceProductIdBatchInput(""); if (failedIds.length === 0) { toast.success(`已通过商品ID填充 ${resolvedItems.length} 张场景参考图`); } else { toast.warning( `已填充 ${resolvedItems.length} 张,失败 ${failedIds.length} 个:${failedIds .slice(0, 3) .join("、")}${failedIds.length > 3 ? "..." : ""}` ); } } catch (error) { toast.error(error instanceof Error ? error.message : "商品ID批量查询失败"); } finally { setIsReferenceProductSearchLoading(false); } }; const handleBatchUpload = async (e: React.ChangeEvent) => { const input = e.currentTarget; const files = validateImageFiles(Array.from(input.files || [])); if (files.length === 0) { input.value = ""; return; } if (batchItems.length + files.length > MAX_BATCH_ITEMS) { toast.error(`单次最多上传 ${MAX_BATCH_ITEMS} 张图片`); input.value = ""; return; } try { const uploadedItems = await uploadImageFiles(files); setBatchItems((prev) => [ ...prev, ...uploadedItems.map((item) => ({ id: Math.random().toString(36).slice(2, 10), image: item.imageUrl, status: "pending" as ItemStatus, })), ]); toast.success(`已上传 ${uploadedItems.length} 张图片`); } catch (error) { toast.error(error instanceof Error ? error.message : "图片上传失败"); } finally { input.value = ""; } }; const updateSceneItem = (id: string, patch: Partial) => { 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>, 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> ) => { 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: "pending" | "processing" | "completed" | "failed"; result?: string; originalUrl?: string; error?: string; queueStatus?: ProcessItem["queueStatus"] | null; 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 === "queue" && msg.data) { const data = msg.data; setter((prev) => prev.map((item) => item.id === data.id ? { ...item, status: data.status, queueStatus: data.queueStatus || undefined, error: data.status === "failed" ? item.error : undefined, } : item ) ); 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, queueStatus: undefined, error: undefined, } : item ) ); return; } failed += 1; setter((prev) => prev.map((item) => item.id === data.id ? { ...item, status: "failed", queueStatus: undefined, 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: "pending" | "processing" | "completed" | "failed"; result?: string; originalUrl?: string; error?: string; queueStatus?: ProcessItem["queueStatus"] | null; 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: "pending" | "processing" | "completed" | "failed"; result?: string; originalUrl?: string; error?: string; queueStatus?: ProcessItem["queueStatus"] | null; 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 === "pending") ? { ...item, status: "failed", queueStatus: undefined, 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 === "pending") ? { ...item, status: "failed", queueStatus: undefined, error: message } : item ) ); } finally { setIsProcessing(false); } }; const runSingleSceneFusionAttempt = async (params: { productImageInput: string; sceneImageInput: string; requestId: string; itemId?: 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: "pending" | "processing" | "completed" | "failed"; result?: string; originalUrl?: string; error?: string; queueStatus?: ProcessItem["queueStatus"] | null; }; error?: string; }; if ( msg.type === "queue" && msg.data?.id === params.requestId && params.itemId ) { setMultiProductItems((prev) => prev.map((item) => item.id === params.itemId ? { ...item, status: msg.data?.status || item.status, queueStatus: msg.data?.queueStatus || undefined, error: undefined, } : item ) ); continue; } 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; itemId?: 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: "pending", queueStatus: undefined, error: undefined } : item ) ); const queue = [...itemsToProcess]; const workerCount = Math.max( 1, Math.min(BATCH_PARALLEL_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()}`; setMultiProductItems((prev) => prev.map((item) => item.id === current.id ? { ...item, status: "processing", error: undefined } : item ) ); try { const result = await runSingleSceneFusionRequest({ productImageInput: current.image, sceneImageInput: referenceSceneImage, requestId, itemId: current.id, 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, queueStatus: undefined, 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", queueStatus: undefined, 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); setBatchItems((prev) => prev.map((item) => itemsToProcess.some((target) => target.id === item.id) ? { ...item, status: "pending", error: undefined } : item ) ); const queue = [...itemsToProcess]; const workerCount = Math.max( 1, Math.min(BATCH_PARALLEL_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; setBatchItems((prev) => prev.map((item) => item.id === current.id ? { ...item, status: "processing", error: undefined } : item ) ); 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 retryBatchImageItem = async (item: ProcessItem) => { if (item.image.startsWith("data:")) { toast.error("这张图仍是旧版本地数据,请重新上传后再处理"); return; } setIsProcessing(true); setBatchItems((prev) => prev.map((batchItem) => batchItem.id === item.id ? { ...batchItem, status: "processing", error: undefined } : batchItem ) ); try { const result = await processBatchImageItem(item); setBatchItems((prev) => prev.map((batchItem) => batchItem.id === item.id ? { ...batchItem, status: "completed", result: result.result, originalUrl: result.originalUrl, error: undefined, } : batchItem ) ); toast.success("已重新处理 1 张图片"); } catch (error) { const message = error instanceof Error ? error.message : "处理失败"; setBatchItems((prev) => prev.map((batchItem) => batchItem.id === item.id ? { ...batchItem, status: "failed", error: message } : batchItem ) ); toast.error(message); } finally { setIsProcessing(false); } }; 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; } await retryBatchImageItem(item); }; 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 toggleResultSelection = (itemId: string) => { setSelectedResultIds((current) => current.includes(itemId) ? current.filter((id) => id !== itemId) : [...current, itemId] ); }; const handleSelectAllCompletedResults = () => { setSelectedResultIds(completedResultItems.map((item) => item.id)); }; const handleOpenPsdBrandDialog = () => { if (!hasCanvasPsdLaunch) { toast.error("当前批量处理页面暂时无法打开 PSD 套版工作台"); return; } if (selectedCompletedResultItems.length === 0) { toast.error("请先选择至少一张处理完成的结果图"); return; } if (!psdBrandName.trim() && availablePsdBrands.length === 1) { setPsdBrandName(availablePsdBrands[0] || ""); } setPsdBrandDialogOpen(true); }; const handleLaunchPsdTemplateWorkspace = () => { const normalizedBrandName = psdBrandName.trim(); if (!canvasId) { toast.error("当前未绑定画布,暂时无法打开 PSD 套版页面"); return; } if (!normalizedBrandName) { toast.error("请先选择品牌信息"); return; } if (selectedCompletedResultItems.length === 0) { toast.error("请先选择至少一张处理完成的结果图"); return; } const token = createCanvasPsdTemplateLaunchToken(); const returnTo = typeof window !== "undefined" ? `${window.location.pathname}${window.location.search}` : `/canvas/${canvasId}/batch`; saveCanvasPsdTemplateLaunchPayload(token, { brandName: normalizedBrandName, pendingImages: selectedCompletedResultItems .map((item, index) => ({ id: item.id, imageUrl: String(item.originalUrl || item.result || "").trim(), title: `批量结果 ${index + 1}`, })) .filter((item) => item.imageUrl), activeImageId: selectedCompletedResultItems[0]?.id || null, returnTo, templateMode: "main", createdAt: Date.now(), }); setPsdBrandDialogOpen(false); router.push(`/canvas/${canvasId}/psd-template?launch=${encodeURIComponent(token)}`); }; const openResultPreview = (imageUrl: string, title: string) => { setPreviewImageUrl(imageUrl); setPreviewImageTitle(title); }; const closeResultPreview = () => { setPreviewImageUrl(null); }; return (
{onBack ? ( ) : null} 批量处理

单次处理硬限制:最多 {MAX_BATCH_ITEMS} 张图片。

{(mode === "scene-fission" || mode === "random-bg") && (
{(productImagePreview || productImage) && ( )}
!(productImagePreview || productImage) && productInputRef.current?.click() } > {productImagePreview || productImage ? (
主图
) : (
点击上传主图
)}
{(productImagePreview || productImage) && ( setProductName(e.target.value)} placeholder="主图名称(如:沙发、椅子)" value={productName} /> )}
)} {showOutputSettings && (
{outputCapabilities?.strategyLabel ? (

{outputCapabilities.strategyLabel}

) : null} {outputCapabilities?.note ? (

{outputCapabilities.note}

) : null}
{showAspectRatioSetting ? (
{availableAspectRatioOptions.map((option) => ( ))}
) : (

当前模型链路不支持自定义图片比例,将按模型默认比例生成。

)} {showImageSizeSetting ? (
{availableImageSizeOptions.map((option) => ( ))}
) : (

当前模型链路不支持自定义分辨率,将按模型默认清晰度生成。

)}
)} {mode === "scene-fission" && (
支持批量输入商品ID,自动获取每个ID首张主图填充为场景参考图
setSceneReferenceProductIdBatchInput(e.target.value) } onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleSceneReferenceProductIdBatchSearch(); } }} placeholder={`批量输入商品ID,支持换行/逗号分隔,最多 ${Math.max( maxScenes - sceneItems.length, 0 )} 个`} value={sceneReferenceProductIdBatchInput} />
{sceneItems.length === 0 && (
sceneInputRef.current?.click()} >

点击添加场景参考图

最多 {maxScenes} 张

)} {sceneItems.map((item, index) => (
{`场景
场景 {index + 1} {!isProcessing && ( )}
updateSceneItem(item.id, { targetName: e.target.value, }) } placeholder="被替换物体(如:椅子、桌子)" value={item.targetName || ""} /> updateSceneItem(item.id, { customPrompt: e.target.value, }) } placeholder="修改要求(可选)" value={item.customPrompt || ""} />
))}
)} {mode === "multi-product-scene" && (
{multiProductItems.length === 0 ? (
multiProductInputRef.current?.click()} >

点击上传产品图(可多张)

最多 {MAX_BATCH_ITEMS} 张

) : (
{multiProductItems.map((item, index) => (
{`产品
产品 {index + 1}
状态: {item.status === "pending" ? "等待" : item.status === "processing" ? "处理中" : item.status === "completed" ? "完成" : "失败"}
{item.error && (
{item.error}
)}
{!isProcessing && ( )}
))}
)}
{referenceSceneImage && ( )}
支持输入商品ID自动获取首张主图填充到参考图
setReferenceProductIdQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleReferenceProductIdSearch(); } }} placeholder="输入商品ID" value={referenceProductIdQuery} />
!referenceSceneImage && referenceSceneInputRef.current?.click() } > {referenceSceneImage ? (
参考图
) : (
点击上传参考图
)}
setReferenceTargetName(e.target.value)} placeholder="被替换物体(可选,如:椅子、花瓶)" value={referenceTargetName} /> setReferenceCustomPrompt(e.target.value)} placeholder="补充要求(可选)" value={referenceCustomPrompt} />
)} {mode === "random-bg" && (
setRandomCount( Math.max( 1, Math.min( MAX_BATCH_ITEMS, Number(e.target.value) || 1 ) ) ) } type="number" value={randomCount} />
setRandomPrompt(e.target.value)} placeholder="例如:暖色调、真实摄影感、商业海报风" value={randomPrompt} />

使用与“裂变场景”同一套生图模型池,自动随机背景风格生成。

)} {(mode === "rmbg" || mode === "enhance" || mode === "watermark") && (
{batchItems.length === 0 && (
batchInputRef.current?.click()} >

点击上传待处理图片

最多 {MAX_BATCH_ITEMS} 张

)} {batchItems.map((item, index) => (
{`图片
图片 {index + 1}
状态: {item.status === "pending" ? "等待" : item.status === "processing" ? "处理中" : item.status === "completed" ? "完成" : "失败"}
{item.error && (
{item.error}
)}
{!isProcessing && ( )}
))}
)}

处理结果

{selectedCompletedResultItems.length > 0 ? (

已选 {selectedCompletedResultItems.length} / {completedResultItems.length} 张完成结果

) : null}
{(completedResultItems.length > 0 || onBatchAddToCanvas) && (
{completedResultItems.length > 1 ? ( ) : null} {hasCanvasPsdLaunch && completedResultItems.length > 0 ? ( ) : null} {onBatchAddToCanvas && completedResultItems.length > 0 && ( )} {completedResultItems.length > 0 && ( )}
)}
{resultItems.length === 0 ? (

选择模式并上传图片后,结果会显示在这里

) : (
{resultItems.map((item, index) => (
任务 {index + 1} {item.status === "completed" ? "完成" : item.status === "failed" ? "失败" : item.status === "processing" ? "处理中" : "等待"}
{ 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 ? ( 处理结果 ) : ( (() => { const queueMeta = resultQueueMetaById.get(item.id) || null; const serverRunningCount = Math.max( 0, Number(item.queueStatus?.sameModelRunningCount || 0) ); const concurrencyLimit = Math.max( 0, Number(item.queueStatus?.concurrencyLimit || 0) ); const serverQueuePosition = Math.max( 0, Number(item.queueStatus?.queuePosition || 0) ); const queueTitle = item.status === "pending" ? serverQueuePosition > 0 ? `排队中,前方 ${serverQueuePosition} 个` : queueMeta ? `批次内排队 ${queueMeta.position}/${queueMeta.total}` : "等待处理" : item.status === "processing" ? serverQueuePosition > 0 ? `模型排队中,前方 ${serverQueuePosition} 个` : queueMeta ? `批次内执行 ${queueMeta.position}/${queueMeta.total}` : "处理中..." : null; const queueDetail = item.status === "pending" ? serverQueuePosition > 0 ? `当前模型运行 ${serverRunningCount}/${Math.max( concurrencyLimit, serverRunningCount, 1 )},${formatQueueWaitLabel( item.queueStatus?.estimatedWaitMs )}` : queueMeta ? `当前批次位次 ${queueMeta.position}/${queueMeta.total}` : null : item.status === "processing" && serverQueuePosition > 0 ? `当前模型运行 ${serverRunningCount}/${Math.max( concurrencyLimit, serverRunningCount, 1 )},${formatQueueWaitLabel( item.queueStatus?.estimatedWaitMs )}` : null; return (
原图 {item.status === "processing" ? (
{queueTitle || "处理中..."}
{queueDetail ? (
{queueDetail}
) : null}
) : item.status === "failed" ? (

{item.error || "处理失败"}

) : (
{queueTitle || "等待处理"}
{queueDetail ? (
{queueDetail}
) : null}
)}
); })() )} {item.status === "completed" && ( <>
)}
))}
)}
{previewImageUrl && (
event.stopPropagation()} >
{previewImageTitle}
{previewImageTitle}
)} 选择套 PSD 模版品牌 当前会把 {selectedCompletedResultItems.length} 张已选结果图带到 PSD 套版页面,并按这个品牌过滤模板。
{availablePsdBrands.length > 0 ? (
{availablePsdBrands.map((brand) => { const active = psdBrandName.trim() === brand; return ( ); })}
) : null}
setPsdBrandName(event.target.value)} />
待带入图片
{selectedCompletedResultItems.map((item, index) => { const previewUrl = item.originalUrl || item.result || item.image; return (
{`已选结果
结果 {index + 1}
); })}
); }