ai-chatbot/components/agents/AgentWorkflowMaterialSelectionPanel.tsx
Your Name 21a915cb13
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: refine agent workflow step navigation
2026-03-21 12:32:57 +08:00

389 lines
14 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;
title?: string;
description?: string;
disabled?: boolean;
disabledHint?: string;
theme?: "light" | "dark";
}
export function AgentWorkflowMaterialSelectionPanel({
inputs,
onInputsChange,
materials,
onMaterialsChange,
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
);
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
title="主 SKU / SPU"
description="主商品图会优先自动填充到“产品图1”;如果没有搭配图,再继续补到“产品图2”。"
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}
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
title="搭配 SKU"
description="搭配商品图会优先自动填充到“产品图2”;品牌模特图会在上方单独选择并进入模特图槽位。"
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}
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>
);
}