feat: support batch product id reference fill
This commit is contained in:
parent
da6cae9103
commit
d0bb8b7cf7
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user