409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
"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: [],
|
||
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;
|
||
|
||
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 response = await fetch(
|
||
`/api/agent-workflow/material-search?keyword=${encodeURIComponent(keyword)}&source=${source}&queryType=${queryType}`,
|
||
{ 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: [],
|
||
},
|
||
});
|
||
} 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}
|
||
|
||
<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>
|
||
);
|
||
}
|