From d0bb8b7cf7277b14431af825785bc6f571ca5be2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 13 May 2026 18:37:03 +0800 Subject: [PATCH] feat: support batch product id reference fill --- .../agent-workflow/reference-search/route.ts | 27 +- components/scene-fusion-view.tsx | 272 ++++++++++++++++++ 2 files changed, 293 insertions(+), 6 deletions(-) diff --git a/app/api/agent-workflow/reference-search/route.ts b/app/api/agent-workflow/reference-search/route.ts index 022de35d8..6e16893cc 100644 --- a/app/api/agent-workflow/reference-search/route.ts +++ b/app/api/agent-workflow/reference-search/route.ts @@ -618,6 +618,7 @@ function extractProductDetailImageUrls(value: unknown) { async function requestProductDetailReferences(params: { productId: string; channel: AgentWorkflowProductDetailChannel; + target: "main" | "detail"; config: ReturnType; }) { const channel = normalizeProductDetailChannel( @@ -756,7 +757,9 @@ async function requestProductDetailReferences(params: { const detailImageUrls = normalizeUrlList(productData.detailImages); const fallbackDetailImageUrls = detailImageUrls.length > 0 ? detailImageUrls : extractProductDetailImageUrls(result); - if (fallbackDetailImageUrls.length === 0) { + const mainImageUrls = normalizeUrlList(productData.mainImages); + const resolvedTarget = params.target === "main" ? "main" : "detail"; + if (resolvedTarget === "detail" && fallbackDetailImageUrls.length === 0) { return jsonNoStore( { success: false, @@ -768,7 +771,18 @@ async function requestProductDetailReferences(params: { { status: 404 } ); } - const mainImageUrls = normalizeUrlList(productData.mainImages); + if (resolvedTarget === "main" && mainImageUrls.length === 0) { + return jsonNoStore( + { + success: false, + error: { + code: "PRODUCT_MAIN_IMAGES_NOT_FOUND", + message: "商品ID接口未返回可用主图", + }, + }, + { status: 404 } + ); + } const resolvedItemId = sanitizeText(productData.itemId, 128) || sanitizeText(productData.sourceItemId, 128) || @@ -803,7 +817,7 @@ async function requestProductDetailReferences(params: { }; const references = buildReferenceItems({ candidate, - target: "detail", + target: resolvedTarget, }); return jsonNoStore({ @@ -811,7 +825,7 @@ async function requestProductDetailReferences(params: { data: { query: resolvedItemId, queryType: "productId" as const, - target: "detail" as const, + target: resolvedTarget, matchedSpu: null, matchedSku: resolvedItemId, brandName: null, @@ -853,13 +867,13 @@ export async function GET(request: NextRequest) { const normalizedKeyword = queryType === "productId" ? parsed.keyword.trim() : parsed.keyword.trim().toUpperCase(); if (queryType === "productId") { - if (parsed.target !== "detail") { + if (parsed.target !== "detail" && parsed.target !== "main") { return jsonNoStore( { success: false, error: { code: "UNSUPPORTED_PRODUCT_ID_TARGET", - message: "商品ID查询当前仅支持详情图", + message: "商品ID查询当前仅支持主图或详情图", }, }, { status: 400 } @@ -872,6 +886,7 @@ export async function GET(request: NextRequest) { return requestProductDetailReferences({ productId: normalizedKeyword, channel: productDetailChannel, + target: parsed.target, config, }); } diff --git a/components/scene-fusion-view.tsx b/components/scene-fusion-view.tsx index ff16523ef..f813a8c7a 100644 --- a/components/scene-fusion-view.tsx +++ b/components/scene-fusion-view.tsx @@ -38,6 +38,7 @@ import { SelectContent, SelectItem, SelectTrigger, + SelectValue, } from "@/components/ui/select"; import { createCanvasPsdTemplateLaunchToken, saveCanvasPsdTemplateLaunchPayload } from "@/lib/canvas/psd-template-launch"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; @@ -48,6 +49,7 @@ import { type SceneFusionCapabilitySummary, } from "@/lib/scene-fusion/output-capabilities"; import { cn } from "@/lib/utils"; +import type { AgentWorkflowProductDetailChannel } from "@/types/agent"; const fetcher = (url: string) => fetch(url).then((res) => res.json()); const MAX_BATCH_ITEMS = 10; @@ -106,6 +108,42 @@ type SceneFusionPersistedState = { selectedResultIds: string[]; }; +type ReferenceSearchResponse = { + success?: boolean; + error?: { + message?: string; + }; + data?: { + references?: Array<{ + id?: string; + imageUrl?: string; + label?: string; + }>; + channel?: AgentWorkflowProductDetailChannel; + }; +}; + +function parseBatchProductIds(raw: string, maxCount: number) { + return Array.from( + new Set( + raw + .split(/[\n,,;\s]+/) + .map((item) => item.trim()) + .filter(Boolean) + ) + ).slice(0, maxCount); +} + +const PRODUCT_DETAIL_CHANNEL_OPTIONS: Array<{ + value: AgentWorkflowProductDetailChannel; + label: string; +}> = [ + { value: "taobao", label: "淘宝" }, + { value: "1688", label: "1688" }, + { value: "douyin", label: "抖音" }, + { value: "jd", label: "京东" }, +]; + function isPersistableImageValue(value: unknown): value is string { if (typeof value !== "string") return false; const normalized = value.trim(); @@ -351,6 +389,13 @@ export function SceneFusionView({ ); const [referenceTargetName, setReferenceTargetName] = useState(""); const [referenceCustomPrompt, setReferenceCustomPrompt] = useState(""); + const [referenceProductIdQuery, setReferenceProductIdQuery] = useState(""); + const [sceneReferenceProductIdBatchInput, setSceneReferenceProductIdBatchInput] = + useState(""); + const [referenceProductDetailChannel, setReferenceProductDetailChannel] = + useState("taobao"); + const [isReferenceProductSearchLoading, setIsReferenceProductSearchLoading] = + useState(false); const [randomItems, setRandomItems] = useState([]); const [randomCount, setRandomCount] = useState(4); const [randomPrompt, setRandomPrompt] = useState(""); @@ -820,6 +865,121 @@ export function SceneFusionView({ } }; + const fetchReferenceProductMainImage = async ( + productId: string, + channel: AgentWorkflowProductDetailChannel + ) => { + const searchParams = new URLSearchParams({ + keyword: productId, + queryType: "productId", + target: "main", + channel, + }); + const response = await fetch( + `/api/agent-workflow/reference-search?${searchParams.toString()}`, + { cache: "no-store" } + ); + const result = (await response.json().catch(() => ({}))) as ReferenceSearchResponse; + if (!response.ok || !result?.success) { + throw new Error(result?.error?.message || `商品ID ${productId} 主图查询失败`); + } + + const firstReferenceUrl = String( + result?.data?.references?.[0]?.imageUrl || "" + ).trim(); + if (!firstReferenceUrl) { + throw new Error(`商品ID ${productId} 未返回可用主图`); + } + + return { + imageUrl: firstReferenceUrl, + channel: result?.data?.channel || channel, + }; + }; + + const handleReferenceProductIdSearch = async () => { + const normalizedProductId = referenceProductIdQuery.trim(); + if (!normalizedProductId) { + toast.error("请输入商品ID后再查询主图"); + return; + } + + setIsReferenceProductSearchLoading(true); + try { + const result = await fetchReferenceProductMainImage( + normalizedProductId, + referenceProductDetailChannel + ); + setReferenceSceneImage(result.imageUrl); + setReferenceProductDetailChannel(result.channel); + toast.success("已将商品ID首张主图填充到参考图"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "商品ID主图查询失败"); + } finally { + setIsReferenceProductSearchLoading(false); + } + }; + + const handleSceneReferenceProductIdBatchSearch = async () => { + const remainingSlots = maxScenes - sceneItems.length; + if (remainingSlots <= 0) { + toast.error(`场景参考图最多 ${maxScenes} 张,请先删除后再批量查询`); + return; + } + + const productIds = parseBatchProductIds( + sceneReferenceProductIdBatchInput, + remainingSlots + ); + if (productIds.length === 0) { + toast.error("请输入至少一个商品ID后再批量查询"); + return; + } + + setIsReferenceProductSearchLoading(true); + try { + const resolvedItems: ProcessItem[] = []; + const failedIds: string[] = []; + for (const productId of productIds) { + try { + const result = await fetchReferenceProductMainImage( + productId, + referenceProductDetailChannel + ); + setReferenceProductDetailChannel(result.channel); + resolvedItems.push({ + id: Math.random().toString(36).slice(2, 10), + image: result.imageUrl, + targetName: "", + customPrompt: "", + status: "pending", + }); + } catch { + failedIds.push(productId); + } + } + + if (resolvedItems.length === 0) { + throw new Error("商品ID批量查询失败,未返回可用主图"); + } + setSceneItems((prev) => [...prev, ...resolvedItems]); + setSceneReferenceProductIdBatchInput(""); + if (failedIds.length === 0) { + toast.success(`已通过商品ID填充 ${resolvedItems.length} 张场景参考图`); + } else { + toast.warning( + `已填充 ${resolvedItems.length} 张,失败 ${failedIds.length} 个:${failedIds + .slice(0, 3) + .join("、")}${failedIds.length > 3 ? "..." : ""}` + ); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "商品ID批量查询失败"); + } finally { + setIsReferenceProductSearchLoading(false); + } + }; + const handleBatchUpload = async (e: React.ChangeEvent) => { const input = e.currentTarget; const files = validateImageFiles(Array.from(input.files || [])); @@ -2055,6 +2215,66 @@ export function SceneFusionView({ /> +
+
+ 支持批量输入商品ID,自动获取每个ID首张主图填充为场景参考图 +
+
+ + setSceneReferenceProductIdBatchInput(e.target.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleSceneReferenceProductIdBatchSearch(); + } + }} + placeholder={`批量输入商品ID,支持换行/逗号分隔,最多 ${Math.max( + maxScenes - sceneItems.length, + 0 + )} 个`} + value={sceneReferenceProductIdBatchInput} + /> +
+ + +
+
+
+
{sceneItems.length === 0 && (
)}
+
+
+ 支持输入商品ID自动获取首张主图填充到参考图 +
+
+ setReferenceProductIdQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleReferenceProductIdSearch(); + } + }} + placeholder="输入商品ID" + value={referenceProductIdQuery} + /> + + +
+