feat: add workflow material upload cutout flow
This commit is contained in:
parent
117621b8cb
commit
3e935fae37
134
app/api/agent-workflow/material-upload/route.ts
Normal file
134
app/api/agent-workflow/material-upload/route.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { executeRmbgJob, RmbgServiceError } from "@/lib/ai/rmbg/service";
|
||||
import { upsertMediaAsset } from "@/lib/db/queries";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 180;
|
||||
|
||||
const payloadSchema = z.object({
|
||||
source: z.enum(["primary", "secondary"]).default("primary"),
|
||||
query: z.string().trim().max(64).optional(),
|
||||
queryType: z.enum(["sku", "spu"]).optional(),
|
||||
matchedSku: z.string().trim().max(64).optional(),
|
||||
matchedSpu: z.string().trim().max(64).optional(),
|
||||
productModel: z.string().trim().max(128).optional(),
|
||||
});
|
||||
|
||||
function sanitizeFilenameStem(value: string) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.replace(/\.[^.]+$/, "")
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "缺少图片文件" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!String(file.type || "").startsWith("image/")) {
|
||||
return NextResponse.json({ error: "请上传图片文件" }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = payloadSchema.parse({
|
||||
source: formData.get("source"),
|
||||
query: formData.get("query"),
|
||||
queryType: formData.get("queryType"),
|
||||
matchedSku: formData.get("matchedSku"),
|
||||
matchedSpu: formData.get("matchedSpu"),
|
||||
productModel: formData.get("productModel"),
|
||||
});
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const imageBuffer = Buffer.from(arrayBuffer);
|
||||
const promptPrefix = payload.source === "secondary" ? "搭配SKU" : "主SPU";
|
||||
const prompt = `${promptPrefix}素材抠图`;
|
||||
|
||||
const result = await executeRmbgJob({
|
||||
userId: session.user.id,
|
||||
imageBuffer,
|
||||
prompt,
|
||||
skipGallerySave: true,
|
||||
});
|
||||
|
||||
const originalStem = sanitizeFilenameStem(file.name) || `workflow-upload-${Date.now()}`;
|
||||
const displayName = `${originalStem}-rmbg.png`;
|
||||
const asset = await upsertMediaAsset({
|
||||
userId: session.user.id,
|
||||
mediaType: "image",
|
||||
sourceType: "agent_workflow_material_upload",
|
||||
status: "ready",
|
||||
originalUrl: result.original_url,
|
||||
previewUrl: result.original_url,
|
||||
thumbnailUrl: result.thumbnail_url,
|
||||
mimeType: "image/png",
|
||||
displayName,
|
||||
prompt,
|
||||
modelId: result.provider,
|
||||
sourceTable: "agent_workflow_material_upload",
|
||||
metadata: {
|
||||
source: payload.source,
|
||||
query: payload.query || null,
|
||||
queryType: payload.queryType || "sku",
|
||||
matchedSku: payload.matchedSku || null,
|
||||
matchedSpu: payload.matchedSpu || null,
|
||||
productModel: payload.productModel || null,
|
||||
originalFileName: file.name,
|
||||
provider: result.provider,
|
||||
},
|
||||
});
|
||||
|
||||
const imageId = asset?.id || `upload-${randomUUID()}`;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: imageId,
|
||||
key: imageId,
|
||||
label: "用户上传",
|
||||
fileName: displayName,
|
||||
imageUrl: result.original_url,
|
||||
assetId: asset?.id ?? null,
|
||||
mimeType: "image/png",
|
||||
sourceType: "upload",
|
||||
group: "other",
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.issues[0]?.message || "请求参数不合法" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof RmbgServiceError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, detail: error.detail || error.message },
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[Agent Workflow Material Upload] Failed to process upload:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "上传失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Check, ImageIcon, Loader2, RefreshCw, Search } from "lucide-react";
|
||||
import { Check, ImageIcon, Loader2, RefreshCw, Search, Trash2, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
AgentWorkflowMaterialImage,
|
||||
AgentWorkflowMaterialQueryType,
|
||||
AgentWorkflowMaterialSearchResult,
|
||||
} from "@/types/agent";
|
||||
@ -32,6 +33,15 @@ interface AgentWorkflowMaterialPickerProps {
|
||||
onSearch: () => void;
|
||||
onSkuOptionSelect?: (skuName: string) => void;
|
||||
onSelectedImageIdsChange: (next: string[]) => void;
|
||||
onUploadImage?: (
|
||||
file: File,
|
||||
options?: {
|
||||
replaceImageId?: string;
|
||||
}
|
||||
) => Promise<void>;
|
||||
onRemoveUploadedImage?: (imageId: string) => void;
|
||||
isUploadingNewImage?: boolean;
|
||||
uploadingImageId?: string | null;
|
||||
theme?: "light" | "dark";
|
||||
}
|
||||
|
||||
@ -56,9 +66,14 @@ export function AgentWorkflowMaterialPicker({
|
||||
onSearch,
|
||||
onSkuOptionSelect,
|
||||
onSelectedImageIdsChange,
|
||||
onUploadImage,
|
||||
onRemoveUploadedImage,
|
||||
isUploadingNewImage = false,
|
||||
uploadingImageId = null,
|
||||
theme = "light",
|
||||
}: AgentWorkflowMaterialPickerProps) {
|
||||
const isDark = theme === "dark";
|
||||
const hasUploadInFlight = isUploadingNewImage || Boolean(uploadingImageId);
|
||||
const [previewDisplayMode, setPreviewDisplayMode] = useState<"selected" | "all">("all");
|
||||
const [hasManualSelectionInteraction, setHasManualSelectionInteraction] = useState(false);
|
||||
const selectedSet = useMemo(
|
||||
@ -72,15 +87,27 @@ export function AgentWorkflowMaterialPicker({
|
||||
: [],
|
||||
[result?.recommendedImageIds]
|
||||
);
|
||||
const selectedCount = result?.images
|
||||
? result.images.filter((item) => selectedSet.has(item.id)).length
|
||||
: 0;
|
||||
const allImages = result?.images || [];
|
||||
const uploadedImages = useMemo(
|
||||
() =>
|
||||
allImages
|
||||
.filter((item) => item.sourceType === "upload")
|
||||
.slice()
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder),
|
||||
[allImages]
|
||||
);
|
||||
const galleryImages = useMemo(
|
||||
() => allImages.filter((item) => item.sourceType !== "upload"),
|
||||
[allImages]
|
||||
);
|
||||
const selectedCount = allImages.filter((item) => selectedSet.has(item.id)).length;
|
||||
const selectedGalleryCount = galleryImages.filter((item) => selectedSet.has(item.id)).length;
|
||||
const resolvedSelectionLimit =
|
||||
typeof selectionLimit === "number" && Number.isFinite(selectionLimit) && selectionLimit > 0
|
||||
? Math.max(1, Math.round(selectionLimit))
|
||||
: null;
|
||||
const hasSelectableImages = Boolean(
|
||||
result?.images?.some((item) => Boolean(String(item.imageUrl || "").trim()))
|
||||
allImages.some((item) => Boolean(String(item.imageUrl || "").trim()))
|
||||
);
|
||||
const goodsInfoItems = [
|
||||
{
|
||||
@ -111,33 +138,41 @@ export function AgentWorkflowMaterialPicker({
|
||||
].filter((item) => Boolean(item.value));
|
||||
const remainingSelectableCount =
|
||||
resolvedSelectionLimit === null ? null : Math.max(resolvedSelectionLimit - selectedCount, 0);
|
||||
const totalImageCount = result?.images?.length || 0;
|
||||
const remainingUploadSlotCount =
|
||||
resolvedSelectionLimit === null
|
||||
? 0
|
||||
: Math.max(
|
||||
resolvedSelectionLimit - selectedGalleryCount - uploadedImages.length,
|
||||
0
|
||||
);
|
||||
const totalImageCount = galleryImages.length;
|
||||
const hasSystemRecommendedSelection =
|
||||
result?.queryType === "sku" &&
|
||||
recommendedImageIds.length > 0 &&
|
||||
selectedImageIds.length === recommendedImageIds.length &&
|
||||
selectedGalleryCount === recommendedImageIds.length &&
|
||||
recommendedImageIds.every((id) => selectedSet.has(id));
|
||||
const isAutoSelectedResult =
|
||||
Boolean(hasSystemRecommendedSelection) && !hasManualSelectionInteraction;
|
||||
const canTogglePreviewMode = totalImageCount > 0 && selectedCount > 0 && selectedCount < totalImageCount;
|
||||
const canTogglePreviewMode =
|
||||
totalImageCount > 0 && selectedGalleryCount > 0 && selectedGalleryCount < totalImageCount;
|
||||
const visibleImages = useMemo(() => {
|
||||
if (!result?.images?.length) {
|
||||
if (!galleryImages.length) {
|
||||
return [];
|
||||
}
|
||||
if (previewDisplayMode === "selected" && selectedCount > 0) {
|
||||
return result.images.filter((image) => selectedSet.has(image.id));
|
||||
if (previewDisplayMode === "selected" && selectedGalleryCount > 0) {
|
||||
return galleryImages.filter((image) => selectedSet.has(image.id));
|
||||
}
|
||||
return result.images;
|
||||
}, [previewDisplayMode, result?.images, selectedCount, selectedSet]);
|
||||
return galleryImages;
|
||||
}, [galleryImages, previewDisplayMode, selectedGalleryCount, selectedSet]);
|
||||
const previewResetKey = useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
queryType: result?.queryType || "",
|
||||
matchedSku: result?.matchedSku || "",
|
||||
imageIds: result?.images?.map((image) => image.id) || [],
|
||||
imageIds: galleryImages.map((image) => image.id),
|
||||
recommendedImageIds,
|
||||
}),
|
||||
[recommendedImageIds, result?.images, result?.matchedSku, result?.queryType]
|
||||
[galleryImages, recommendedImageIds, result?.matchedSku, result?.queryType]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -170,10 +205,9 @@ export function AgentWorkflowMaterialPicker({
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setHasManualSelectionInteraction(true);
|
||||
const allIds =
|
||||
result?.images
|
||||
?.filter((item) => Boolean(String(item.imageUrl || "").trim()))
|
||||
.map((item) => item.id) || [];
|
||||
const allIds = allImages
|
||||
.filter((item) => Boolean(String(item.imageUrl || "").trim()))
|
||||
.map((item) => item.id);
|
||||
const nextIds =
|
||||
resolvedSelectionLimit !== null ? allIds.slice(0, resolvedSelectionLimit) : allIds;
|
||||
onSelectedImageIdsChange(nextIds);
|
||||
@ -182,6 +216,96 @@ export function AgentWorkflowMaterialPicker({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadInputChange = async (
|
||||
file: File | null,
|
||||
options?: {
|
||||
replaceImageId?: string;
|
||||
}
|
||||
) => {
|
||||
if (!file || !onUploadImage) return;
|
||||
if (!String(file.type || "").startsWith("image/")) {
|
||||
toast.error("请上传图片文件");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onUploadImage(file, options);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
const renderUploadPreview = (params: {
|
||||
image?: AgentWorkflowMaterialImage | null;
|
||||
selected?: boolean;
|
||||
isUploading?: boolean;
|
||||
}) => {
|
||||
const { image, selected = false, isUploading = false } = params;
|
||||
const hasImage = Boolean(image?.imageUrl);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative aspect-square overflow-hidden rounded-[22px] border",
|
||||
hasImage
|
||||
? isDark
|
||||
? "border-white/10 bg-white/[0.04]"
|
||||
: "border-border bg-muted/20"
|
||||
: isDark
|
||||
? "border-dashed border-white/12 bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.16),transparent_55%),rgba(255,255,255,0.02)]"
|
||||
: "border-dashed border-primary/25 bg-primary/5"
|
||||
)}
|
||||
>
|
||||
{hasImage ? (
|
||||
<img
|
||||
src={image?.imageUrl}
|
||||
alt={image?.label || "上传素材"}
|
||||
className="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 px-4 text-center">
|
||||
{isUploading ? (
|
||||
<Loader2 className={cn("h-7 w-7 animate-spin", isDark ? "text-emerald-200" : "text-primary")} />
|
||||
) : (
|
||||
<Upload className={cn("h-7 w-7", isDark ? "text-emerald-200" : "text-primary")} />
|
||||
)}
|
||||
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "text-foreground")}>
|
||||
{isUploading ? "正在自动抠图" : "上传商品图"}
|
||||
</div>
|
||||
<div className={cn("text-[11px] leading-5", isDark ? "text-white/55" : "text-muted-foreground")}>
|
||||
{isUploading ? "系统会先抠图,再把透明底结果加入候选区。" : "支持 PNG / JPG / WEBP,上传后会自动抠图。"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
|
||||
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">
|
||||
{hasImage ? "用户上传" : "自动抠图上传"}
|
||||
</span>
|
||||
{hasImage ? (
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-full border text-white transition-colors",
|
||||
selected
|
||||
? isDark
|
||||
? "border-emerald-400 bg-emerald-400 text-black"
|
||||
: "border-primary bg-primary"
|
||||
: "border-white/60 bg-black/35"
|
||||
)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 via-black/25 to-transparent px-3 py-2 text-[11px] text-white">
|
||||
{hasImage ? image?.fileName || image?.label : isUploading ? "抠图处理中..." : "没有搜索到时也可以直接上传"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
@ -208,7 +332,7 @@ export function AgentWorkflowMaterialPicker({
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => onQueryModeChange(mode)}
|
||||
disabled={disabled}
|
||||
disabled={disabled || hasUploadInFlight}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-xs transition-colors",
|
||||
queryMode === mode
|
||||
@ -232,7 +356,7 @@ export function AgentWorkflowMaterialPicker({
|
||||
value={value}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
disabled={disabled || hasUploadInFlight}
|
||||
className={cn(
|
||||
hasFetchSuccess && "pr-11",
|
||||
isDark &&
|
||||
@ -241,7 +365,7 @@ export function AgentWorkflowMaterialPicker({
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
if (disabled || hasUploadInFlight) return;
|
||||
onSearch();
|
||||
}
|
||||
}}
|
||||
@ -255,7 +379,7 @@ export function AgentWorkflowMaterialPicker({
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onSearch}
|
||||
disabled={disabled || isLoading || !value.trim()}
|
||||
disabled={disabled || isLoading || hasUploadInFlight || !value.trim()}
|
||||
className={cn(
|
||||
isDark &&
|
||||
"border-white/12 bg-white/[0.05] text-white/85 hover:bg-white/[0.11] hover:text-white disabled:text-white/40"
|
||||
@ -289,6 +413,171 @@ export function AgentWorkflowMaterialPicker({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!disabled && (resolvedSelectionLimit !== null || uploadedImages.length > 0) ? (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-3 rounded-[24px] border p-3",
|
||||
isDark ? "border-white/12 bg-white/[0.03]" : "border-border bg-background/70"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className={cn("text-sm font-medium", isDark ? "text-white/90" : "text-foreground")}>
|
||||
直接上传商品图
|
||||
</div>
|
||||
<div className={cn("text-xs leading-5", isDark ? "text-white/58" : "text-muted-foreground")}>
|
||||
搜索不到合适白底图时,可以直接上传原图。系统会自动抠图后再加入当前候选素材。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{resolvedSelectionLimit !== null ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-1",
|
||||
isDark ? "bg-white/8 text-white/82" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
当前模式需 {resolvedSelectionLimit} 张产品图
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-1",
|
||||
isDark ? "bg-white/8 text-white/82" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
已上传 {uploadedImages.length} 张
|
||||
</span>
|
||||
{remainingUploadSlotCount > 0 ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-1",
|
||||
isDark ? "bg-emerald-500/[0.12] text-emerald-200" : "bg-emerald-50 text-emerald-700"
|
||||
)}
|
||||
>
|
||||
还可上传 {remainingUploadSlotCount} 张
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadedImages.length > 0 || remainingUploadSlotCount > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{uploadedImages.map((image) => {
|
||||
const isSelected = selectedSet.has(image.id);
|
||||
const isReplacing = uploadingImageId === image.id;
|
||||
return (
|
||||
<div
|
||||
key={`uploaded-material-${image.id}`}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-[24px] border transition-all",
|
||||
isSelected
|
||||
? isDark
|
||||
? "border-emerald-400/70 bg-white/[0.03] shadow-[0_0_0_1px_rgba(52,211,153,0.2)]"
|
||||
: "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
|
||||
: isDark
|
||||
? "border-white/12 bg-white/[0.02]"
|
||||
: "border-border bg-background"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleImage(image.id)}
|
||||
className="block w-full text-left"
|
||||
>
|
||||
{renderUploadPreview({
|
||||
image,
|
||||
selected: isSelected,
|
||||
isUploading: isReplacing,
|
||||
})}
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-2 px-3 py-3">
|
||||
<label
|
||||
className={cn(
|
||||
"inline-flex cursor-pointer items-center justify-center rounded-full border px-3 py-2 text-xs font-medium transition-colors",
|
||||
isDark
|
||||
? "border-white/12 bg-white/[0.04] text-white/82 hover:bg-white/[0.10] hover:text-white"
|
||||
: "border-border bg-background text-foreground hover:bg-muted/60",
|
||||
(isReplacing || isLoading) && "pointer-events-none opacity-50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
disabled={isReplacing || isUploadingNewImage || isLoading}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] || null;
|
||||
event.currentTarget.value = "";
|
||||
void handleUploadInputChange(file, { replaceImageId: image.id });
|
||||
}}
|
||||
/>
|
||||
{isReplacing ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
替换
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onRemoveUploadedImage?.(image.id)}
|
||||
disabled={isReplacing || isUploadingNewImage || isLoading}
|
||||
className={cn(
|
||||
isDark &&
|
||||
"border-white/12 bg-white/[0.04] text-white/82 hover:bg-white/[0.10] hover:text-white disabled:text-white/35"
|
||||
)}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{Array.from({ length: remainingUploadSlotCount }, (_, index) => (
|
||||
<label
|
||||
key={`new-upload-slot-${index + 1}`}
|
||||
className={cn(
|
||||
"block cursor-pointer overflow-hidden rounded-[24px] border transition-all",
|
||||
isDark
|
||||
? "border-dashed border-emerald-400/25 bg-white/[0.02] hover:border-emerald-300/45"
|
||||
: "border-dashed border-primary/30 bg-primary/5 hover:border-primary/50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
disabled={isUploadingNewImage || Boolean(uploadingImageId) || isLoading}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] || null;
|
||||
event.currentTarget.value = "";
|
||||
void handleUploadInputChange(file);
|
||||
}}
|
||||
/>
|
||||
{renderUploadPreview({
|
||||
isUploading: isUploadingNewImage && index === 0,
|
||||
})}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-[20px] border px-3 py-2 text-xs",
|
||||
isDark ? "border-white/10 bg-black/20 text-white/55" : "border-border bg-muted/20 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
当前上传槽位已满。若要改用用户上传图片,可先取消一张检索图,或删除上面已上传的图片后再补充。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!disabled && result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
@ -321,21 +610,31 @@ export function AgentWorkflowMaterialPicker({
|
||||
型号:{result.productModel}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-1",
|
||||
isDark ? "bg-white/8 text-white/82" : "bg-background"
|
||||
)}
|
||||
>
|
||||
共 {result.totalCount} 张
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-1",
|
||||
isDark ? "bg-white/8 text-white/82" : "bg-background"
|
||||
)}
|
||||
>
|
||||
检索图 {totalImageCount} 张
|
||||
</span>
|
||||
{uploadedImages.length > 0 ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-1",
|
||||
isDark ? "bg-white/8 text-white/82" : "bg-background"
|
||||
)}
|
||||
>
|
||||
已选 {selectedCount} 张
|
||||
上传图 {uploadedImages.length} 张
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-1",
|
||||
isDark ? "bg-white/8 text-white/82" : "bg-background"
|
||||
)}
|
||||
>
|
||||
已选 {selectedCount} 张
|
||||
</span>
|
||||
{resolvedSelectionLimit !== null ? (
|
||||
<span
|
||||
@ -360,7 +659,7 @@ export function AgentWorkflowMaterialPicker({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewDisplayMode("selected")}
|
||||
disabled={selectedCount === 0}
|
||||
disabled={selectedGalleryCount === 0}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
previewDisplayMode === "selected"
|
||||
@ -372,7 +671,7 @@ export function AgentWorkflowMaterialPicker({
|
||||
: "text-muted-foreground disabled:text-muted-foreground/50"
|
||||
)}
|
||||
>
|
||||
已选 {selectedCount}
|
||||
已选 {selectedGalleryCount}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -516,7 +815,7 @@ export function AgentWorkflowMaterialPicker({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result.images?.length ? (
|
||||
{totalImageCount > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{isAutoSelectedResult || canTogglePreviewMode ? (
|
||||
<div
|
||||
@ -529,7 +828,7 @@ export function AgentWorkflowMaterialPicker({
|
||||
>
|
||||
<div className="text-xs leading-5">
|
||||
{previewDisplayMode === "selected"
|
||||
? `当前优先展示已选中的 ${selectedCount} 张图片,便于你快速确认结果。`
|
||||
? `当前优先展示已选中的 ${selectedGalleryCount} 张检索图,便于你快速确认结果。`
|
||||
: `当前展示全部 ${totalImageCount} 张候选图片,可继续手动微调选图。`}
|
||||
</div>
|
||||
{canTogglePreviewMode ? (
|
||||
@ -685,7 +984,9 @@ export function AgentWorkflowMaterialPicker({
|
||||
<RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{result?.skuOptions?.length
|
||||
? "当前是 SPU 命中结果,请先选择具体 SKU,再继续拉取白底图。"
|
||||
: "白底图会先按权重自动选入槽位,如需微调仍可手动调整。"}
|
||||
: totalImageCount > 0
|
||||
? "白底图会先按权重自动选入槽位,如需微调仍可手动调整。"
|
||||
: "上传后的抠图结果会直接并入当前候选素材,并自动带入后续生图槽位。"}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@ -7,6 +7,7 @@ import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanel";
|
||||
import type {
|
||||
AgentWorkflowMaterialImage,
|
||||
AgentWorkflowMaterialSearchResult,
|
||||
AgentWorkflowMaterialQueryType,
|
||||
AgentWorkflowMaterialSelections,
|
||||
@ -25,6 +26,110 @@ interface AgentWorkflowMaterialSelectionPanelProps {
|
||||
theme?: "light" | "dark";
|
||||
}
|
||||
|
||||
function getUploadedWorkflowMaterialImages(
|
||||
result?: AgentWorkflowMaterialSearchResult | null
|
||||
): AgentWorkflowMaterialImage[] {
|
||||
return Array.isArray(result?.images)
|
||||
? result.images
|
||||
.filter((item) => item.sourceType === "upload")
|
||||
.slice()
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
: [];
|
||||
}
|
||||
|
||||
function markSearchWorkflowMaterialImages(
|
||||
images: AgentWorkflowMaterialImage[]
|
||||
): AgentWorkflowMaterialImage[] {
|
||||
return images.map((image) => ({
|
||||
...image,
|
||||
sourceType: image.sourceType === "upload" ? "upload" : "search",
|
||||
}));
|
||||
}
|
||||
|
||||
function buildWorkflowMaterialResult(params: {
|
||||
currentResult?: AgentWorkflowMaterialSearchResult | null;
|
||||
images: AgentWorkflowMaterialImage[];
|
||||
fallbackQuery: string;
|
||||
fallbackQueryType: AgentWorkflowMaterialQueryType;
|
||||
}): AgentWorkflowMaterialSearchResult {
|
||||
const normalizedImages: AgentWorkflowMaterialImage[] = params.images.map((image, index) => ({
|
||||
...image,
|
||||
sourceType: image.sourceType === "upload" ? "upload" : "search",
|
||||
sortOrder: index,
|
||||
}));
|
||||
const validIds = new Set(normalizedImages.map((image) => image.id));
|
||||
const normalizedQuery = params.fallbackQuery.trim().toUpperCase();
|
||||
const currentResult = params.currentResult;
|
||||
const queryType = currentResult?.queryType || params.fallbackQueryType;
|
||||
const hasUploadedImages = normalizedImages.some((image) => image.sourceType === "upload");
|
||||
const fallbackMatchedSku =
|
||||
queryType === "sku"
|
||||
? currentResult?.matchedSku || normalizedQuery || "手动上传素材"
|
||||
: currentResult?.matchedSku || (hasUploadedImages ? "手动上传素材" : "");
|
||||
const fallbackMatchedSpu =
|
||||
currentResult?.matchedSpu ||
|
||||
(queryType === "spu" ? normalizedQuery || (hasUploadedImages ? "手动上传素材" : "") : null);
|
||||
|
||||
return {
|
||||
query: currentResult?.query || normalizedQuery || "手动上传素材",
|
||||
queryType,
|
||||
matchedSku: currentResult?.matchedSku || fallbackMatchedSku,
|
||||
matchedSpu: fallbackMatchedSpu,
|
||||
materialId: currentResult?.materialId || null,
|
||||
productModel:
|
||||
currentResult?.productModel ||
|
||||
fallbackMatchedSpu ||
|
||||
currentResult?.matchedSku ||
|
||||
fallbackMatchedSku ||
|
||||
null,
|
||||
zipUrl: currentResult?.zipUrl || null,
|
||||
fixedCount:
|
||||
normalizedImages.length > 0
|
||||
? normalizedImages.filter((image) => image.group === "fixed").length
|
||||
: currentResult?.fixedCount || 0,
|
||||
otherCount:
|
||||
normalizedImages.length > 0
|
||||
? normalizedImages.filter((image) => image.group === "other").length
|
||||
: currentResult?.otherCount || 0,
|
||||
totalCount:
|
||||
normalizedImages.length > 0
|
||||
? normalizedImages.length
|
||||
: currentResult?.totalCount || 0,
|
||||
images: normalizedImages,
|
||||
recommendedImageIds: Array.isArray(currentResult?.recommendedImageIds)
|
||||
? currentResult!.recommendedImageIds.filter((id) => validIds.has(id))
|
||||
: [],
|
||||
skuOptions: currentResult?.skuOptions || [],
|
||||
goodsInfo: currentResult?.goodsInfo || null,
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeWorkflowMaterialResult(
|
||||
result: AgentWorkflowMaterialSearchResult | null
|
||||
): AgentWorkflowMaterialSearchResult | null {
|
||||
if (!result) return null;
|
||||
const hasImages = Array.isArray(result.images) && result.images.length > 0;
|
||||
const hasSkuOptions = Array.isArray(result.skuOptions) && result.skuOptions.length > 0;
|
||||
return hasImages || hasSkuOptions ? result : null;
|
||||
}
|
||||
|
||||
function mergeSearchResultWithUploadedImages(params: {
|
||||
searchResult: AgentWorkflowMaterialSearchResult;
|
||||
currentResult?: AgentWorkflowMaterialSearchResult | null;
|
||||
}): AgentWorkflowMaterialSearchResult {
|
||||
const uploadedImages = getUploadedWorkflowMaterialImages(params.currentResult);
|
||||
const searchImages = markSearchWorkflowMaterialImages(params.searchResult.images || []);
|
||||
return buildWorkflowMaterialResult({
|
||||
currentResult: {
|
||||
...params.searchResult,
|
||||
images: [...uploadedImages, ...searchImages],
|
||||
},
|
||||
images: [...uploadedImages, ...searchImages],
|
||||
fallbackQuery: params.searchResult.query,
|
||||
fallbackQueryType: params.searchResult.queryType === "spu" ? "spu" : "sku",
|
||||
});
|
||||
}
|
||||
|
||||
export function AgentWorkflowMaterialSelectionPanel({
|
||||
inputs,
|
||||
onInputsChange,
|
||||
@ -69,6 +174,7 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
imageUrl: "",
|
||||
assetId: null,
|
||||
mimeType: null,
|
||||
sourceType: "search",
|
||||
group: "fixed",
|
||||
sortOrder: index,
|
||||
})),
|
||||
@ -82,6 +188,13 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
primary: { isLoading: false, errorText: null },
|
||||
secondary: { isLoading: false, errorText: null },
|
||||
});
|
||||
const [materialUploadState, setMaterialUploadState] = useState<{
|
||||
primary: { isUploadingNew: boolean; uploadingImageId: string | null };
|
||||
secondary: { isUploadingNew: boolean; uploadingImageId: string | null };
|
||||
}>({
|
||||
primary: { isUploadingNew: false, uploadingImageId: null },
|
||||
secondary: { isUploadingNew: false, uploadingImageId: null },
|
||||
});
|
||||
const [materialSearchMode, setMaterialSearchMode] = useState<{
|
||||
primary: AgentWorkflowMaterialQueryType;
|
||||
secondary: AgentWorkflowMaterialQueryType;
|
||||
@ -122,6 +235,29 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
}));
|
||||
};
|
||||
|
||||
const updateMaterialUploadState = (
|
||||
source: "primary" | "secondary",
|
||||
updates: Partial<{ isUploadingNew: boolean; uploadingImageId: string | null }>
|
||||
) => {
|
||||
setMaterialUploadState((prev) => ({
|
||||
...prev,
|
||||
[source]: {
|
||||
...prev[source],
|
||||
...updates,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const getFallbackMaterialQuery = (source: "primary" | "secondary") => {
|
||||
const currentResult = materials[source].result;
|
||||
const rawInput = String(source === "primary" ? inputs.primarySku : inputs.secondarySku).trim();
|
||||
return (currentResult?.query || rawInput || "手动上传素材").trim();
|
||||
};
|
||||
|
||||
const getFallbackMaterialQueryType = (source: "primary" | "secondary") => {
|
||||
return materials[source].result?.queryType || materialSearchMode[source];
|
||||
};
|
||||
|
||||
const handleMaterialInputChange = (source: "primary" | "secondary", value: string) => {
|
||||
const inputKey = source === "primary" ? "primarySku" : "secondarySku";
|
||||
const trimmedValue = value.trim().toUpperCase();
|
||||
@ -189,13 +325,26 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
throw new Error(result?.error?.message || result?.error || "搜索失败");
|
||||
}
|
||||
|
||||
const mergedResult = mergeSearchResultWithUploadedImages({
|
||||
searchResult: result.data,
|
||||
currentResult: materials[source].result,
|
||||
});
|
||||
const preservedUploadedIds = getUploadedWorkflowMaterialImages(materials[source].result)
|
||||
.map((image) => image.id)
|
||||
.filter((id) => materials[source].selectedImageIds.includes(id));
|
||||
|
||||
onMaterialsChange({
|
||||
...materials,
|
||||
[source]: {
|
||||
result: result.data,
|
||||
selectedImageIds: Array.isArray(result.data?.recommendedImageIds)
|
||||
? result.data.recommendedImageIds
|
||||
: [],
|
||||
result: mergedResult,
|
||||
selectedImageIds: Array.from(
|
||||
new Set([
|
||||
...(Array.isArray(result.data?.recommendedImageIds)
|
||||
? result.data.recommendedImageIds
|
||||
: []),
|
||||
...preservedUploadedIds,
|
||||
])
|
||||
).filter((id) => mergedResult.images.some((image) => image.id === id)),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@ -206,6 +355,130 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
updateMaterialSearchState(source, { isLoading: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadMaterialImage = async (
|
||||
source: "primary" | "secondary",
|
||||
file: File,
|
||||
options?: {
|
||||
replaceImageId?: string;
|
||||
}
|
||||
) => {
|
||||
const currentResult = materials[source].result;
|
||||
const query = getFallbackMaterialQuery(source);
|
||||
const queryType = getFallbackMaterialQueryType(source);
|
||||
const matchedSku =
|
||||
currentResult?.matchedSku ||
|
||||
(queryType === "sku"
|
||||
? String(source === "primary" ? inputs.primarySku : inputs.secondarySku).trim().toUpperCase()
|
||||
: "");
|
||||
const matchedSpu =
|
||||
currentResult?.matchedSpu ||
|
||||
(queryType === "spu"
|
||||
? String(source === "primary" ? inputs.primarySku : inputs.secondarySku).trim().toUpperCase()
|
||||
: "");
|
||||
|
||||
updateMaterialUploadState(source, {
|
||||
isUploadingNew: !options?.replaceImageId,
|
||||
uploadingImageId: options?.replaceImageId || null,
|
||||
});
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
formData.set("source", source);
|
||||
formData.set("query", query);
|
||||
formData.set("queryType", queryType);
|
||||
if (matchedSku) {
|
||||
formData.set("matchedSku", matchedSku);
|
||||
}
|
||||
if (matchedSpu) {
|
||||
formData.set("matchedSpu", matchedSpu);
|
||||
}
|
||||
if (currentResult?.productModel) {
|
||||
formData.set("productModel", currentResult.productModel);
|
||||
}
|
||||
|
||||
const response = await fetch("/api/agent-workflow/material-upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || !payload?.success || !payload?.data?.imageUrl) {
|
||||
throw new Error(payload?.error || payload?.detail || "图片上传失败");
|
||||
}
|
||||
|
||||
const uploadedImage = payload.data as AgentWorkflowMaterialImage;
|
||||
const currentUploads = getUploadedWorkflowMaterialImages(currentResult).filter(
|
||||
(image) => image.id !== options?.replaceImageId
|
||||
);
|
||||
const searchImages = markSearchWorkflowMaterialImages(
|
||||
(currentResult?.images || []).filter((image) => image.sourceType !== "upload")
|
||||
);
|
||||
const nextResult = buildWorkflowMaterialResult({
|
||||
currentResult,
|
||||
images: [...currentUploads, { ...uploadedImage, sourceType: "upload" }, ...searchImages],
|
||||
fallbackQuery: query,
|
||||
fallbackQueryType: queryType,
|
||||
});
|
||||
const nextSelectedIds = Array.from(
|
||||
new Set([
|
||||
...materials[source].selectedImageIds.filter(
|
||||
(id) => id !== options?.replaceImageId && nextResult.images.some((image) => image.id === id)
|
||||
),
|
||||
uploadedImage.id,
|
||||
])
|
||||
);
|
||||
|
||||
onMaterialsChange({
|
||||
...materials,
|
||||
[source]: {
|
||||
result: nextResult,
|
||||
selectedImageIds: nextSelectedIds,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
updateMaterialUploadState(source, {
|
||||
isUploadingNew: false,
|
||||
uploadingImageId: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveUploadedMaterialImage = (
|
||||
source: "primary" | "secondary",
|
||||
imageId: string
|
||||
) => {
|
||||
const currentResult = materials[source].result;
|
||||
if (!currentResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUploads = getUploadedWorkflowMaterialImages(currentResult).filter(
|
||||
(image) => image.id !== imageId
|
||||
);
|
||||
const searchImages = markSearchWorkflowMaterialImages(
|
||||
(currentResult.images || []).filter((image) => image.sourceType !== "upload")
|
||||
);
|
||||
const nextResult = finalizeWorkflowMaterialResult(
|
||||
buildWorkflowMaterialResult({
|
||||
currentResult,
|
||||
images: [...nextUploads, ...searchImages],
|
||||
fallbackQuery: getFallbackMaterialQuery(source),
|
||||
fallbackQueryType: getFallbackMaterialQueryType(source),
|
||||
})
|
||||
);
|
||||
|
||||
onMaterialsChange({
|
||||
...materials,
|
||||
[source]: {
|
||||
result: nextResult,
|
||||
selectedImageIds: materials[source].selectedImageIds.filter(
|
||||
(id) => id !== imageId && (nextResult?.images || []).some((image) => image.id === id)
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
@ -273,11 +546,18 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
onSearch={() => {
|
||||
void handleSearchMaterials("primary");
|
||||
}}
|
||||
onUploadImage={(file, options) => handleUploadMaterialImage("primary", file, options)}
|
||||
onRemoveUploadedImage={(imageId) =>
|
||||
handleRemoveUploadedMaterialImage("primary", imageId)
|
||||
}
|
||||
isUploadingNewImage={materialUploadState.primary.isUploadingNew}
|
||||
uploadingImageId={materialUploadState.primary.uploadingImageId}
|
||||
onSkuOptionSelect={(skuName) => {
|
||||
const selectedOption = materials.primary.result?.skuOptions?.find(
|
||||
(option) => option.skuName === skuName
|
||||
);
|
||||
const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
|
||||
const uploadedImages = getUploadedWorkflowMaterialImages(materials.primary.result);
|
||||
onInputsChange({
|
||||
...inputs,
|
||||
primarySku: skuName,
|
||||
@ -285,12 +565,42 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
onMaterialsChange({
|
||||
...materials,
|
||||
primary: {
|
||||
result: buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.primary.result,
|
||||
placeholderCount,
|
||||
result: buildWorkflowMaterialResult({
|
||||
currentResult: {
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.primary.result,
|
||||
placeholderCount,
|
||||
}),
|
||||
images: [
|
||||
...uploadedImages,
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.primary.result,
|
||||
placeholderCount,
|
||||
}).images.map((image) => ({
|
||||
...image,
|
||||
sourceType: "search" as const,
|
||||
})),
|
||||
],
|
||||
},
|
||||
images: [
|
||||
...uploadedImages,
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.primary.result,
|
||||
placeholderCount,
|
||||
}).images.map((image) => ({
|
||||
...image,
|
||||
sourceType: "search" as const,
|
||||
})),
|
||||
],
|
||||
fallbackQuery: skuName,
|
||||
fallbackQueryType: "sku",
|
||||
}),
|
||||
selectedImageIds: [],
|
||||
selectedImageIds: materials.primary.selectedImageIds.filter((id) =>
|
||||
uploadedImages.some((image) => image.id === id)
|
||||
),
|
||||
},
|
||||
});
|
||||
setMaterialSearchMode((prev) => ({
|
||||
@ -347,11 +657,18 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
onSearch={() => {
|
||||
void handleSearchMaterials("secondary");
|
||||
}}
|
||||
onUploadImage={(file, options) => handleUploadMaterialImage("secondary", file, options)}
|
||||
onRemoveUploadedImage={(imageId) =>
|
||||
handleRemoveUploadedMaterialImage("secondary", imageId)
|
||||
}
|
||||
isUploadingNewImage={materialUploadState.secondary.isUploadingNew}
|
||||
uploadingImageId={materialUploadState.secondary.uploadingImageId}
|
||||
onSkuOptionSelect={(skuName) => {
|
||||
const selectedOption = materials.secondary.result?.skuOptions?.find(
|
||||
(option) => option.skuName === skuName
|
||||
);
|
||||
const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
|
||||
const uploadedImages = getUploadedWorkflowMaterialImages(materials.secondary.result);
|
||||
onInputsChange({
|
||||
...inputs,
|
||||
secondarySku: skuName,
|
||||
@ -359,12 +676,42 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
onMaterialsChange({
|
||||
...materials,
|
||||
secondary: {
|
||||
result: buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.secondary.result,
|
||||
placeholderCount,
|
||||
result: buildWorkflowMaterialResult({
|
||||
currentResult: {
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.secondary.result,
|
||||
placeholderCount,
|
||||
}),
|
||||
images: [
|
||||
...uploadedImages,
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.secondary.result,
|
||||
placeholderCount,
|
||||
}).images.map((image) => ({
|
||||
...image,
|
||||
sourceType: "search" as const,
|
||||
})),
|
||||
],
|
||||
},
|
||||
images: [
|
||||
...uploadedImages,
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.secondary.result,
|
||||
placeholderCount,
|
||||
}).images.map((image) => ({
|
||||
...image,
|
||||
sourceType: "search" as const,
|
||||
})),
|
||||
],
|
||||
fallbackQuery: skuName,
|
||||
fallbackQueryType: "sku",
|
||||
}),
|
||||
selectedImageIds: [],
|
||||
selectedImageIds: materials.secondary.selectedImageIds.filter((id) =>
|
||||
uploadedImages.some((image) => image.id === id)
|
||||
),
|
||||
},
|
||||
});
|
||||
setMaterialSearchMode((prev) => ({
|
||||
|
||||
@ -42,6 +42,7 @@ function normalizeMaterialImage(
|
||||
imageUrl,
|
||||
assetId: sanitizeText(raw.assetId, 128) || null,
|
||||
mimeType: sanitizeText(raw.mimeType, 128) || null,
|
||||
sourceType: raw.sourceType === "upload" ? "upload" : "search",
|
||||
group,
|
||||
sortOrder: Number.isFinite(Number(raw.sortOrder))
|
||||
? Math.max(0, Math.min(999, Math.round(Number(raw.sortOrder))))
|
||||
|
||||
@ -175,6 +175,7 @@ export interface AgentWorkflowMaterialImage {
|
||||
imageUrl: string;
|
||||
assetId?: string | null;
|
||||
mimeType?: string | null;
|
||||
sourceType?: "search" | "upload";
|
||||
group: "fixed" | "other";
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user