feat: add workflow material upload cutout flow
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-04-01 23:36:05 +08:00
parent 117621b8cb
commit 3e935fae37
5 changed files with 834 additions and 50 deletions

View 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 }
);
}
}

View File

@ -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>

View File

@ -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) => ({

View File

@ -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))))

View File

@ -175,6 +175,7 @@ export interface AgentWorkflowMaterialImage {
imageUrl: string;
assetId?: string | null;
mimeType?: string | null;
sourceType?: "search" | "upload";
group: "fixed" | "other";
sortOrder: number;
}