feat: add multi-product-to-single-scene batch replacement mode
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-05 19:41:22 +08:00
parent 8fc52dfc76
commit 879aa932fc

View File

@ -27,7 +27,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const MAX_BATCH_ITEMS = 10;
type BatchMode = "scene-fission" | "random-bg" | "rmbg" | "enhance" | "watermark";
type BatchMode = "scene-fission" | "multi-product-scene" | "random-bg" | "rmbg" | "enhance" | "watermark";
type ItemStatus = "pending" | "processing" | "completed" | "failed";
interface ProcessItem {
@ -77,6 +77,7 @@ 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 },
@ -87,6 +88,8 @@ 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":
@ -131,6 +134,10 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
const [productName, setProductName] = useState(initialProductName || "");
const [sceneItems, setSceneItems] = useState<ProcessItem[]>([]);
const [multiProductItems, setMultiProductItems] = useState<ProcessItem[]>([]);
const [referenceSceneImage, setReferenceSceneImage] = useState<string | null>(null);
const [referenceTargetName, setReferenceTargetName] = useState("");
const [referenceCustomPrompt, setReferenceCustomPrompt] = useState("");
const [randomItems, setRandomItems] = useState<ProcessItem[]>([]);
const [randomCount, setRandomCount] = useState<number>(4);
const [randomPrompt, setRandomPrompt] = useState("");
@ -139,20 +146,24 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
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, randomItems, sceneItems]);
}, [batchItems, mode, multiProductItems, randomItems, sceneItems]);
const modeCanStart = useMemo(() => {
if (mode === "scene-fission") return Boolean(productImage) && sceneItems.length > 0;
if (mode === "multi-product-scene") return multiProductItems.length > 0 && Boolean(referenceSceneImage);
if (mode === "random-bg") return Boolean(productImage) && randomCount > 0;
return batchItems.length > 0;
}, [batchItems.length, mode, productImage, randomCount, sceneItems.length]);
}, [batchItems.length, mode, multiProductItems.length, productImage, randomCount, referenceSceneImage, sceneItems.length]);
const updateProductPreview = (previewUrl: string | null, isObjectUrl = false) => {
if (productPreviewObjectUrlRef.current) {
@ -244,6 +255,54 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
e.target.value = "";
};
const handleMultiProductUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
if (multiProductItems.length + files.length > MAX_BATCH_ITEMS) {
toast.error(`单次最多上传 ${MAX_BATCH_ITEMS} 张产品图`);
return;
}
files.forEach((file) => {
if (file.size > 50 * 1024 * 1024) {
toast.error(`图片 ${file.name} 大小不能超过 50MB`);
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const image = event.target?.result as string;
setMultiProductItems((prev) => [
...prev,
{
id: Math.random().toString(36).slice(2, 10),
image,
status: "pending",
},
]);
};
reader.readAsDataURL(file);
});
e.target.value = "";
};
const handleReferenceSceneUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const image = event.target?.result as string;
setReferenceSceneImage(image);
};
reader.readAsDataURL(file);
e.target.value = "";
};
const handleBatchUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
@ -283,6 +342,10 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
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));
};
@ -431,6 +494,180 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
}
};
const runSingleSceneFusionRequest = async (params: {
productImageInput: string;
sceneImageInput: string;
requestId: string;
targetName?: string;
customPrompt?: string;
}) => {
const response = await fetch("/api/scene-fusion", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
productImage: params.productImageInput,
productName: productName || undefined,
aspectRatio,
imageSize,
scenes: [
{
id: params.requestId,
image: params.sceneImageInput,
targetName: params.targetName || undefined,
customPrompt: params.customPrompt || undefined,
},
],
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.error || "处理失败");
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("读取流式响应失败");
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
const msg = JSON.parse(line) as {
type: string;
data?: {
id: string;
status: "completed" | "failed";
result?: string;
originalUrl?: string;
error?: string;
};
error?: string;
};
if (msg.type === "result" && msg.data?.id === params.requestId) {
if (msg.data.status === "completed") {
return {
result: msg.data.result || params.sceneImageInput,
originalUrl: msg.data.originalUrl || msg.data.result || params.sceneImageInput,
};
}
throw new Error(msg.data.error || "生成失败");
}
if (msg.type === "error") {
throw new Error(msg.error || "处理失败");
}
}
}
throw new Error("未收到生成结果");
};
const runMultiProductSceneMode = async () => {
if (multiProductItems.length === 0) {
toast.error("请先上传产品图");
return;
}
if (!referenceSceneImage) {
toast.error("请先上传参考图");
return;
}
if (config?.enabled === false) {
toast.error("批量处理功能已被管理员禁用");
return;
}
let itemsToProcess = multiProductItems.filter((item) => item.status === "pending" || item.status === "failed");
if (itemsToProcess.length === 0) {
itemsToProcess = multiProductItems;
}
if (itemsToProcess.length > MAX_BATCH_ITEMS) {
toast.error(`单次最多处理 ${MAX_BATCH_ITEMS} 张产品图`);
return;
}
setIsProcessing(true);
setMultiProductItems((prev) =>
prev.map((item) =>
itemsToProcess.some((target) => target.id === item.id)
? { ...item, status: "processing", error: undefined }
: item
)
);
const queue = [...itemsToProcess];
const workerCount = Math.max(1, Math.min(2, queue.length));
let successCount = 0;
let failedCount = 0;
const workers = Array.from({ length: workerCount }, async () => {
while (queue.length > 0) {
const current = queue.shift();
if (!current) break;
const requestId = `multi-product-${current.id}-${Date.now()}`;
try {
const result = await runSingleSceneFusionRequest({
productImageInput: current.image,
sceneImageInput: referenceSceneImage,
requestId,
targetName: referenceTargetName || undefined,
customPrompt: referenceCustomPrompt || undefined,
});
successCount += 1;
setMultiProductItems((prev) =>
prev.map((item) =>
item.id === current.id
? {
...item,
status: "completed",
result: result.result,
originalUrl: result.originalUrl,
error: undefined,
}
: item
)
);
} catch (error) {
const message = error instanceof Error ? error.message : "处理失败";
failedCount += 1;
setMultiProductItems((prev) =>
prev.map((item) =>
item.id === current.id
? {
...item,
status: "failed",
error: message,
}
: item
)
);
}
}
});
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);
@ -626,6 +863,11 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
return;
}
if (mode === "multi-product-scene") {
await runMultiProductSceneMode();
return;
}
await runBatchImageMode();
};
@ -659,6 +901,20 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
return;
}
if (mode === "multi-product-scene") {
if (!referenceSceneImage) {
toast.error("请先上传参考图");
return;
}
setMultiProductItems((prev) =>
prev.map((productItem) =>
productItem.id === item.id ? { ...productItem, status: "pending", error: undefined } : productItem
)
);
await runMultiProductSceneMode();
return;
}
setBatchItems((prev) =>
prev.map((batchItem) =>
batchItem.id === item.id ? { ...batchItem, status: "pending", error: undefined } : batchItem
@ -754,109 +1010,109 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
</div>
{(mode === "scene-fission" || mode === "random-bg") && (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">主图 A (必须)</Label>
{(productImagePreview || productImage) && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs text-muted-foreground hover:text-destructive"
onClick={clearProductImage}
>
清除
</Button>
)}
</div>
<div
className={cn(
"border-2 border-dashed rounded-lg p-4 transition-colors text-center cursor-pointer relative group",
productImagePreview || productImage
? "border-primary/50 bg-primary/5"
: "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50"
)}
onClick={() => !(productImagePreview || productImage) && productInputRef.current?.click()}
>
<input
type="file"
ref={productInputRef}
className="hidden"
accept="image/png,image/jpeg,image/webp"
onChange={handleProductUpload}
/>
{productImagePreview || productImage ? (
<div className="relative aspect-square w-full max-w-[200px] mx-auto">
<img
src={productImagePreview || productImage || ""}
alt="主图"
className="w-full h-full object-contain rounded-md"
/>
</div>
) : (
<div className="py-8 flex flex-col items-center gap-2 text-muted-foreground">
<Upload className="w-8 h-8 opacity-50" />
<span className="text-sm">点击上传主图</span>
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">主图 A (必须)</Label>
{(productImagePreview || productImage) && (
<Input
placeholder="主图名称(如:沙发、椅子)"
value={productName}
onChange={(e) => setProductName(e.target.value)}
className="h-9 text-sm"
disabled={isProcessing}
/>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs text-muted-foreground hover:text-destructive"
onClick={clearProductImage}
>
清除
</Button>
)}
</div>
<div className="space-y-3">
<Label className="text-base font-medium">输出设置</Label>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">图片比例</label>
<div className="flex flex-wrap gap-1.5">
{aspectRatioOptions.map((option) => (
<button
key={option.value}
onClick={() => setAspectRatio(option.value)}
disabled={isProcessing}
className={cn(
"px-2.5 py-1 text-xs rounded-md border transition-colors",
aspectRatio === option.value
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-muted border-input"
)}
>
{option.label}
</button>
))}
<div
className={cn(
"border-2 border-dashed rounded-lg p-4 transition-colors text-center cursor-pointer relative group",
productImagePreview || productImage
? "border-primary/50 bg-primary/5"
: "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50"
)}
onClick={() => !(productImagePreview || productImage) && productInputRef.current?.click()}
>
<input
type="file"
ref={productInputRef}
className="hidden"
accept="image/png,image/jpeg,image/webp"
onChange={handleProductUpload}
/>
{productImagePreview || productImage ? (
<div className="relative aspect-square w-full max-w-[200px] mx-auto">
<img
src={productImagePreview || productImage || ""}
alt="主图"
className="w-full h-full object-contain rounded-md"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">输出分辨率</label>
<div className="flex flex-wrap gap-1.5">
{imageSizeOptions.map((option) => (
<button
key={option.value}
onClick={() => setImageSize(option.value)}
disabled={isProcessing}
className={cn(
"px-2.5 py-1 text-xs rounded-md border transition-colors",
imageSize === option.value
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-muted border-input"
)}
>
{option.label}
</button>
))}
) : (
<div className="py-8 flex flex-col items-center gap-2 text-muted-foreground">
<Upload className="w-8 h-8 opacity-50" />
<span className="text-sm">点击上传主图</span>
</div>
)}
</div>
{(productImagePreview || productImage) && (
<Input
placeholder="主图名称(如:沙发、椅子)"
value={productName}
onChange={(e) => setProductName(e.target.value)}
className="h-9 text-sm"
disabled={isProcessing}
/>
)}
</div>
)}
{(mode === "scene-fission" || mode === "random-bg" || mode === "multi-product-scene") && (
<div className="space-y-3">
<Label className="text-base font-medium">输出设置</Label>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">图片比例</label>
<div className="flex flex-wrap gap-1.5">
{aspectRatioOptions.map((option) => (
<button
key={option.value}
onClick={() => setAspectRatio(option.value)}
disabled={isProcessing}
className={cn(
"px-2.5 py-1 text-xs rounded-md border transition-colors",
aspectRatio === option.value
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-muted border-input"
)}
>
{option.label}
</button>
))}
</div>
</div>
</>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">输出分辨率</label>
<div className="flex flex-wrap gap-1.5">
{imageSizeOptions.map((option) => (
<button
key={option.value}
onClick={() => setImageSize(option.value)}
disabled={isProcessing}
className={cn(
"px-2.5 py-1 text-xs rounded-md border transition-colors",
imageSize === option.value
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-muted border-input"
)}
>
{option.label}
</button>
))}
</div>
</div>
</div>
)}
{mode === "scene-fission" && (
@ -937,6 +1193,134 @@ export function SceneFusionView({ onBack, initialProductImage, initialProductNam
</div>
)}
{mode === "multi-product-scene" && (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">产品图列表 ({multiProductItems.length}/{MAX_BATCH_ITEMS})</Label>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => multiProductInputRef.current?.click()}
disabled={multiProductItems.length >= MAX_BATCH_ITEMS || isProcessing}
>
<Plus className="w-3 h-3" /> 批量添加
</Button>
<input
type="file"
ref={multiProductInputRef}
className="hidden"
multiple
accept="image/png,image/jpeg,image/webp"
onChange={handleMultiProductUpload}
/>
</div>
{multiProductItems.length === 0 ? (
<div
className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => multiProductInputRef.current?.click()}
>
<ImageIcon className="w-8 h-8 mx-auto opacity-20 mb-2" />
<p className="text-sm text-muted-foreground">点击上传产品图(可多张)</p>
<p className="text-xs text-muted-foreground/60 mt-1">最多 {MAX_BATCH_ITEMS} 张</p>
</div>
) : (
<div className="space-y-3">
{multiProductItems.map((item, index) => (
<div key={item.id} className="border rounded-lg p-3 bg-background relative group">
<div className="flex gap-3 items-center">
<div className="w-20 h-20 shrink-0 bg-muted rounded-md overflow-hidden border">
<img src={item.image} alt={`产品 ${index + 1}`} className="w-full h-full object-cover" />
</div>
<div className="flex-1">
<div className="text-xs font-medium text-muted-foreground">产品 {index + 1}</div>
<div className="text-xs text-muted-foreground mt-1">
状态:{item.status === "pending" ? "等待" : item.status === "processing" ? "处理中" : item.status === "completed" ? "完成" : "失败"}
</div>
{item.error && <div className="text-xs text-destructive mt-1 line-clamp-2">{item.error}</div>}
</div>
{!isProcessing && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => removeMultiProductItem(item.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">参考图 B(统一替换目标)</Label>
{referenceSceneImage && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs text-muted-foreground hover:text-destructive"
onClick={() => setReferenceSceneImage(null)}
>
清除
</Button>
)}
</div>
<div
className={cn(
"border-2 border-dashed rounded-lg p-4 transition-colors text-center cursor-pointer relative group",
referenceSceneImage
? "border-primary/50 bg-primary/5"
: "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50"
)}
onClick={() => !referenceSceneImage && referenceSceneInputRef.current?.click()}
>
<input
type="file"
ref={referenceSceneInputRef}
className="hidden"
accept="image/png,image/jpeg,image/webp"
onChange={handleReferenceSceneUpload}
/>
{referenceSceneImage ? (
<div className="relative aspect-square w-full max-w-[220px] mx-auto">
<img
src={referenceSceneImage}
alt="参考图"
className="w-full h-full object-cover rounded-md"
/>
</div>
) : (
<div className="py-8 flex flex-col items-center gap-2 text-muted-foreground">
<Upload className="w-8 h-8 opacity-50" />
<span className="text-sm">点击上传参考图</span>
</div>
)}
</div>
<Input
placeholder="被替换物体(可选,如:椅子、花瓶)"
value={referenceTargetName}
onChange={(e) => setReferenceTargetName(e.target.value)}
className="h-8 text-xs"
disabled={isProcessing}
/>
<Input
placeholder="补充要求(可选)"
value={referenceCustomPrompt}
onChange={(e) => setReferenceCustomPrompt(e.target.value)}
className="h-8 text-xs"
disabled={isProcessing}
/>
</div>
</div>
)}
{mode === "random-bg" && (
<div className="space-y-3">
<Label className="text-base font-medium">随机背景参数</Label>