ai-chatbot/components/agents/AgentWorkflowMaterialSelectionPanel.tsx
2026-03-22 19:43:31 +08:00

525 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import { Plus, Sparkles } from "lucide-react";
import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import { cn } from "@/lib/utils";
import type { AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanel";
import type {
AgentWorkflowMaterialSearchResult,
AgentWorkflowMaterialQueryType,
AgentWorkflowMaterialSelections,
} from "@/types/agent";
interface AgentWorkflowMaterialSelectionPanelProps {
inputs: AgentWorkflowInputs;
onInputsChange: (next: AgentWorkflowInputs) => void;
materials: AgentWorkflowMaterialSelections;
onMaterialsChange: (next: AgentWorkflowMaterialSelections) => void;
productSelectionLimit?: number | null;
title?: string;
description?: string;
disabled?: boolean;
disabledHint?: string;
theme?: "light" | "dark";
}
export function AgentWorkflowMaterialSelectionPanel({
inputs,
onInputsChange,
materials,
onMaterialsChange,
productSelectionLimit = null,
title,
description,
disabled = false,
disabledHint = "请先在下方选择一个生图模式,再启用 SKU / SPU 搜图。",
theme = "light",
}: AgentWorkflowMaterialSelectionPanelProps) {
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,
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 [materialSearchMode, setMaterialSearchMode] = useState<{
primary: AgentWorkflowMaterialQueryType;
secondary: AgentWorkflowMaterialQueryType;
}>({
primary: "sku",
secondary: "sku",
});
const [isSecondaryMaterialVisible, setIsSecondaryMaterialVisible] = useState(
hasSecondaryMaterialContent
);
const primarySelectedCount = materials.primary.selectedImageIds.length;
const secondarySelectedCount = materials.secondary.selectedImageIds.length;
const primarySelectionLimit =
typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0
? Math.max(productSelectionLimit - secondarySelectedCount, primarySelectedCount, 0)
: null;
const secondarySelectionLimit =
typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0
? Math.max(productSelectionLimit - primarySelectedCount, secondarySelectedCount, 0)
: null;
const hasAnyQueryInput =
Boolean(String(inputs.primarySku || "").trim()) ||
Boolean(String(inputs.secondarySku || "").trim());
const hasSkuSelectionPending =
Boolean(materials.primary.result?.skuOptions?.length) ||
Boolean(materials.secondary.result?.skuOptions?.length);
const hasAnyMaterialResult =
Boolean(materials.primary.result?.images?.length) ||
Boolean(materials.secondary.result?.images?.length) ||
hasSkuSelectionPending;
const hasAnySelectedImages =
materials.primary.selectedImageIds.length > 0 || materials.secondary.selectedImageIds.length > 0;
const materialGuideSteps = [
{
badge: "01",
label: "输入型号",
description: "先选择按 SPU 或 SKU 搜索,再输入商品型号。",
status: hasAnyQueryInput ? "completed" : "current",
},
{
badge: "02",
label: "选择SKU",
description: "如果是 SPU 查询,先从返回的 SKU 列表里点选具体 SKU。",
status: hasSkuSelectionPending ? "current" : hasAnyMaterialResult ? "completed" : "pending",
},
{
badge: "03",
label: "自动选图",
description: "系统会按角度权重自动选图并映射到产品槽位,你也可以手动微调。",
status: hasAnySelectedImages ? "completed" : hasAnyMaterialResult ? "current" : "pending",
},
] as const;
useEffect(() => {
if (hasSecondaryMaterialContent) {
setIsSecondaryMaterialVisible(true);
}
}, [hasSecondaryMaterialContent]);
const updateMaterialSearchState = (
source: "primary" | "secondary",
updates: Partial<{ isLoading: boolean; errorText: string | null }>
) => {
setMaterialSearchState((prev) => ({
...prev,
[source]: {
...prev[source],
...updates,
},
}));
};
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));
}
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 || "搜索失败");
}
onMaterialsChange({
...materials,
[source]: {
result: result.data,
selectedImageIds: Array.isArray(result.data?.recommendedImageIds)
? result.data.recommendedImageIds
: [],
},
});
} catch (error) {
updateMaterialSearchState(source, {
errorText: error instanceof Error ? error.message : "搜索失败",
});
} finally {
updateMaterialSearchState(source, { isLoading: false });
}
};
const isDark = theme === "dark";
return (
<div
className={cn(
"space-y-3 rounded-[28px] border p-4",
isDark ? "border-white/12 bg-white/[0.04]" : "border-[#dbe3ff] bg-[#f8faff]"
)}
>
{title || description ? (
<div className="space-y-1">
{title ? (
<div
className={cn(
"flex items-center gap-2 text-sm font-medium",
isDark ? "text-white/88" : "text-[#334155]"
)}
>
<Sparkles className={cn("h-4 w-4", isDark ? "text-emerald-300" : "text-[#4f46e5]")} />
<span>{title}</span>
</div>
) : null}
{description ? (
<div
className={cn(
"text-xs leading-5",
isDark ? "text-white/55" : "text-[#5b6472]"
)}
>
{description}
</div>
) : null}
</div>
) : null}
<div
className={cn(
"space-y-3 rounded-[24px] border px-4 py-4",
isDark ? "border-white/10 bg-black/25" : "border-[#dbe3ff] bg-white"
)}
>
<div className="flex flex-col gap-1">
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "text-[#334155]")}>
选图流程
</div>
<div className={cn("text-xs leading-5", isDark ? "text-white/50" : "text-[#64748b]")}>
按这 3 步操作就能完成输入图准备,避免来回试错。
</div>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-3">
{materialGuideSteps.map((step) => (
<div
key={step.badge}
className={cn(
"rounded-[20px] border px-4 py-3",
step.status === "completed"
? isDark
? "border-emerald-400/20 bg-emerald-500/[0.08]"
: "border-emerald-200 bg-emerald-50"
: step.status === "current"
? isDark
? "border-amber-400/20 bg-amber-500/[0.08]"
: "border-amber-200 bg-amber-50"
: isDark
? "border-white/10 bg-white/[0.03]"
: "border-[#dbe3ff] bg-[#f8faff]"
)}
>
<div className="flex items-center gap-2">
<span
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-semibold",
step.status === "completed"
? isDark
? "bg-emerald-300 text-black"
: "bg-emerald-600 text-white"
: step.status === "current"
? isDark
? "bg-amber-300 text-black"
: "bg-amber-500 text-white"
: isDark
? "bg-white/10 text-white/60"
: "bg-white text-[#64748b]"
)}
>
{step.badge}
</span>
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "text-[#334155]")}>
{step.label}
</div>
</div>
<div className={cn("mt-2 text-xs leading-5", isDark ? "text-white/50" : "text-[#64748b]")}>
{step.description}
</div>
</div>
))}
</div>
</div>
<AgentWorkflowMaterialPicker
value={inputs.primarySku}
queryMode={materialSearchMode.primary}
placeholder={materialSearchMode.primary === "spu" ? "输入主 SPU" : "输入主 SKU"}
buttonLabel={materialSearchMode.primary === "spu" ? "查SKU列表" : "搜索主图"}
searchNote={
materialSearchMode.primary === "spu"
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,点选某个 SKU 后再继续拉取白底图。"
: "按 SKU 搜索会直接拉取白底图,适合你已经知道具体 SKU 的情况。"
}
disabled={disabled}
disabledHint={disabledHint}
isLoading={materialSearchState.primary.isLoading}
errorText={materialSearchState.primary.errorText}
result={materials.primary.result}
selectedImageIds={materials.primary.selectedImageIds}
selectionLimit={primarySelectionLimit}
selectionLimitLabel={
typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0
? `当前模式产品图总上限 ${productSelectionLimit} 张`
: undefined
}
onValueChange={(value) => handleMaterialInputChange("primary", value)}
onQueryModeChange={(nextMode) =>
setMaterialSearchMode((prev) => ({
...prev,
primary: nextMode,
}))
}
onSearch={() => {
void handleSearchMaterials("primary");
}}
onSkuOptionSelect={(skuName) => {
const selectedOption = materials.primary.result?.skuOptions?.find(
(option) => option.skuName === skuName
);
const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
onInputsChange({
...inputs,
primarySku: skuName,
});
onMaterialsChange({
...materials,
primary: {
result: buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.primary.result,
placeholderCount,
}),
selectedImageIds: [],
},
});
setMaterialSearchMode((prev) => ({
...prev,
primary: "sku",
}));
void handleSearchMaterials("primary", {
keyword: skuName,
queryType: "sku",
});
}}
theme={theme}
onSelectedImageIdsChange={(next) =>
onMaterialsChange({
...materials,
primary: {
...materials.primary,
selectedImageIds: next,
},
})
}
/>
{isSecondaryMaterialVisible ? (
<AgentWorkflowMaterialPicker
value={inputs.secondarySku}
queryMode={materialSearchMode.secondary}
placeholder={materialSearchMode.secondary === "spu" ? "输入搭配 SPU" : "输入搭配 SKU"}
buttonLabel={materialSearchMode.secondary === "spu" ? "查SKU列表" : "搜索搭配图"}
searchNote={
materialSearchMode.secondary === "spu"
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,再选具体 SKU 获取白底图。"
: "选中的搭配图会进入第二个产品槽位,你也可以在下面继续手动替换。"
}
disabled={disabled}
disabledHint={disabledHint}
isLoading={materialSearchState.secondary.isLoading}
errorText={materialSearchState.secondary.errorText}
result={materials.secondary.result}
selectedImageIds={materials.secondary.selectedImageIds}
selectionLimit={secondarySelectionLimit}
selectionLimitLabel={
typeof productSelectionLimit === "number" && Number.isFinite(productSelectionLimit) && productSelectionLimit > 0
? `当前模式产品图总上限 ${productSelectionLimit} 张`
: undefined
}
onValueChange={(value) => handleMaterialInputChange("secondary", value)}
onQueryModeChange={(nextMode) =>
setMaterialSearchMode((prev) => ({
...prev,
secondary: nextMode,
}))
}
onSearch={() => {
void handleSearchMaterials("secondary");
}}
onSkuOptionSelect={(skuName) => {
const selectedOption = materials.secondary.result?.skuOptions?.find(
(option) => option.skuName === skuName
);
const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
onInputsChange({
...inputs,
secondarySku: skuName,
});
onMaterialsChange({
...materials,
secondary: {
result: buildLoadingMaterialResult({
keyword: skuName,
currentResult: materials.secondary.result,
placeholderCount,
}),
selectedImageIds: [],
},
});
setMaterialSearchMode((prev) => ({
...prev,
secondary: "sku",
}));
void handleSearchMaterials("secondary", {
keyword: skuName,
queryType: "sku",
});
}}
theme={theme}
onSelectedImageIdsChange={(next) =>
onMaterialsChange({
...materials,
secondary: {
...materials.secondary,
selectedImageIds: next,
},
})
}
/>
) : (
<button
type="button"
onClick={() => setIsSecondaryMaterialVisible(true)}
disabled={disabled}
className={cn(
"flex w-full items-center justify-between rounded-[24px] border border-dashed px-4 py-3 text-left transition-colors",
disabled
? isDark
? "cursor-not-allowed border-white/10 bg-white/[0.02] text-white/30"
: "cursor-not-allowed border-[#dbe3ff] bg-[#f8faff] text-[#94a3b8]"
: isDark
? "border-white/14 bg-white/[0.03] text-white/78 hover:border-emerald-300/30 hover:bg-white/[0.05]"
: "border-[#c7d2fe] bg-white text-[#334155] hover:border-[#818cf8] hover:bg-[#f8faff]"
)}
>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<Plus className={cn("h-4 w-4", isDark ? "text-emerald-300" : "text-[#4f46e5]")} />
<span>添加搭配SKU</span>
</div>
<div className={cn("text-xs leading-5", isDark ? "text-white/45" : "text-[#64748b]")}>
需要补充第二个产品图时再展开这个模块,不使用时默认保持隐藏。
</div>
</div>
<span
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-medium",
isDark ? "bg-white/[0.06] text-white/72" : "bg-[#eef2ff] text-[#4f46e5]"
)}
>
可选
</span>
</button>
)}
</div>
);
}