feat: support batch product id reference fill
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-05-13 18:37:03 +08:00
parent da6cae9103
commit d0bb8b7cf7
2 changed files with 293 additions and 6 deletions

View File

@ -618,6 +618,7 @@ function extractProductDetailImageUrls(value: unknown) {
async function requestProductDetailReferences(params: {
productId: string;
channel: AgentWorkflowProductDetailChannel;
target: "main" | "detail";
config: ReturnType<typeof normalizeAgentWorkflowMaterialApiConfig>;
}) {
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,
});
}

View File

@ -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<AgentWorkflowProductDetailChannel>("taobao");
const [isReferenceProductSearchLoading, setIsReferenceProductSearchLoading] =
useState(false);
const [randomItems, setRandomItems] = useState<ProcessItem[]>([]);
const [randomCount, setRandomCount] = useState<number>(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<HTMLInputElement>) => {
const input = e.currentTarget;
const files = validateImageFiles(Array.from(input.files || []));
@ -2055,6 +2215,66 @@ export function SceneFusionView({
/>
</div>
<div className="rounded-lg border bg-muted/20 p-3">
<div className="mb-2 text-muted-foreground text-xs">
支持批量输入商品ID,自动获取每个ID首张主图填充为场景参考图
</div>
<div className="flex flex-col gap-2">
<Input
className="h-8 text-xs"
disabled={isProcessing || isReferenceProductSearchLoading}
onChange={(e) =>
setSceneReferenceProductIdBatchInput(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleSceneReferenceProductIdBatchSearch();
}
}}
placeholder={`批量输入商品ID,支持换行/逗号分隔,最多 ${Math.max(
maxScenes - sceneItems.length,
0
)} 个`}
value={sceneReferenceProductIdBatchInput}
/>
<div className="flex flex-col gap-2 sm:flex-row">
<Select
disabled={isProcessing || isReferenceProductSearchLoading}
onValueChange={(value) =>
setReferenceProductDetailChannel(
value as AgentWorkflowProductDetailChannel
)
}
value={referenceProductDetailChannel}
>
<SelectTrigger className="h-8 w-full text-xs sm:w-[110px]">
<SelectValue placeholder="渠道" />
</SelectTrigger>
<SelectContent className="z-[30000] !bg-white dark:!bg-zinc-950">
{PRODUCT_DETAIL_CHANNEL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
className="h-8 px-3 text-xs"
disabled={isProcessing || isReferenceProductSearchLoading}
onClick={() => void handleSceneReferenceProductIdBatchSearch()}
type="button"
variant="outline"
>
{isReferenceProductSearchLoading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : null}
批量查询主图
</Button>
</div>
</div>
</div>
<div className="space-y-3">
{sceneItems.length === 0 && (
<div
@ -2241,6 +2461,58 @@ export function SceneFusionView({
</Button>
)}
</div>
<div className="rounded-lg border bg-muted/20 p-3">
<div className="mb-2 text-muted-foreground text-xs">
支持输入商品ID自动获取首张主图填充到参考图
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
className="h-8 text-xs"
disabled={isProcessing || isReferenceProductSearchLoading}
onChange={(e) => setReferenceProductIdQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleReferenceProductIdSearch();
}
}}
placeholder="输入商品ID"
value={referenceProductIdQuery}
/>
<Select
disabled={isProcessing || isReferenceProductSearchLoading}
onValueChange={(value) =>
setReferenceProductDetailChannel(
value as AgentWorkflowProductDetailChannel
)
}
value={referenceProductDetailChannel}
>
<SelectTrigger className="h-8 w-full text-xs sm:w-[110px]">
<SelectValue placeholder="渠道" />
</SelectTrigger>
<SelectContent className="z-[30000] !bg-white dark:!bg-zinc-950">
{PRODUCT_DETAIL_CHANNEL_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
className="h-8 px-3 text-xs"
disabled={isProcessing || isReferenceProductSearchLoading}
onClick={() => void handleReferenceProductIdSearch()}
type="button"
variant="outline"
>
{isReferenceProductSearchLoading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : null}
查询主图
</Button>
</div>
</div>
<div
className={cn(
"group relative cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors",