feat: add multi-product-to-single-scene batch replacement mode
This commit is contained in:
parent
8fc52dfc76
commit
879aa932fc
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user