ai-chatbot/components/agents/AgentWorkflowMaterialSelectionPanel.tsx
Your Name c0d4375daa
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: improve agent workflow guidance
2026-03-21 22:10:14 +08:00

638 lines
24 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, useMemo, 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;
const primarySelectedImages = useMemo(
() =>
(materials.primary.result?.images || []).filter((image) =>
materials.primary.selectedImageIds.includes(image.id)
),
[materials.primary.result?.images, materials.primary.selectedImageIds]
);
const secondarySelectedImages = useMemo(
() =>
(materials.secondary.result?.images || []).filter((image) =>
materials.secondary.selectedImageIds.includes(image.id)
),
[materials.secondary.result?.images, materials.secondary.selectedImageIds]
);
const slotPreviewCount = useMemo(() => {
if (
typeof productSelectionLimit === "number" &&
Number.isFinite(productSelectionLimit) &&
productSelectionLimit > 0
) {
return Math.round(productSelectionLimit);
}
return primarySelectedImages.length + secondarySelectedImages.length;
}, [primarySelectedImages.length, productSelectionLimit, secondarySelectedImages.length]);
const slotPreviewItems = useMemo(() => {
const orderedSources = [
...primarySelectedImages.map((image, index) => ({
source: "主商品" as const,
image,
sourceIndex: index,
})),
...secondarySelectedImages.map((image, index) => ({
source: "搭配商品" as const,
image,
sourceIndex: index,
})),
];
return Array.from({ length: slotPreviewCount }, (_, index) => {
const assigned = orderedSources[index] || null;
return {
slotLabel: `产品图${index + 1}`,
assigned,
};
});
}, [primarySelectedImages, secondarySelectedImages, slotPreviewCount]);
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 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}
<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>
{slotPreviewItems.length > 0 ? (
<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]")}>
当前已选图片会按顺序自动带入后续生图槽位,先主商品,后搭配商品。
</div>
</div>
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{slotPreviewItems.map((item, index) => {
const source = item.assigned?.source || null;
const previewUrl = item.assigned?.image.imageUrl || "";
const isAssigned = Boolean(item.assigned && previewUrl);
return (
<div
key={`${item.slotLabel}-${index}`}
className={cn(
"overflow-hidden rounded-[22px] border",
isAssigned
? isDark
? "border-emerald-400/20 bg-white/[0.03]"
: "border-[#c7d2fe] bg-[#f8faff]"
: isDark
? "border-dashed border-white/10 bg-white/[0.02]"
: "border-dashed border-[#dbe3ff] bg-[#f8faff]"
)}
>
<div
className={cn(
"relative aspect-[5/4] overflow-hidden",
isAssigned ? "" : isDark ? "bg-black/30" : "bg-white"
)}
>
{isAssigned ? (
<img
src={previewUrl}
alt={item.assigned?.image.label || item.slotLabel}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
<div
className={cn(
"flex h-full w-full items-center justify-center text-[11px]",
isDark ? "text-white/30" : "text-[#94a3b8]"
)}
>
待选择
</div>
)}
<div className="absolute left-2 top-2 rounded-full bg-black/60 px-2 py-1 text-[10px] text-white">
{item.slotLabel}
</div>
<div className="absolute right-2 top-2 rounded-full border border-white/10 bg-black/45 px-2 py-1 text-[10px] text-white/78">
{source || "空槽位"}
</div>
</div>
<div className="space-y-1 border-t border-white/10 px-3 py-2">
<div className={cn("truncate text-xs font-medium", isDark ? "text-white/82" : "text-[#334155]")}>
{isAssigned
? item.assigned?.image.label || `已映射到${item.slotLabel}`
: `${item.slotLabel}待补图`}
</div>
<div className={cn("truncate text-[11px]", isDark ? "text-white/45" : "text-[#64748b]")}>
{isAssigned
? `${source}第 ${(item.assigned?.sourceIndex || 0) + 1} 张`
: "先在下方搜索并选择图片"}
</div>
</div>
</div>
);
})}
</div>
</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>
);
}