3361 lines
117 KiB
TypeScript
3361 lines
117 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
ArrowLeft,
|
||
Check,
|
||
Download,
|
||
Eraser,
|
||
Image as ImageIcon,
|
||
Layers,
|
||
Loader2,
|
||
Maximize2,
|
||
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"
|
||
| "extend"
|
||
| "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";
|
||
};
|
||
}
|
||
|
||
function markProcessItemRegenerating(
|
||
item: ProcessItem,
|
||
status: Extract<ItemStatus, "pending" | "processing">
|
||
): ProcessItem {
|
||
return {
|
||
...item,
|
||
status,
|
||
result: undefined,
|
||
originalUrl: undefined,
|
||
queueStatus: undefined,
|
||
error: undefined,
|
||
};
|
||
}
|
||
|
||
interface SceneFusionViewProps {
|
||
initialProductImage?: string | null;
|
||
initialProductName?: string;
|
||
onBatchAddToCanvas?: (imageUrls: string[]) => void | Promise<void>;
|
||
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 getProcessItemPreviewUrl(item: ProcessItem) {
|
||
return String(item.result || item.originalUrl || item.image || "").trim();
|
||
}
|
||
|
||
function getProcessItemSourceUrl(item: ProcessItem) {
|
||
return String(item.originalUrl || item.result || item.image || "").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<string, unknown>;
|
||
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: "extend",
|
||
label: "批量图像拓展",
|
||
description: "多图同步扩展画面",
|
||
icon: Maximize2,
|
||
},
|
||
{
|
||
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 "extend":
|
||
return `批量图像拓展 (${count} 张)`;
|
||
case "watermark":
|
||
return `批量去水印 (${count} 张)`;
|
||
}
|
||
}
|
||
|
||
function getModePrompt(mode: BatchMode) {
|
||
switch (mode) {
|
||
case "rmbg":
|
||
return "批量处理-抠图";
|
||
case "enhance":
|
||
return "批量处理-变清晰";
|
||
case "extend":
|
||
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 aspectRatioOptionLabel = useMemo(
|
||
() =>
|
||
new Map<string, string>(
|
||
SCENE_FUSION_ASPECT_RATIO_OPTIONS.map((option) => [
|
||
option.value,
|
||
option.label,
|
||
])
|
||
),
|
||
[]
|
||
);
|
||
const imageSizeOptionLabel = useMemo(
|
||
() =>
|
||
new Map<string, string>(
|
||
SCENE_FUSION_IMAGE_SIZE_OPTIONS.map((option) => [
|
||
option.value,
|
||
option.label,
|
||
])
|
||
),
|
||
[]
|
||
);
|
||
const availableAspectRatioOptions = useMemo(
|
||
() => {
|
||
if (outputCapabilities?.aspectRatio?.enabled === false) {
|
||
return [];
|
||
}
|
||
const values = outputCapabilities?.aspectRatio?.options?.length
|
||
? outputCapabilities.aspectRatio.options
|
||
: SCENE_FUSION_ASPECT_RATIO_OPTIONS.map((option) => option.value);
|
||
return values.map((value) => ({
|
||
value,
|
||
label: aspectRatioOptionLabel.get(value) || value,
|
||
}));
|
||
},
|
||
[
|
||
aspectRatioOptionLabel,
|
||
outputCapabilities?.aspectRatio?.enabled,
|
||
outputCapabilities?.aspectRatio?.options,
|
||
]
|
||
);
|
||
const availableImageSizeOptions = useMemo(
|
||
() => {
|
||
if (outputCapabilities?.imageSize?.enabled === false) {
|
||
return [];
|
||
}
|
||
const values = outputCapabilities?.imageSize?.options?.length
|
||
? outputCapabilities.imageSize.options
|
||
: SCENE_FUSION_IMAGE_SIZE_OPTIONS.map((option) => option.value);
|
||
return values.map((value) => ({
|
||
value,
|
||
label: imageSizeOptionLabel.get(value) || value,
|
||
}));
|
||
},
|
||
[
|
||
imageSizeOptionLabel,
|
||
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 [referenceProductIdQuery, setReferenceProductIdQuery] = useState("");
|
||
const [sceneReferenceProductIdBatchInput, setSceneReferenceProductIdBatchInput] =
|
||
useState("");
|
||
const [referenceProductDetailChannel, setReferenceProductDetailChannel] =
|
||
useState<AgentWorkflowProductDetailChannel>("taobao");
|
||
const [isReferenceProductSearchLoading, setIsReferenceProductSearchLoading] =
|
||
useState(false);
|
||
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 [selectedResultIds, setSelectedResultIds] = useState<string[]>([]);
|
||
const [psdBrandDialogOpen, setPsdBrandDialogOpen] = useState(false);
|
||
const [psdBrandName, setPsdBrandName] = useState("");
|
||
|
||
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 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" && Boolean(getProcessItemSourceUrl(item))
|
||
),
|
||
[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<SceneFusionPersistedState> | 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<HTMLInputElement>) => {
|
||
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<HTMLInputElement>) => {
|
||
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<HTMLInputElement>) => {
|
||
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<HTMLInputElement>
|
||
) => {
|
||
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<HTMLInputElement>) => {
|
||
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<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)
|
||
? status === "pending" || status === "processing"
|
||
? markProcessItemRegenerating(item, status)
|
||
: {
|
||
...item,
|
||
status,
|
||
}
|
||
: item
|
||
)
|
||
);
|
||
};
|
||
|
||
const updateFusionItemsStatus = (
|
||
setter: React.Dispatch<React.SetStateAction<ProcessItem[]>>,
|
||
ids: string[],
|
||
status: ItemStatus
|
||
) => {
|
||
setter((prev) =>
|
||
prev.map((item) =>
|
||
ids.includes(item.id)
|
||
? status === "pending" || status === "processing"
|
||
? markProcessItemRegenerating(item, status)
|
||
: {
|
||
...item,
|
||
status,
|
||
}
|
||
: 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: "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
|
||
? data.status === "pending" || data.status === "processing"
|
||
? {
|
||
...markProcessItemRegenerating(item, data.status),
|
||
queueStatus: data.queueStatus || undefined,
|
||
}
|
||
: {
|
||
...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
|
||
? msg.data?.status === "pending" ||
|
||
msg.data?.status === "processing"
|
||
? {
|
||
...markProcessItemRegenerating(
|
||
item,
|
||
msg.data.status
|
||
),
|
||
queueStatus: msg.data.queueStatus || undefined,
|
||
}
|
||
: {
|
||
...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)
|
||
? markProcessItemRegenerating(item, "pending")
|
||
: 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
|
||
? markProcessItemRegenerating(item, "processing")
|
||
: 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 === "extend") {
|
||
const payload = await requestLongTaskResult({
|
||
endpoint: "/api/extend",
|
||
payload: {
|
||
image_url: item.image,
|
||
prompt: getModePrompt(mode),
|
||
includeBase64: false,
|
||
},
|
||
});
|
||
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)
|
||
? markProcessItemRegenerating(item, "pending")
|
||
: 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
|
||
? markProcessItemRegenerating(item, "processing")
|
||
: 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
|
||
? markProcessItemRegenerating(batchItem, "processing")
|
||
: 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
|
||
? markProcessItemRegenerating(productItem, "pending")
|
||
: 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" && Boolean(getProcessItemSourceUrl(item))
|
||
);
|
||
if (completed.length === 0) {
|
||
toast.error("没有可下载的图片");
|
||
return;
|
||
}
|
||
for (let i = 0; i < completed.length; i += 1) {
|
||
const item = completed[i]!;
|
||
await downloadImage(
|
||
getProcessItemSourceUrl(item),
|
||
`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) => getProcessItemSourceUrl(item))
|
||
.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: getProcessItemSourceUrl(item),
|
||
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 (
|
||
<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">
|
||
{onBack ? (
|
||
<Button
|
||
className="h-8 w-8 rounded-full"
|
||
onClick={onBack}
|
||
size="icon"
|
||
variant="ghost"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
</Button>
|
||
) : null}
|
||
<Layers className="h-4 w-4 text-primary" />
|
||
<span className="font-medium">批量处理</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4">
|
||
<div className="space-y-6">
|
||
<div className="space-y-2">
|
||
<Label className="font-medium text-base">处理模式</Label>
|
||
<Select
|
||
disabled={isProcessing}
|
||
onValueChange={(value) => setMode(value as BatchMode)}
|
||
value={mode}
|
||
>
|
||
<SelectTrigger className="h-10 bg-background">
|
||
<span className="truncate">
|
||
{selectedModeOption?.label || "请选择处理模式"}
|
||
</span>
|
||
</SelectTrigger>
|
||
<SelectContent className="z-[30000] !bg-white dark:!bg-zinc-950">
|
||
{modeOptions.map((option) => (
|
||
<SelectItem
|
||
className="items-start py-2"
|
||
key={option.id}
|
||
value={option.id}
|
||
>
|
||
<div className="flex flex-col">
|
||
<span className="text-sm">{option.label}</span>
|
||
<span className="text-muted-foreground text-xs">
|
||
{option.description}
|
||
</span>
|
||
</div>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-muted-foreground text-xs">
|
||
单次处理硬限制:最多 {MAX_BATCH_ITEMS} 张图片。
|
||
</p>
|
||
</div>
|
||
|
||
{(mode === "scene-fission" || mode === "random-bg") && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label className="font-medium text-base">
|
||
主图 A (必须)
|
||
</Label>
|
||
{(productImagePreview || productImage) && (
|
||
<Button
|
||
className="h-6 text-muted-foreground text-xs hover:text-destructive"
|
||
onClick={clearProductImage}
|
||
size="sm"
|
||
variant="ghost"
|
||
>
|
||
清除
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
className={cn(
|
||
"group relative cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors",
|
||
productImagePreview || productImage
|
||
? "border-primary/50 bg-primary/5"
|
||
: "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50"
|
||
)}
|
||
onClick={() =>
|
||
!(productImagePreview || productImage) &&
|
||
productInputRef.current?.click()
|
||
}
|
||
>
|
||
<input
|
||
accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
|
||
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,image/heic,image/heif,.heic,.heif"
|
||
className="hidden"
|
||
multiple
|
||
onChange={handleSceneUpload}
|
||
ref={sceneInputRef}
|
||
type="file"
|
||
/>
|
||
</div>
|
||
|
||
<div className="rounded-lg border bg-muted/20 p-3">
|
||
<div className="mb-2 text-muted-foreground text-xs">
|
||
支持批量输入商品ID,自动获取每个ID首张主图填充为场景参考图
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
<Input
|
||
className="h-8 text-xs"
|
||
disabled={isProcessing || isReferenceProductSearchLoading}
|
||
onChange={(e) =>
|
||
setSceneReferenceProductIdBatchInput(e.target.value)
|
||
}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
void handleSceneReferenceProductIdBatchSearch();
|
||
}
|
||
}}
|
||
placeholder={`批量输入商品ID,支持换行/逗号分隔,最多 ${Math.max(
|
||
maxScenes - sceneItems.length,
|
||
0
|
||
)} 个`}
|
||
value={sceneReferenceProductIdBatchInput}
|
||
/>
|
||
<div className="flex flex-col gap-2 sm:flex-row">
|
||
<Select
|
||
disabled={isProcessing || isReferenceProductSearchLoading}
|
||
onValueChange={(value) =>
|
||
setReferenceProductDetailChannel(
|
||
value as AgentWorkflowProductDetailChannel
|
||
)
|
||
}
|
||
value={referenceProductDetailChannel}
|
||
>
|
||
<SelectTrigger className="h-8 w-full text-xs sm:w-[110px]">
|
||
<SelectValue placeholder="渠道" />
|
||
</SelectTrigger>
|
||
<SelectContent className="z-[30000] !bg-white dark:!bg-zinc-950">
|
||
{PRODUCT_DETAIL_CHANNEL_OPTIONS.map((option) => (
|
||
<SelectItem key={option.value} value={option.value}>
|
||
{option.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
className="h-8 px-3 text-xs"
|
||
disabled={isProcessing || isReferenceProductSearchLoading}
|
||
onClick={() => void handleSceneReferenceProductIdBatchSearch()}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{isReferenceProductSearchLoading ? (
|
||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||
) : null}
|
||
批量查询主图
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</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,image/heic,image/heif,.heic,.heif"
|
||
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="rounded-lg border bg-muted/20 p-3">
|
||
<div className="mb-2 text-muted-foreground text-xs">
|
||
支持输入商品ID自动获取首张主图填充到参考图
|
||
</div>
|
||
<div className="flex flex-col gap-2 sm:flex-row">
|
||
<Input
|
||
className="h-8 text-xs"
|
||
disabled={isProcessing || isReferenceProductSearchLoading}
|
||
onChange={(e) => setReferenceProductIdQuery(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
void handleReferenceProductIdSearch();
|
||
}
|
||
}}
|
||
placeholder="输入商品ID"
|
||
value={referenceProductIdQuery}
|
||
/>
|
||
<Select
|
||
disabled={isProcessing || isReferenceProductSearchLoading}
|
||
onValueChange={(value) =>
|
||
setReferenceProductDetailChannel(
|
||
value as AgentWorkflowProductDetailChannel
|
||
)
|
||
}
|
||
value={referenceProductDetailChannel}
|
||
>
|
||
<SelectTrigger className="h-8 w-full text-xs sm:w-[110px]">
|
||
<SelectValue placeholder="渠道" />
|
||
</SelectTrigger>
|
||
<SelectContent className="z-[30000] !bg-white dark:!bg-zinc-950">
|
||
{PRODUCT_DETAIL_CHANNEL_OPTIONS.map((option) => (
|
||
<SelectItem key={option.value} value={option.value}>
|
||
{option.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
className="h-8 px-3 text-xs"
|
||
disabled={isProcessing || isReferenceProductSearchLoading}
|
||
onClick={() => void handleReferenceProductIdSearch()}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{isReferenceProductSearchLoading ? (
|
||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||
) : null}
|
||
查询主图
|
||
</Button>
|
||
</div>
|
||
</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,image/heic,image/heif,.heic,.heif"
|
||
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 === "extend" ||
|
||
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,image/heic,image/heif,.heic,.heif"
|
||
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">
|
||
<div>
|
||
<h3 className="font-medium">处理结果</h3>
|
||
{selectedCompletedResultItems.length > 0 ? (
|
||
<p className="mt-0.5 text-muted-foreground text-xs">
|
||
已选 {selectedCompletedResultItems.length} / {completedResultItems.length} 张完成结果
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
{(completedResultItems.length > 0 || onBatchAddToCanvas) && (
|
||
<div className="flex items-center gap-2">
|
||
{completedResultItems.length > 1 ? (
|
||
<Button
|
||
onClick={handleSelectAllCompletedResults}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
全选完成结果
|
||
</Button>
|
||
) : null}
|
||
{hasCanvasPsdLaunch && completedResultItems.length > 0 ? (
|
||
<Button
|
||
disabled={selectedCompletedResultItems.length === 0}
|
||
onClick={handleOpenPsdBrandDialog}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
<Layers className="mr-2 h-4 w-4" />
|
||
套PSD模版
|
||
</Button>
|
||
) : null}
|
||
{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) => (
|
||
(() => {
|
||
const resultPreviewUrl =
|
||
item.status === "completed"
|
||
? getProcessItemPreviewUrl(item)
|
||
: "";
|
||
const resultSourceUrl =
|
||
item.status === "completed"
|
||
? getProcessItemSourceUrl(item)
|
||
: "";
|
||
return (
|
||
<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",
|
||
resultSourceUrl && "cursor-zoom-in"
|
||
)}
|
||
onClick={() => {
|
||
if (resultSourceUrl) {
|
||
openResultPreview(
|
||
resultSourceUrl,
|
||
`任务 ${index + 1} 预览`
|
||
);
|
||
}
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key !== "Enter" && event.key !== " ")
|
||
return;
|
||
if (resultSourceUrl) {
|
||
event.preventDefault();
|
||
openResultPreview(
|
||
resultSourceUrl,
|
||
`任务 ${index + 1} 预览`
|
||
);
|
||
}
|
||
}}
|
||
role="button"
|
||
tabIndex={
|
||
resultSourceUrl ? 0 : -1
|
||
}
|
||
>
|
||
{resultPreviewUrl ? (
|
||
<img
|
||
alt="处理结果"
|
||
className="h-full w-full object-cover"
|
||
src={resultPreviewUrl}
|
||
/>
|
||
) : (
|
||
(() => {
|
||
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 localBatchQueueMeta =
|
||
isProcessing ? queueMeta : null;
|
||
const queueTitle =
|
||
item.status === "pending"
|
||
? serverQueuePosition > 0
|
||
? `排队中,前方 ${serverQueuePosition} 个`
|
||
: localBatchQueueMeta
|
||
? `批次内排队 ${localBatchQueueMeta.position}/${localBatchQueueMeta.total}`
|
||
: "等待开始"
|
||
: item.status === "processing"
|
||
? serverQueuePosition > 0
|
||
? `模型排队中,前方 ${serverQueuePosition} 个`
|
||
: localBatchQueueMeta
|
||
? `批次内执行 ${localBatchQueueMeta.position}/${localBatchQueueMeta.total}`
|
||
: "处理中..."
|
||
: null;
|
||
const queueDetail =
|
||
item.status === "pending"
|
||
? serverQueuePosition > 0
|
||
? `当前模型运行 ${serverRunningCount}/${Math.max(
|
||
concurrencyLimit,
|
||
serverRunningCount,
|
||
1
|
||
)},${formatQueueWaitLabel(
|
||
item.queueStatus?.estimatedWaitMs
|
||
)}`
|
||
: localBatchQueueMeta
|
||
? `当前批次位次 ${localBatchQueueMeta.position}/${localBatchQueueMeta.total}`
|
||
: "点击开始批量处理后进入队列"
|
||
: item.status === "processing" &&
|
||
serverQueuePosition > 0
|
||
? `当前模型运行 ${serverRunningCount}/${Math.max(
|
||
concurrencyLimit,
|
||
serverRunningCount,
|
||
1
|
||
)},${formatQueueWaitLabel(
|
||
item.queueStatus?.estimatedWaitMs
|
||
)}`
|
||
: null;
|
||
return (
|
||
<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" />
|
||
<div className="max-w-[90%] rounded bg-black/55 px-2.5 py-1.5 text-center text-white/95 text-xs">
|
||
<div>{queueTitle || "处理中..."}</div>
|
||
{queueDetail ? (
|
||
<div className="mt-1 text-[11px] text-white/70">
|
||
{queueDetail}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</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>
|
||
) : (
|
||
<div className="relative z-10 max-w-[90%] rounded bg-black/55 px-2.5 py-1.5 text-center text-white text-xs">
|
||
<div className="font-medium">
|
||
{queueTitle || "等待处理"}
|
||
</div>
|
||
{queueDetail ? (
|
||
<div className="mt-1 text-[11px] text-white/70">
|
||
{queueDetail}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})()
|
||
)}
|
||
|
||
{resultSourceUrl && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
toggleResultSelection(item.id);
|
||
}}
|
||
className={cn(
|
||
"absolute left-3 top-3 z-10 flex h-8 min-w-8 items-center justify-center rounded-full border bg-black/65 px-2 text-[11px] text-white transition-colors",
|
||
selectedResultIds.includes(item.id)
|
||
? "border-emerald-400/55 bg-emerald-500/80"
|
||
: "border-white/12 hover:border-white/25"
|
||
)}
|
||
aria-label={selectedResultIds.includes(item.id) ? "取消选择结果图" : "选择结果图"}
|
||
>
|
||
{selectedResultIds.includes(item.id) ? (
|
||
<Check className="h-4 w-4" />
|
||
) : (
|
||
<span>选中</span>
|
||
)}
|
||
</button>
|
||
<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(
|
||
resultSourceUrl,
|
||
`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>
|
||
)}
|
||
<Dialog onOpenChange={setPsdBrandDialogOpen} open={psdBrandDialogOpen}>
|
||
<DialogContent className="sm:max-w-[560px]">
|
||
<DialogHeader>
|
||
<DialogTitle>选择套 PSD 模版品牌</DialogTitle>
|
||
<DialogDescription>
|
||
当前会把 {selectedCompletedResultItems.length} 张已选结果图带到 PSD 套版页面,并按这个品牌过滤模板。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
{availablePsdBrands.length > 0 ? (
|
||
<div className="space-y-2">
|
||
<Label className="text-sm">快捷选择</Label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{availablePsdBrands.map((brand) => {
|
||
const active = psdBrandName.trim() === brand;
|
||
return (
|
||
<button
|
||
key={brand}
|
||
type="button"
|
||
onClick={() => setPsdBrandName(brand)}
|
||
className={cn(
|
||
"rounded-full border px-3 py-1.5 text-sm transition-colors",
|
||
active
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-background hover:bg-muted"
|
||
)}
|
||
>
|
||
{brand}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="scene-fusion-psd-brand-input">品牌名</Label>
|
||
<Input
|
||
id="scene-fusion-psd-brand-input"
|
||
placeholder="请输入风格化页面套 PSD 需要的品牌名"
|
||
value={psdBrandName}
|
||
onChange={(event) => setPsdBrandName(event.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="rounded-xl border bg-muted/30 p-3">
|
||
<div className="mb-2 font-medium text-sm">待带入图片</div>
|
||
<div className="grid grid-cols-4 gap-2">
|
||
{selectedCompletedResultItems.map((item, index) => {
|
||
const previewUrl = getProcessItemPreviewUrl(item);
|
||
return (
|
||
<div
|
||
key={`psd-selected-preview-${item.id}`}
|
||
className="overflow-hidden rounded-lg border bg-background"
|
||
>
|
||
<div className="aspect-square bg-muted/20">
|
||
<img
|
||
alt={`已选结果 ${index + 1}`}
|
||
className="h-full w-full object-cover"
|
||
src={previewUrl}
|
||
/>
|
||
</div>
|
||
<div className="truncate px-2 py-1 text-[11px] text-muted-foreground">
|
||
结果 {index + 1}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
onClick={() => setPsdBrandDialogOpen(false)}
|
||
variant="outline"
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleLaunchPsdTemplateWorkspace}>
|
||
打开套PSD模版页面
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|