"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { Plus, Sparkles } from "lucide-react"; import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker"; import { ClientErrorBoundary } from "@/components/ui/client-error-boundary"; import { Switch } from "@/components/ui/switch"; import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config"; import { cn } from "@/lib/utils"; import type { AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanel"; import type { AgentWorkflowMaterialImage, AgentWorkflowMaterialSearchResult, AgentWorkflowMaterialQueryType, AgentWorkflowMaterialSelections, } from "@/types/agent"; interface AgentWorkflowMaterialSelectionPanelProps { inputs: AgentWorkflowInputs; onInputsChange: (next: AgentWorkflowInputs) => void; materials: AgentWorkflowMaterialSelections; onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void; appearanceRecognitionEnabled?: boolean; onAppearanceRecognitionEnabledChange?: (next: boolean) => void; productSelectionLimit?: number | null; totalUploadRequirement?: number | null; modelImageRequirement?: number | null; title?: string; description?: string; disabled?: boolean; disabledHint?: string; theme?: "light" | "dark"; } 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 normalizeMaterialSearchResult( result?: AgentWorkflowMaterialSearchResult | null ): AgentWorkflowMaterialSearchResult | null { if (!result || typeof result !== "object" || Array.isArray(result)) { return null; } return { ...result, images: normalizeMaterialImages(result.images), recommendedImageIds: normalizeImageIdList(result.recommendedImageIds), skuOptions: Array.isArray(result.skuOptions) ? result.skuOptions.filter((item) => Boolean(String(item?.skuName || "").trim())) : [], }; } function normalizeMaterialSelections( value: AgentWorkflowMaterialSelections ): AgentWorkflowMaterialSelections { return { primary: { result: normalizeMaterialSearchResult(value?.primary?.result), selectedImageIds: normalizeImageIdList(value?.primary?.selectedImageIds), }, secondary: { result: normalizeMaterialSearchResult(value?.secondary?.result), selectedImageIds: normalizeImageIdList(value?.secondary?.selectedImageIds), }, }; } 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 getSelectedWorkflowMaterialImages(params: { result?: AgentWorkflowMaterialSearchResult | null; selectedImageIds: string[]; }): AgentWorkflowMaterialImage[] { const selectedIds = new Set( normalizeImageIdList(params.selectedImageIds) ); if (!params.result || selectedIds.size === 0) { return []; } return normalizeMaterialImages(params.result.images).filter((image) => selectedIds.has(image.id) ); } function syncWorkflowMaterialAppearanceSummary(params: { result?: AgentWorkflowMaterialSearchResult | null; selectedImageIds: string[]; appearanceSummary?: string | null; imageId?: string | null; }): AgentWorkflowMaterialSearchResult | null | undefined { const result = params.result; if (!result) { return result; } const selectedImages = getSelectedWorkflowMaterialImages({ result, selectedImageIds: params.selectedImageIds, }); const targetImage = (params.imageId ? selectedImages.find((image) => image.id === params.imageId) : selectedImages[0]) || null; if (!targetImage) { return result; } const nextSummary = String( params.appearanceSummary ?? targetImage.appearanceSummary ?? "" ).trim(); const currentGoodsSummary = String(result.goodsInfo?.appearanceSummary || "").trim(); if (!nextSummary) { if (!currentGoodsSummary) { return result; } return { ...result, goodsInfo: result.goodsInfo ? { ...result.goodsInfo, appearanceSummary: null, } : null, }; } let changed = false; const nextImages = normalizeMaterialImages(result.images).map((image) => { if (image.id !== targetImage.id) { return image; } if (String(image.appearanceSummary || "").trim() === nextSummary) { return image; } changed = true; return { ...image, appearanceSummary: nextSummary, }; }); const nextGoodsInfo = currentGoodsSummary === nextSummary && result.goodsInfo ? result.goodsInfo : { ...(result.goodsInfo || {}), appearanceSummary: nextSummary, }; if (currentGoodsSummary !== nextSummary) { changed = true; } if (!changed) { return result; } return { ...result, images: nextImages, goodsInfo: nextGoodsInfo, }; } 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( normalizeMaterialImages(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", }); } function MaterialPickerErrorFallback({ isDark, title, }: { isDark: boolean; title: string; }) { return (
{title}暂时无法渲染
已拦截本次客户端异常,避免整个风格化工作台崩溃。请修改搜索词或刷新后重试。
); } export function AgentWorkflowMaterialSelectionPanel({ inputs, onInputsChange, materials: rawMaterials, onMaterialsChange, appearanceRecognitionEnabled = false, onAppearanceRecognitionEnabledChange, productSelectionLimit = null, totalUploadRequirement = null, modelImageRequirement = 0, title, description, disabled = false, disabledHint = "请先在下方选择一个生图模式,再启用 SKU / SPU 搜图。", theme = "light", }: AgentWorkflowMaterialSelectionPanelProps) { const materials = useMemo( () => normalizeMaterialSelections(rawMaterials), [rawMaterials] ); const hasSecondaryMaterialContent = Boolean(String(inputs.secondarySku || "").trim()) || Boolean(materials.secondary.result) || materials.secondary.selectedImageIds.length > 0; const buildLoadingMaterialResult = (params: { keyword: string; currentResult?: AgentWorkflowMaterialSearchResult | null; placeholderCount: number; }): AgentWorkflowMaterialSearchResult => { const count = Math.max(4, Math.min(params.placeholderCount || 8, 16)); return { query: params.keyword, queryType: "sku", matchedSku: params.keyword, matchedSpu: params.currentResult?.matchedSpu || null, materialId: params.currentResult?.materialId || null, productModel: params.currentResult?.productModel || null, zipUrl: null, fixedCount: count, otherCount: 0, totalCount: count, skuOptions: [], recommendedImageIds: [], images: Array.from({ length: count }, (_, index) => ({ id: `loading-${params.keyword}-${index + 1}`, key: `loading-${params.keyword}-${index + 1}`, label: `加载中 ${index + 1}`, fileName: `正在加载白底图 ${index + 1}`, imageUrl: "", assetId: null, mimeType: null, sourceType: "search", group: "fixed", sortOrder: index, })), }; }; const [materialSearchState, setMaterialSearchState] = useState<{ primary: { isLoading: boolean; errorText: string | null }; secondary: { isLoading: boolean; errorText: string | null }; }>({ 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; }>({ primary: "sku", secondary: "sku", }); const [isSecondaryMaterialVisible, setIsSecondaryMaterialVisible] = useState( hasSecondaryMaterialContent ); const latestMaterialsRef = useRef(materials); const appearanceRequestRef = useRef>({ primary: "", secondary: "", }); const normalizedRawProductSelectionLimit = typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0 ? Math.max(1, Math.round(productSelectionLimit)) : null; const normalizedTotalUploadRequirement = typeof totalUploadRequirement === "number" && Number.isFinite(totalUploadRequirement) && totalUploadRequirement > 0 ? Math.max(1, Math.round(totalUploadRequirement)) : null; const normalizedModelImageRequirement = typeof modelImageRequirement === "number" && Number.isFinite(modelImageRequirement) && modelImageRequirement > 0 ? Math.max(0, Math.round(modelImageRequirement)) : 0; const normalizedDerivedProductSelectionLimit = normalizedTotalUploadRequirement !== null ? Math.max(normalizedTotalUploadRequirement - normalizedModelImageRequirement, 0) : null; const normalizedProductSelectionLimit = normalizedRawProductSelectionLimit !== null ? normalizedDerivedProductSelectionLimit !== null && normalizedDerivedProductSelectionLimit > 0 && normalizedDerivedProductSelectionLimit < normalizedRawProductSelectionLimit ? normalizedDerivedProductSelectionLimit : normalizedRawProductSelectionLimit : normalizedDerivedProductSelectionLimit; const hasAdjustedProductSelectionLimit = normalizedRawProductSelectionLimit !== null && normalizedProductSelectionLimit !== null && normalizedProductSelectionLimit !== normalizedRawProductSelectionLimit; const primarySelectedCount = materials.primary.selectedImageIds.length; const secondarySelectedCount = materials.secondary.selectedImageIds.length; const primarySelectionLimit = typeof normalizedProductSelectionLimit === "number" && Number.isFinite(normalizedProductSelectionLimit) && normalizedProductSelectionLimit > 0 ? Math.max(normalizedProductSelectionLimit - secondarySelectedCount, primarySelectedCount, 0) : null; const secondarySelectionLimit = typeof normalizedProductSelectionLimit === "number" && Number.isFinite(normalizedProductSelectionLimit) && normalizedProductSelectionLimit > 0 ? Math.max(normalizedProductSelectionLimit - primarySelectedCount, secondarySelectedCount, 0) : null; useEffect(() => { if (hasSecondaryMaterialContent) { setIsSecondaryMaterialVisible(true); } }, [hasSecondaryMaterialContent]); useEffect(() => { latestMaterialsRef.current = materials; }, [materials]); const updateMaterialSearchState = ( source: "primary" | "secondary", updates: Partial<{ isLoading: boolean; errorText: string | null }> ) => { setMaterialSearchState((prev) => ({ ...prev, [source]: { ...prev[source], ...updates, }, })); }; 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 ensureSelectedImageAppearanceSummary = async ( source: "primary" | "secondary" ) => { if (!appearanceRecognitionEnabled) { appearanceRequestRef.current[source] = ""; return; } const currentSourceMaterials = latestMaterialsRef.current[source]; const currentResult = currentSourceMaterials.result; const selectedImages = getSelectedWorkflowMaterialImages({ result: currentResult, selectedImageIds: currentSourceMaterials.selectedImageIds, }); const firstSelectedImage = selectedImages[0]; if (!currentResult || !firstSelectedImage) { appearanceRequestRef.current[source] = ""; return; } const syncedResult = syncWorkflowMaterialAppearanceSummary({ result: currentResult, selectedImageIds: currentSourceMaterials.selectedImageIds, }); if (syncedResult !== currentResult) { const nextMaterials = { ...latestMaterialsRef.current, [source]: { ...currentSourceMaterials, result: syncedResult, }, }; latestMaterialsRef.current = nextMaterials; onMaterialsChange(nextMaterials); } if (!String(firstSelectedImage.imageUrl || "").trim()) { appearanceRequestRef.current[source] = ""; return; } if (String(firstSelectedImage.appearanceSummary || "").trim()) { appearanceRequestRef.current[source] = ""; return; } const requestKey = `${firstSelectedImage.id}:${firstSelectedImage.imageUrl}`; if (appearanceRequestRef.current[source] === requestKey) { return; } appearanceRequestRef.current[source] = requestKey; try { const response = await fetch("/api/agent-workflow/material-appearance", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ imageUrl: firstSelectedImage.imageUrl, }), }); const payload = await response.json().catch(() => ({})); const appearanceSummary = String(payload?.data?.appearanceSummary || "").trim(); if (!response.ok || !payload?.success || !appearanceSummary) { return; } const latestSourceMaterials = latestMaterialsRef.current[source]; const latestSelectedImages = getSelectedWorkflowMaterialImages({ result: latestSourceMaterials.result, selectedImageIds: latestSourceMaterials.selectedImageIds, }); const latestFirstSelectedImage = latestSelectedImages[0]; if (!latestFirstSelectedImage || latestFirstSelectedImage.id !== firstSelectedImage.id) { return; } const nextResult = syncWorkflowMaterialAppearanceSummary({ result: latestSourceMaterials.result, selectedImageIds: latestSourceMaterials.selectedImageIds, appearanceSummary, imageId: latestFirstSelectedImage.id, }); if (nextResult === latestSourceMaterials.result) { return; } const nextMaterials = { ...latestMaterialsRef.current, [source]: { ...latestSourceMaterials, result: nextResult, }, }; latestMaterialsRef.current = nextMaterials; onMaterialsChange(nextMaterials); } finally { if (appearanceRequestRef.current[source] === requestKey) { appearanceRequestRef.current[source] = ""; } } }; const primaryAppearanceSyncKey = [ materials.primary.selectedImageIds.join("|"), (materials.primary.result?.images || []) .map((image) => `${image.id}:${String(image.appearanceSummary || "").trim()}`) .join("|"), String(materials.primary.result?.goodsInfo?.appearanceSummary || "").trim(), ].join("::"); const secondaryAppearanceSyncKey = [ materials.secondary.selectedImageIds.join("|"), (materials.secondary.result?.images || []) .map((image) => `${image.id}:${String(image.appearanceSummary || "").trim()}`) .join("|"), String(materials.secondary.result?.goodsInfo?.appearanceSummary || "").trim(), ].join("::"); useEffect(() => { void ensureSelectedImageAppearanceSummary("primary"); }, [appearanceRecognitionEnabled, primaryAppearanceSyncKey]); useEffect(() => { void ensureSelectedImageAppearanceSummary("secondary"); }, [appearanceRecognitionEnabled, secondaryAppearanceSyncKey]); const handleMaterialInputChange = (source: "primary" | "secondary", value: string) => { const inputKey = source === "primary" ? "primarySku" : "secondarySku"; const trimmedValue = value.trim().toUpperCase(); const currentQuery = String(materials[source]?.result?.query || "").trim().toUpperCase(); onInputsChange({ ...inputs, [inputKey]: value, }); if ( trimmedValue !== currentQuery && (materials[source]?.result || materials[source]?.selectedImageIds.length) ) { onMaterialsChange({ ...materials, [source]: createEmptyAgentWorkflowMaterialSelection(), }); } if (materialSearchState[source].errorText) { updateMaterialSearchState(source, { errorText: null }); } }; const handleSearchMaterials = async ( source: "primary" | "secondary", options?: { keyword?: string; queryType?: AgentWorkflowMaterialQueryType; } ) => { const keyword = String(options?.keyword || (source === "primary" ? inputs.primarySku : inputs.secondarySku)).trim(); const queryType = options?.queryType || materialSearchMode[source]; if (!keyword) { updateMaterialSearchState(source, { errorText: "请先输入搜索关键词" }); return; } try { updateMaterialSearchState(source, { isLoading: true, errorText: null }); const searchParams = new URLSearchParams({ keyword, source, queryType, }); const currentSelectionLimit = source === "primary" ? primarySelectionLimit : secondarySelectionLimit; const requiredCount = typeof currentSelectionLimit === "number" && Number.isFinite(currentSelectionLimit) && currentSelectionLimit > 0 ? Math.round(currentSelectionLimit) : null; if (requiredCount) { searchParams.set("requiredCount", String(requiredCount)); } searchParams.set( "appearanceRecognitionEnabled", appearanceRecognitionEnabled ? "true" : "false" ); const response = await fetch( `/api/agent-workflow/material-search?${searchParams.toString()}`, { cache: "no-store" } ); const result = await response.json().catch(() => ({})); if (!response.ok || !result?.success || !result?.data) { 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: 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) { updateMaterialSearchState(source, { errorText: error instanceof Error ? error.message : "搜索失败", }); } finally { 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); } formData.set( "appearanceRecognitionEnabled", appearanceRecognitionEnabled ? "true" : "false" ); 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?.detail || payload?.error || "图片上传失败"); } 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"; const selectionLimitLabel = normalizedProductSelectionLimit !== null ? normalizedModelImageRequirement > 0 ? `当前步骤商品图上限 ${normalizedProductSelectionLimit} 张(模特图 ${normalizedModelImageRequirement} 张另算)` : `当前步骤商品图上限 ${normalizedProductSelectionLimit} 张` : undefined; return (
{title || description ? (
{title ? (
{title}
) : null} {description ? (
{description}
) : null}
) : null} {!disabled && (normalizedTotalUploadRequirement !== null || normalizedProductSelectionLimit !== null || normalizedModelImageRequirement > 0) ? (
{[ normalizedTotalUploadRequirement !== null ? `当前模式共需 ${normalizedTotalUploadRequirement} 张素材` : "", normalizedProductSelectionLimit !== null ? `其中本步骤商品图最多 ${normalizedProductSelectionLimit} 张` : "", normalizedModelImageRequirement > 0 ? `模特图 ${normalizedModelImageRequirement} 张在后续单独带入,不占这里的商品图上传位` : "", hasAdjustedProductSelectionLimit ? "已按总素材数扣除模特图后的商品图数量限制上传槽位" : "", ] .filter(Boolean) .join(",")} 。
) : null}
产品外观识别
开启后会用 QWEN3-VL 识别当前选中商品图的外观结构和颜色,并拼进生图提示词;关闭后不会调用识别,也不会加入外观锁定。
{appearanceRecognitionEnabled ? "已开启" : "已关闭"} onAppearanceRecognitionEnabledChange?.(checked === true) } disabled={disabled} aria-label="切换产品外观识别" className={ isDark ? "data-[state=checked]:border-emerald-400/70 data-[state=checked]:bg-emerald-500" : undefined } />
} > handleMaterialInputChange("primary", value)} onQueryModeChange={(nextMode) => setMaterialSearchMode((prev) => ({ ...prev, primary: nextMode, })) } 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, }); onMaterialsChange({ ...materials, primary: { 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: materials.primary.selectedImageIds.filter((id) => uploadedImages.some((image) => image.id === id) ), }, }); setMaterialSearchMode((prev) => ({ ...prev, primary: "sku", })); void handleSearchMaterials("primary", { keyword: skuName, queryType: "sku", }); }} theme={theme} onSelectedImageIdsChange={(next) => onMaterialsChange({ ...materials, primary: { ...materials.primary, selectedImageIds: next, }, }) } /> {isSecondaryMaterialVisible ? ( } > handleMaterialInputChange("secondary", value)} onQueryModeChange={(nextMode) => setMaterialSearchMode((prev) => ({ ...prev, secondary: nextMode, })) } 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, }); onMaterialsChange({ ...materials, secondary: { 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: materials.secondary.selectedImageIds.filter((id) => uploadedImages.some((image) => image.id === id) ), }, }); setMaterialSearchMode((prev) => ({ ...prev, secondary: "sku", })); void handleSearchMaterials("secondary", { keyword: skuName, queryType: "sku", }); }} theme={theme} onSelectedImageIdsChange={(next) => onMaterialsChange({ ...materials, secondary: { ...materials.secondary, selectedImageIds: next, }, }) } /> ) : ( )}
); }