"use client"; import { useVirtualizer } from "@tanstack/react-virtual"; import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Check, ImageIcon, Loader2, RefreshCw, Search, Trash2, Upload } from "lucide-react"; import { toast } from "sonner"; import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { processInBrowserBatches } from "@/lib/client/yield-to-browser"; import { cn } from "@/lib/utils"; import type { AgentWorkflowMaterialImage, AgentWorkflowMaterialQueryType, AgentWorkflowMaterialSearchResult, } from "@/types/agent"; interface AgentWorkflowMaterialPickerProps { title?: string; description?: string; value: string; queryMode: AgentWorkflowMaterialQueryType; placeholder: string; buttonLabel: string; searchNote?: string; disabled?: boolean; disabledHint?: string; isLoading?: boolean; errorText?: string | null; result?: AgentWorkflowMaterialSearchResult | null; selectedImageIds: string[]; selectionLimit?: number | null; selectionLimitLabel?: string; onValueChange: (value: string) => void; onQueryModeChange: (value: AgentWorkflowMaterialQueryType) => void; onSearch: () => void; onSkuOptionSelect?: (skuName: string) => void; onSelectedImageIdsChange: (next: string[]) => void; onUploadImage?: ( file: File, options?: { replaceImageId?: string; } ) => Promise; onRemoveUploadedImage?: (imageId: string) => void; isUploadingNewImage?: boolean; uploadingImageId?: string | null; appearanceRecognitionEnabled?: boolean; theme?: "light" | "dark"; } const MATERIAL_GRID_VIRTUALIZE_THRESHOLD = 12; const MATERIAL_GRID_GAP = 12; const MATERIAL_GRID_MIN_CARD_HEIGHT = 258; const AGENT_STYLE_OPTIMIZED_RENDER_ENABLED = process.env.NEXT_PUBLIC_AGENT_STYLE_OPTIMIZED_RENDER !== "false"; function normalizeImageIdList(value: unknown): string[] { if (!Array.isArray(value)) { return []; } return value.map((item) => String(item || "").trim()).filter(Boolean); } function normalizeMaterialImages(value: unknown): AgentWorkflowMaterialImage[] { if (!Array.isArray(value)) { return []; } return value.filter((item): item is AgentWorkflowMaterialImage => { if (!item || typeof item !== "object" || Array.isArray(item)) { return false; } const image = item as Partial; return Boolean(String(image.id || "").trim()); }); } function normalizeSkuOptions( value: AgentWorkflowMaterialSearchResult["skuOptions"] | unknown ): NonNullable { if (!Array.isArray(value)) { return []; } return value.filter((item): item is NonNullable[number] => { if (!item || typeof item !== "object" || Array.isArray(item)) { return false; } return Boolean(String(item.skuName || "").trim()); }); } const MaterialImageCard = memo(function MaterialImageCard({ image, selected, isDark, onToggle, }: { image: AgentWorkflowMaterialImage; selected: boolean; isDark: boolean; onToggle: (imageId: string) => void; }) { const isPlaceholder = !String(image.imageUrl || "").trim(); const displayImageUrl = String(image.imageUrl || "").trim(); const [imageStatus, setImageStatus] = useState<"loading" | "loaded" | "error">( isPlaceholder ? "loaded" : "loading" ); useEffect(() => { setImageStatus(isPlaceholder ? "loaded" : "loading"); }, [displayImageUrl, isPlaceholder]); return ( ); }); function MaterialImageGrid({ images, selectedSet, isDark, onToggle, }: { images: AgentWorkflowMaterialImage[]; selectedSet: Set; isDark: boolean; onToggle: (imageId: string) => void; }) { const parentRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); useEffect(() => { const node = parentRef.current; if (!node || images.length < MATERIAL_GRID_VIRTUALIZE_THRESHOLD) return; const updateWidth = () => setContainerWidth(node.clientWidth || 0); updateWidth(); if (typeof ResizeObserver === "undefined") { window.addEventListener("resize", updateWidth, { passive: true }); return () => window.removeEventListener("resize", updateWidth); } const observer = new ResizeObserver(updateWidth); observer.observe(node); return () => observer.disconnect(); }, [images.length]); const columnCount = useMemo(() => { if (containerWidth >= 1280) return 4; if (containerWidth >= 900) return 3; return 2; }, [containerWidth]); const rowCount = Math.ceil(images.length / columnCount); const cardWidth = containerWidth > 0 ? Math.floor((containerWidth - MATERIAL_GRID_GAP * (columnCount - 1)) / columnCount) : 240; const rowHeight = Math.max(MATERIAL_GRID_MIN_CARD_HEIGHT, cardWidth + 74); // Keep hook order stable when SKU loading placeholders and final images cross the virtualization threshold. const rowVirtualizer = useVirtualizer({ count: rowCount, getScrollElement: () => parentRef.current, estimateSize: () => rowHeight + MATERIAL_GRID_GAP, overscan: 2, }); if ( !AGENT_STYLE_OPTIMIZED_RENDER_ENABLED || images.length < MATERIAL_GRID_VIRTUALIZE_THRESHOLD ) { return (
{images.map((image) => ( ))}
); } return (
{rowVirtualizer.getVirtualItems().map((virtualRow) => { const start = virtualRow.index * columnCount; const rowImages = images.slice(start, start + columnCount); return (
{rowImages.map((image) => ( ))}
); })}
); } export function AgentWorkflowMaterialPicker({ title, description, value, queryMode, placeholder, buttonLabel, searchNote, disabled = false, disabledHint, isLoading = false, errorText, result, selectedImageIds, selectionLimit = null, selectionLimitLabel = "当前生图模式", onValueChange, onQueryModeChange, onSearch, onSkuOptionSelect, onSelectedImageIdsChange, onUploadImage, onRemoveUploadedImage, isUploadingNewImage = false, uploadingImageId = null, appearanceRecognitionEnabled = true, theme = "light", }: AgentWorkflowMaterialPickerProps) { const isDark = theme === "dark"; const hasUploadInFlight = isUploadingNewImage || Boolean(uploadingImageId); const canUploadMaterialImage = Boolean(onUploadImage); const [previewDisplayMode, setPreviewDisplayMode] = useState<"selected" | "all">("all"); const [hasManualSelectionInteraction, setHasManualSelectionInteraction] = useState(false); const normalizedSelectedImageIds = useMemo( () => normalizeImageIdList(selectedImageIds), [selectedImageIds] ); const selectedSet = useMemo( () => new Set(normalizedSelectedImageIds), [normalizedSelectedImageIds] ); const recommendedImageIds = useMemo( () => Array.isArray(result?.recommendedImageIds) ? result.recommendedImageIds.map((item) => String(item || "").trim()).filter(Boolean) : [], [result?.recommendedImageIds] ); const allImages = useMemo( () => normalizeMaterialImages(result?.images), [result?.images] ); const skuOptions = useMemo( () => normalizeSkuOptions(result?.skuOptions), [result?.skuOptions] ); 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 selectedPrimaryImage = useMemo( () => allImages.find((item) => selectedSet.has(item.id)) || null, [allImages, selectedSet] ); const visibleAppearanceSummary = selectedPrimaryImage ? selectedPrimaryImage.appearanceSummary || null : allImages[0]?.appearanceSummary || result?.goodsInfo?.appearanceSummary || null; 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( allImages.some((item) => Boolean(String(item.imageUrl || "").trim())) ); const goodsInfoItems = [ { label: "商品名称", value: result?.goodsInfo?.productName || null, fullWidth: true, }, { label: "尺寸信息", value: result?.goodsInfo?.sizeInfo || null, }, { label: "材质信息", value: result?.goodsInfo?.materialInfo || null, }, { label: "设计师", value: result?.goodsInfo?.designer || null, }, { label: "采购负责人", value: result?.goodsInfo?.buyer || null, }, { label: "板材类型", value: result?.goodsInfo?.boardType || null, }, ...(appearanceRecognitionEnabled ? [ { label: "外观识别", value: visibleAppearanceSummary, fullWidth: true, }, ] : []), ].filter((item) => Boolean(item.value)); const remainingSelectableCount = resolvedSelectionLimit === null ? null : Math.max(resolvedSelectionLimit - selectedCount, 0); const remainingUploadSlotCount = !canUploadMaterialImage ? 0 : resolvedSelectionLimit === null ? 1 : Math.max( resolvedSelectionLimit - selectedGalleryCount - uploadedImages.length, 0 ); const totalImageCount = galleryImages.length; const hasSystemRecommendedSelection = result?.queryType === "sku" && recommendedImageIds.length > 0 && selectedGalleryCount === recommendedImageIds.length && recommendedImageIds.every((id) => selectedSet.has(id)); const isAutoSelectedResult = Boolean(hasSystemRecommendedSelection) && !hasManualSelectionInteraction; const canTogglePreviewMode = totalImageCount > 0; const visibleImages = useMemo(() => { if (!galleryImages.length) { return []; } if (previewDisplayMode === "selected" && selectedGalleryCount > 0) { return galleryImages.filter((image) => selectedSet.has(image.id)); } return galleryImages; }, [galleryImages, previewDisplayMode, selectedGalleryCount, selectedSet]); const previewResetKey = useMemo( () => JSON.stringify({ queryType: result?.queryType || "", matchedSku: result?.matchedSku || "", imageIds: galleryImages.map((image) => image.id), recommendedImageIds, }), [galleryImages, recommendedImageIds, result?.matchedSku, result?.queryType] ); useEffect(() => { setHasManualSelectionInteraction(false); }, [previewResetKey]); useEffect(() => { if (hasManualSelectionInteraction) { return; } setPreviewDisplayMode("all"); }, [hasManualSelectionInteraction, previewResetKey]); const hasFetchSuccess = !disabled && !isLoading && !errorText && Boolean(result); const handleToggleImage = useCallback((imageId: string) => { setHasManualSelectionInteraction(true); const next = new Set(normalizedSelectedImageIds); if (next.has(imageId)) { next.delete(imageId); } else { if (resolvedSelectionLimit !== null && next.size >= resolvedSelectionLimit) { toast.error(`${selectionLimitLabel}最多可选择 ${resolvedSelectionLimit} 张图片`); return; } next.add(imageId); } const nextIds = Array.from(next); startTransition(() => { onSelectedImageIdsChange(nextIds); }); }, [ onSelectedImageIdsChange, resolvedSelectionLimit, normalizedSelectedImageIds, selectionLimitLabel, ]); const handleSelectAll = useCallback(async () => { setHasManualSelectionInteraction(true); const allIds: string[] = []; await processInBrowserBatches( allImages, (item) => { if (String(item.imageUrl || "").trim()) { allIds.push(item.id); } }, { batchSize: 80 } ); const nextIds = resolvedSelectionLimit !== null ? allIds.slice(0, resolvedSelectionLimit) : allIds; startTransition(() => { onSelectedImageIdsChange(nextIds); }); if (resolvedSelectionLimit !== null && allIds.length > resolvedSelectionLimit) { toast.message(`${selectionLimitLabel}最多可选择 ${resolvedSelectionLimit} 张图片,已按上限选取`); } }, [allImages, onSelectedImageIdsChange, resolvedSelectionLimit, selectionLimitLabel]); 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 (
{hasImage ? ( ) : (
{isUploading ? ( ) : ( )}
{isUploading ? "正在自动抠图" : "上传商品图"}
{isUploading ? "系统会先抠图,再把透明底结果加入候选区。" : "支持 PNG / JPG / WEBP,上传后会自动抠图。"}
)}
{hasImage ? "用户上传" : "自动抠图上传"} {hasImage ? ( ) : null}
{hasImage ? image?.fileName || image?.label : isUploading ? "抠图处理中..." : "没有搜索到时也可以直接上传"}
); }; return (
{title || description ? (
{title ? (
{title}
) : null} {description ? (
{description}
) : null}
) : null}
{(["sku", "spu"] as const).map((mode) => ( ))}
onValueChange(event.target.value)} placeholder={placeholder} disabled={disabled || hasUploadInFlight} className={cn( hasFetchSuccess && "pr-11", isDark && "border-white/12 bg-white/[0.04] text-white placeholder:text-white/25 focus-visible:ring-emerald-400/30" )} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); if (disabled || hasUploadInFlight) return; onSearch(); } }} /> {hasFetchSuccess ? ( ) : null}
{disabled && disabledHint ? (
{disabledHint}
) : null} {!disabled && errorText ? (
{errorText}
) : null} {!disabled && canUploadMaterialImage ? (
直接上传商品图
搜索不到合适白底图时,可以直接上传原图。这里只统计商品图,模特图会在后续单独处理;系统会自动抠图后再加入当前候选素材。
{resolvedSelectionLimit !== null ? ( 本步骤需 {resolvedSelectionLimit} 张商品图 ) : null} 已上传 {uploadedImages.length} 张 {remainingUploadSlotCount > 0 ? ( {resolvedSelectionLimit === null ? "可继续上传" : `还可上传 ${remainingUploadSlotCount} 张`} ) : null}
{uploadedImages.length > 0 || remainingUploadSlotCount > 0 ? (
{uploadedImages.map((image) => { const isSelected = selectedSet.has(image.id); const isReplacing = uploadingImageId === image.id; return (
); })} {Array.from({ length: remainingUploadSlotCount }, (_, index) => ( ))}
) : (
当前上传槽位已满。若要改用用户上传图片,可先取消一张检索图,或删除上面已上传的图片后再补充。
)}
) : null} {!disabled && result ? (
命中:{result.matchedSku || result.query} {result.matchedSpu ? ( SPU:{result.matchedSpu} ) : null} {result.productModel ? ( 型号:{result.productModel} ) : null} 检索图 {totalImageCount} 张 {uploadedImages.length > 0 ? ( 上传图 {uploadedImages.length} 张 ) : null} 已选 {selectedCount} 张 {resolvedSelectionLimit !== null ? ( 最多 {resolvedSelectionLimit} 张 {remainingSelectableCount !== null ? ` · 还可选 ${remainingSelectableCount} 张` : ""} ) : null}
{canTogglePreviewMode ? (
) : null}
{goodsInfoItems.length > 0 ? (
{goodsInfoItems.map((item) => (
{item.label}
{item.value}
))}
) : null} {skuOptions.length ? (
{skuOptions.map((option) => (
{option.previewImageUrl ? ( ) : (
)}
{option.skuName}
{option.hasWhiteBackground ? `已有 ${option.whiteBackgroundCount} 张白底图` : "暂无白底图"}
))}
) : null} {totalImageCount > 0 ? (
{isAutoSelectedResult || canTogglePreviewMode ? (
{previewDisplayMode === "selected" ? `当前优先展示已选中的 ${selectedGalleryCount} 张检索图,便于你快速确认结果。` : `当前展示全部 ${totalImageCount} 张候选图片,可继续手动微调选图。`}
{canTogglePreviewMode ? (
在上方切换“已选 / 全部”即可,无需再单独收起预览。
) : null}
) : null} {visibleImages.length > 0 ? ( ) : (
当前没有可展示的已选图片
切换到“全部 {totalImageCount}”可查看全部候选图片并重新调整。
)}
) : null}
) : (
{disabled ? ( <> 先选择风格化模板,再搜索并选择素材图。 ) : isLoading ? ( <> 正在搜索并导入图片,请稍候... ) : ( <> {value.trim() ? "输入后点击搜索,从素材库拉取白底图。" : "先输入关键词,再搜索素材图。"} )}
)} {(allImages.length || skuOptions.length) ? (
{skuOptions.length ? "当前是 SPU 命中结果,请先选择具体 SKU,再继续拉取白底图。" : totalImageCount > 0 ? "白底图会先按权重自动选入槽位,如需微调仍可手动调整。" : "上传后的抠图结果会直接并入当前候选素材,并自动带入后续生图槽位。"}
) : null}
); }