diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index 09d1e7bee..a06a0cb30 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -6,10 +6,11 @@ import useSWR from "swr"; import { toast } from "sonner"; import { motion, AnimatePresence } from "framer-motion"; import { + ArrowDown, ArrowLeft, + ArrowUp, ChevronDown, ChevronRight, - GripVertical, Image as ImageIcon, Layers, Loader2, @@ -95,6 +96,7 @@ import type { AgentWorkflowMaterialApiConfig, AgentWorkflowOptionUiConfig, AgentPsdTextColorConfig, + AgentWorkflowStoreEntry, AgentWorkflowShowcaseConfig, AgentWorkflowStepUiConfig, AgentWorkflowUiConfig, @@ -306,6 +308,23 @@ function removeWorkflowStepTree( })); } +function moveArrayItem(items: T[], fromIndex: number, toIndex: number): T[] { + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= items.length || + toIndex >= items.length + ) { + return items; + } + + const nextItems = [...items]; + const [movedItem] = nextItems.splice(fromIndex, 1); + nextItems.splice(toIndex, 0, movedItem); + return nextItems; +} + function collectWorkflowStepKeys(steps: AgentWorkflowStepUiConfig[]): string[] { return flattenAgentWorkflowSteps(steps).map(({ step }) => step.key); } @@ -437,13 +456,33 @@ function buildWorkflowInputOptionsFromBrandEntries( ) { const normalizedEntries = entries.map((entry, index) => ({ brandName: entry.brandName.trim(), - stores: Array.from( - new Set( - (entry.stores || []) - .map((store) => store.trim()) - .filter(Boolean) - ) - ), + storeEntries: (() => { + const uniqueStoreEntries = new Map(); + + (entry.storeEntries || []).forEach((storeEntry, storeIndex) => { + const storeName = String(storeEntry.storeName || "").trim(); + if (!storeName) { + return; + } + + uniqueStoreEntries.set(storeName.toLowerCase(), { + storeName, + thirdCategories: Array.from( + new Set( + (storeEntry.thirdCategories || []) + .map((thirdCategory) => thirdCategory.trim()) + .filter(Boolean) + ) + ), + sortOrder: storeIndex, + }); + }); + + return Array.from(uniqueStoreEntries.values()).map((storeEntry, storeIndex) => ({ + ...storeEntry, + sortOrder: storeIndex, + })); + })(), sortOrder: index, })); const normalizedPriceBands = Array.from( @@ -455,9 +494,25 @@ function buildWorkflowInputOptionsFromBrandEntries( ); return { - brandStoreEntries: normalizedEntries, + brandStoreEntries: normalizedEntries.map((entry) => ({ + ...entry, + stores: entry.storeEntries.map((storeEntry) => storeEntry.storeName), + })), brands: normalizedEntries.map((entry) => entry.brandName), - stores: Array.from(new Set(normalizedEntries.flatMap((entry) => entry.stores))), + stores: Array.from( + new Set( + normalizedEntries.flatMap((entry) => + entry.storeEntries.map((storeEntry) => storeEntry.storeName) + ) + ) + ), + thirdCategories: Array.from( + new Set( + normalizedEntries.flatMap((entry) => + entry.storeEntries.flatMap((storeEntry) => storeEntry.thirdCategories || []) + ) + ) + ), priceBands: normalizedPriceBands, }; } @@ -517,6 +572,8 @@ export function AgentWorkflowSettingsClient({ const [brandDraft, setBrandDraft] = useState(""); const [priceBandDraft, setPriceBandDraft] = useState(""); const [storeDrafts, setStoreDrafts] = useState>({}); + const [thirdCategoryDrafts, setThirdCategoryDrafts] = useState>({}); + const [blockedOptionSearches, setBlockedOptionSearches] = useState>({}); const fileInputRefs = useRef>({}); useEffect(() => { @@ -597,6 +654,14 @@ export function AgentWorkflowSettingsClient({ [...presetLibraryConfig.sections].sort((left, right) => left.sortOrder - right.sortOrder), [presetLibraryConfig.sections] ); + const workflowOptionRelations = useMemo( + () => collectWorkflowOptionRelationsFromSteps(config.steps || []), + [config.steps] + ); + const workflowOptionRelationLookup = useMemo( + () => new Map(workflowOptionRelations.map((item) => [item.key, item] as const)), + [workflowOptionRelations] + ); const updateInputOptions = (nextEntries: AgentWorkflowBrandStoreEntry[]) => { setConfig((prev) => ({ ...prev, @@ -629,6 +694,7 @@ export function AgentWorkflowSettingsClient({ { brandName: nextValue, stores: [], + storeEntries: [], sortOrder: brandStoreEntries.length, }, ]); @@ -642,6 +708,15 @@ export function AgentWorkflowSettingsClient({ delete next[brandName]; return next; }); + setThirdCategoryDrafts((prev) => { + const next = { ...prev }; + Object.keys(next).forEach((key) => { + if (key.startsWith(`${brandName}::`)) { + delete next[key]; + } + }); + return next; + }); }; const handleAddStoreToBrand = (brandName: string) => { @@ -653,7 +728,12 @@ export function AgentWorkflowSettingsClient({ } const targetEntry = brandStoreEntries.find((entry) => entry.brandName === brandName); - if (targetEntry?.stores.some((store) => store.toLowerCase() === nextValue.toLowerCase())) { + if ( + targetEntry?.storeEntries?.some( + (storeEntry) => storeEntry.storeName.toLowerCase() === nextValue.toLowerCase() + ) || + targetEntry?.stores.some((store) => store.toLowerCase() === nextValue.toLowerCase()) + ) { toast.error("该店铺已存在"); return; } @@ -664,6 +744,17 @@ export function AgentWorkflowSettingsClient({ return { ...entry, stores: [...entry.stores, nextValue], + storeEntries: [ + ...(entry.storeEntries || []).map((storeEntry, storeIndex) => ({ + ...storeEntry, + sortOrder: storeIndex, + })), + { + storeName: nextValue, + thirdCategories: [], + sortOrder: (entry.storeEntries || []).length, + }, + ], }; }) ); @@ -680,10 +771,86 @@ export function AgentWorkflowSettingsClient({ ? { ...entry, stores: entry.stores.filter((store) => store !== storeName), + storeEntries: (entry.storeEntries || []).filter( + (storeEntry) => storeEntry.storeName !== storeName + ), } : entry ) ); + setThirdCategoryDrafts((prev) => { + const next = { ...prev }; + delete next[`${brandName}::${storeName}`]; + return next; + }); + }; + + const handleAddThirdCategoryToStore = (brandName: string, storeName: string) => { + const draftKey = `${brandName}::${storeName}`; + const nextValue = normalizeInputOptionValue(thirdCategoryDrafts[draftKey] || ""); + if (!nextValue) { + toast.error("请输入三级分类名称"); + return; + } + + const targetEntry = brandStoreEntries.find((entry) => entry.brandName === brandName); + const targetStoreEntry = targetEntry?.storeEntries?.find( + (storeEntry) => storeEntry.storeName === storeName + ); + if ( + targetStoreEntry?.thirdCategories.some( + (thirdCategory) => thirdCategory.toLowerCase() === nextValue.toLowerCase() + ) + ) { + toast.error("该三级分类已存在"); + return; + } + + updateInputOptions( + brandStoreEntries.map((entry) => { + if (entry.brandName !== brandName) return entry; + return { + ...entry, + storeEntries: (entry.storeEntries || []).map((storeEntry) => + storeEntry.storeName === storeName + ? { + ...storeEntry, + thirdCategories: [...(storeEntry.thirdCategories || []), nextValue], + } + : storeEntry + ), + }; + }) + ); + setThirdCategoryDrafts((prev) => ({ + ...prev, + [draftKey]: "", + })); + }; + + const handleRemoveThirdCategoryFromStore = ( + brandName: string, + storeName: string, + thirdCategory: string + ) => { + updateInputOptions( + brandStoreEntries.map((entry) => { + if (entry.brandName !== brandName) return entry; + return { + ...entry, + storeEntries: (entry.storeEntries || []).map((storeEntry) => + storeEntry.storeName === storeName + ? { + ...storeEntry, + thirdCategories: (storeEntry.thirdCategories || []).filter( + (item) => item !== thirdCategory + ), + } + : storeEntry + ), + }; + }) + ); }; const handleAddPriceBand = () => { @@ -1020,6 +1187,33 @@ export function AgentWorkflowSettingsClient({ })); }; + const handleMoveRootOption = (stepKey: string, optionKey: string, direction: "up" | "down") => { + updateStep(stepKey, (step) => { + const sortedRootOptions = [...(step.options || [])].sort( + (left, right) => left.sortOrder - right.sortOrder + ); + const currentIndex = sortedRootOptions.findIndex((option) => option.key === optionKey); + if (currentIndex < 0) { + return step; + } + + const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1; + if (targetIndex < 0 || targetIndex >= sortedRootOptions.length) { + return step; + } + + return { + ...step, + options: moveArrayItem(sortedRootOptions, currentIndex, targetIndex).map( + (option, index) => ({ + ...option, + sortOrder: index, + }) + ), + }; + }); + }; + const handleUploadPreview = async (stepKey: string, optionKey: string, file: File) => { if (!file.type.startsWith("image/")) { toast.error("请上传图片文件"); @@ -1346,13 +1540,26 @@ export function AgentWorkflowSettingsClient({ (left, right) => left.sortOrder - right.sortOrder ); const canAddChild = depth < AGENT_WORKFLOW_MAX_OPTION_DEPTH; - const optionRelations = collectWorkflowOptionRelationsFromSteps(config.steps || []); - const optionLookup = new Map(optionRelations.map((item) => [item.key, item] as const)); - const blockedCandidates = optionRelations.filter((item) => item.key !== option.key); + const blockedCandidates = workflowOptionRelations.filter( + (item) => item.key !== option.key && item.depth === depth + ); const selectedBlockedItems = (option.blockedOptionKeys || []) - .map((blockedKey) => optionLookup.get(blockedKey)) + .map((blockedKey) => workflowOptionRelationLookup.get(blockedKey)) .filter((item): item is WorkflowOptionRelation => Boolean(item)); const isExpanded = isWorkflowOptionExpanded(option.key, depth); + const blockedSearchValue = blockedOptionSearches[refKey] || ""; + const normalizedBlockedSearchValue = blockedSearchValue.trim().toLowerCase(); + const filteredBlockedCandidates = blockedCandidates.filter((candidate) => { + if (!normalizedBlockedSearchValue) { + return true; + } + + return [candidate.label, candidate.stepTitle, candidate.key].some((value) => + value.toLowerCase().includes(normalizedBlockedSearchValue) + ); + }); + const canMoveUp = depth === 1 && indexPath[0] > 1; + const canMoveDown = depth === 1 && indexPath[0] < (step.options || []).length; return (
1 && "pl-4 sm:pl-6")}> @@ -1403,6 +1610,30 @@ export function AgentWorkflowSettingsClient({
+ {depth === 1 ? ( + <> + + + + ) : null} {canAddChild ? (
@@ -1574,9 +1805,24 @@ export function AgentWorkflowSettingsClient({ )} -
- {blockedCandidates.length > 0 ? ( - blockedCandidates.map((candidate) => { +
+
+ + + setBlockedOptionSearches((prev) => ({ + ...prev, + [refKey]: e.target.value, + })) + } + placeholder={`搜索同级卡片(${blockedCandidates.length} 个候选)`} + className="h-8 pl-8 text-xs" + /> +
+
+ {filteredBlockedCandidates.length > 0 ? ( + filteredBlockedCandidates.map((candidate) => { const active = (option.blockedOptionKeys || []).includes(candidate.key); return ( ); - }) - ) : ( -
- 当前工作流下没有其他可限制卡片 -
- )} + }) + ) : ( +
+ {blockedCandidates.length > 0 + ? "没有命中搜索结果,换个关键词试试" + : "当前工作流下没有其他可限制的同级卡片"} +
+ )} +
@@ -2477,7 +2726,7 @@ export function AgentWorkflowSettingsClient({
基础下拉选项 - 配置品牌、店铺母子关系,以及第一页可选的价格带区间 + 配置品牌、店铺、三级分类的层级关系,以及第一页可选的价格带区间
@@ -2530,7 +2779,7 @@ export function AgentWorkflowSettingsClient({ - {entry.stores.length} 个店铺 + {(entry.storeEntries || []).length} 个店铺 @@ -2564,24 +2813,112 @@ export function AgentWorkflowSettingsClient({ - {entry.stores.length > 0 ? ( -
- {entry.stores.map((store) => ( -
- {store} - -
- ))} +
+
+
+ {storeEntry.storeName} + +
+ + {(storeEntry.thirdCategories || []).length} 个三级分类 + +
+
+ +
+
+ + setThirdCategoryDrafts((prev) => ({ + ...prev, + [draftKey]: e.target.value, + })) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddThirdCategoryToStore( + entry.brandName, + storeEntry.storeName + ); + } + }} + placeholder={`给店铺“${storeEntry.storeName}”添加三级分类`} + /> + +
+ + {(storeEntry.thirdCategories || []).length > 0 ? ( +
+ {(storeEntry.thirdCategories || []).map( + (thirdCategory) => ( +
+ {thirdCategory} + +
+ ) + )} +
+ ) : ( +
+ 这个店铺下还没有添加任何三级分类。 +
+ )} +
+
+ ); + })} ) : (
@@ -2594,7 +2931,7 @@ export function AgentWorkflowSettingsClient({
) : (
- 还没有添加品牌。先添加品牌,再在每个品牌下继续添加店铺。 + 还没有添加品牌。先添加品牌,再在每个品牌下继续添加店铺和三级分类。
)} diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 6bc70028b..b5c1224bd 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -730,12 +730,26 @@ const updateSchema = z.object({ .object({ brands: z.array(z.string().trim().min(1).max(64)).max(300).optional(), stores: z.array(z.string().trim().min(1).max(64)).max(300).optional(), + thirdCategories: z.array(z.string().trim().min(1).max(64)).max(300).optional(), priceBands: z.array(z.string().trim().min(1).max(64)).max(300).optional(), brandStoreEntries: z .array( z.object({ brandName: z.string().trim().min(1).max(64), stores: z.array(z.string().trim().min(1).max(64)).max(300).optional(), + storeEntries: z + .array( + z.object({ + storeName: z.string().trim().min(1).max(64), + thirdCategories: z + .array(z.string().trim().min(1).max(64)) + .max(300) + .optional(), + sortOrder: z.number().min(0).max(999).optional(), + }) + ) + .max(300) + .optional(), sortOrder: z.number().min(0).max(999).optional(), }) ) diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx index 6b23f8de4..7dc63701a 100644 --- a/app/canvas/[id]/page.tsx +++ b/app/canvas/[id]/page.tsx @@ -152,6 +152,7 @@ function hasMeaningfulWorkflowState( String(inputs?.secondarySku || "").trim() || String(inputs?.brand || "").trim() || String(inputs?.store || "").trim() || + String(inputs?.thirdCategory || "").trim() || String(inputs?.priceBand || "").trim() ); @@ -320,6 +321,7 @@ export default function CanvasEditorPage({ params }: PageProps) { secondarySku: "", brand: "", store: "", + thirdCategory: "", priceBand: "", }); const [workflowMaterials, setWorkflowMaterials] = useState( @@ -486,6 +488,7 @@ export default function CanvasEditorPage({ params }: PageProps) { secondarySku: workflowInputs.secondarySku.trim(), brand: workflowInputs.brand.trim(), store: workflowInputs.store.trim(), + thirdCategory: workflowInputs.thirdCategory.trim(), priceBand: workflowInputs.priceBand.trim(), }, materials: workflowMaterials, @@ -531,6 +534,7 @@ export default function CanvasEditorPage({ params }: PageProps) { secondarySku: "", brand: "", store: "", + thirdCategory: "", priceBand: "", }); setWorkflowMaterials(createEmptyAgentWorkflowMaterialSelections()); @@ -562,6 +566,7 @@ export default function CanvasEditorPage({ params }: PageProps) { secondarySku: String(persistedWorkflow.inputs?.secondarySku || "").trim(), brand: String(persistedWorkflow.inputs?.brand || "").trim(), store: String(persistedWorkflow.inputs?.store || "").trim(), + thirdCategory: String(persistedWorkflow.inputs?.thirdCategory || "").trim(), priceBand: String(persistedWorkflow.inputs?.priceBand || "").trim(), }); setWorkflowMaterials( @@ -670,6 +675,7 @@ export default function CanvasEditorPage({ params }: PageProps) { secondarySku: workflowInputs.secondarySku.trim(), brand: workflowInputs.brand.trim(), store: workflowInputs.store.trim(), + thirdCategory: workflowInputs.thirdCategory.trim(), priceBand: workflowInputs.priceBand.trim(), }, materials: workflowMaterials, @@ -756,6 +762,7 @@ export default function CanvasEditorPage({ params }: PageProps) { secondarySku: String(parsed.inputs?.secondarySku || "").trim(), brand: String(parsed.inputs?.brand || "").trim(), store: String(parsed.inputs?.store || "").trim(), + thirdCategory: String(parsed.inputs?.thirdCategory || "").trim(), priceBand: String(parsed.inputs?.priceBand || "").trim(), }); setWorkflowMaterials(normalizeAgentWorkflowMaterialSelections(parsed.materials)); @@ -882,6 +889,7 @@ export default function CanvasEditorPage({ params }: PageProps) { secondarySku: String(persistedWorkflow.inputs?.secondarySku || "").trim(), brand: String(persistedWorkflow.inputs?.brand || "").trim(), store: String(persistedWorkflow.inputs?.store || "").trim(), + thirdCategory: String(persistedWorkflow.inputs?.thirdCategory || "").trim(), priceBand: String(persistedWorkflow.inputs?.priceBand || "").trim(), }); setWorkflowMaterials(normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials)); diff --git a/components/agents/AgentPageDark.tsx b/components/agents/AgentPageDark.tsx index 6b78d022f..30241448f 100644 --- a/components/agents/AgentPageDark.tsx +++ b/components/agents/AgentPageDark.tsx @@ -37,6 +37,7 @@ export function AgentPageDark({ secondarySku: "", brand: "", store: "", + thirdCategory: "", priceBand: "", }); const [workflowMaterials, setWorkflowMaterials] = useState( diff --git a/components/agents/AgentWorkflowPanel.tsx b/components/agents/AgentWorkflowPanel.tsx index 00eb9dfe9..44162e76b 100644 --- a/components/agents/AgentWorkflowPanel.tsx +++ b/components/agents/AgentWorkflowPanel.tsx @@ -12,6 +12,7 @@ import { defaultAgentWorkflowUiConfig, getAgentWorkflowBrandStoreEntries, getAgentWorkflowPriceBandOptions, + getAgentWorkflowThirdCategoryOptionsByBrandStore, getAgentWorkflowStoreOptionsByBrand, } from "@/lib/agents/workflow-ui-config"; import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config"; @@ -31,6 +32,7 @@ export interface AgentWorkflowInputs { secondarySku: string; brand: string; store: string; + thirdCategory: string; priceBand: string; } @@ -98,6 +100,15 @@ export function AgentWorkflowPanel({ () => getAgentWorkflowStoreOptionsByBrand({ config, brand: inputs.brand }), [config, inputs.brand] ); + const thirdCategoryOptions = useMemo( + () => + getAgentWorkflowThirdCategoryOptionsByBrandStore({ + config, + brand: inputs.brand, + store: inputs.store, + }), + [config, inputs.brand, inputs.store] + ); const priceBandOptions = useMemo( () => getAgentWorkflowPriceBandOptions(config), [config] @@ -181,13 +192,14 @@ export function AgentWorkflowPanel({ onReset(); return; } - onInputsChange({ - primarySku: "", - secondarySku: "", - brand: "", - store: "", - priceBand: "", - }); + onInputsChange({ + primarySku: "", + secondarySku: "", + brand: "", + store: "", + thirdCategory: "", + priceBand: "", + }); onMaterialsChange({ primary: createEmptyAgentWorkflowMaterialSelection(), secondary: createEmptyAgentWorkflowMaterialSelection(), @@ -205,7 +217,7 @@ export function AgentWorkflowPanel({
基础配置
-
+
@@ -230,7 +246,25 @@ export function AgentWorkflowPanel({ searchPlaceholder="输入店铺名筛选" emptyText={inputs.brand ? "当前品牌下暂无店铺可选" : "请先选择品牌"} disabled={!inputs.brand || (brandStoreEntries.length > 0 && storeOptions.length === 0)} - onValueChange={(value) => onInputsChange({ ...inputs, store: value })} + onValueChange={(value) => + onInputsChange({ + ...inputs, + store: value, + thirdCategory: + value.trim().toLowerCase() === inputs.store.trim().toLowerCase() + ? inputs.thirdCategory + : "", + }) + } + /> + onInputsChange({ ...inputs, thirdCategory: value })} /> 0 || materials.secondary.selectedImageIds.length > 0 ? ( @@ -338,6 +373,9 @@ export function AgentWorkflowPanel({ {inputs.store ? ( 店铺:{inputs.store} ) : null} + {inputs.thirdCategory ? ( + 三级分类:{inputs.thirdCategory} + ) : null} {inputs.priceBand ? ( 价格带:{inputs.priceBand} ) : null} diff --git a/components/agents/AgentWorkflowPanelDark.tsx b/components/agents/AgentWorkflowPanelDark.tsx index be0d4a4dc..bd73ced0b 100644 --- a/components/agents/AgentWorkflowPanelDark.tsx +++ b/components/agents/AgentWorkflowPanelDark.tsx @@ -13,6 +13,7 @@ import { defaultAgentWorkflowUiConfig, getAgentWorkflowBrandStoreEntries, getAgentWorkflowPriceBandOptions, + getAgentWorkflowThirdCategoryOptionsByBrandStore, getAgentWorkflowStoreOptionsByBrand, } from "@/lib/agents/workflow-ui-config"; import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config"; @@ -32,6 +33,7 @@ export interface AgentWorkflowInputs { secondarySku: string; brand: string; store: string; + thirdCategory: string; priceBand: string; } @@ -180,6 +182,15 @@ export function AgentWorkflowPanelDark({ () => getAgentWorkflowStoreOptionsByBrand({ config, brand: inputs.brand }), [config, inputs.brand] ); + const thirdCategoryOptions = useMemo( + () => + getAgentWorkflowThirdCategoryOptionsByBrandStore({ + config, + brand: inputs.brand, + store: inputs.store, + }), + [config, inputs.brand, inputs.store] + ); const priceBandOptions = useMemo( () => getAgentWorkflowPriceBandOptions(config), [config] @@ -273,7 +284,7 @@ export function AgentWorkflowPanelDark({ onReset(); return; } - onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "", priceBand: "" }); + onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "", thirdCategory: "", priceBand: "" }); onMaterialsChange({ primary: createEmptyAgentWorkflowMaterialSelection(), secondary: createEmptyAgentWorkflowMaterialSelection(), @@ -307,7 +318,7 @@ export function AgentWorkflowPanelDark({ 基础配置 @@ -336,7 +351,25 @@ export function AgentWorkflowPanelDark({ searchPlaceholder="输入店铺名筛选" emptyText={inputs.brand ? "当前品牌下暂无店铺可选" : "请先选择品牌"} disabled={!inputs.brand || (brandStoreEntries.length > 0 && storeOptions.length === 0)} - onValueChange={(value) => onInputsChange({ ...inputs, store: value })} + onValueChange={(value) => + onInputsChange({ + ...inputs, + store: value, + thirdCategory: + value.trim().toLowerCase() === inputs.store.trim().toLowerCase() + ? inputs.thirdCategory + : "", + }) + } + /> + onInputsChange({ ...inputs, thirdCategory: value })} /> config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [], @@ -286,6 +288,15 @@ export function AgentWorkflowBrandStorePanel({ () => getAgentWorkflowStoreOptionsByBrand({ config, brand: inputs.brand }), [config, inputs.brand] ); + const thirdCategoryOptions = useMemo( + () => + getAgentWorkflowThirdCategoryOptionsByBrandStore({ + config, + brand: inputs.brand, + store: inputs.store, + }), + [config, inputs.brand, inputs.store] + ); const priceBandOptions = useMemo( () => getAgentWorkflowPriceBandOptions(config), [config] @@ -307,7 +318,7 @@ export function AgentWorkflowBrandStorePanel({ {description}
-
+
@@ -334,7 +349,26 @@ export function AgentWorkflowBrandStorePanel({ emptyText={inputs.brand ? "当前品牌下暂无店铺可选" : "请先选择品牌"} theme={theme} disabled={!inputs.brand || (brandStoreEntries.length > 0 && storeOptions.length === 0)} - onValueChange={(value) => onInputsChange({ ...inputs, store: value })} + onValueChange={(value) => + onInputsChange({ + ...inputs, + store: value, + thirdCategory: + value.trim().toLowerCase() === inputs.store.trim().toLowerCase() + ? inputs.thirdCategory + : "", + }) + } + /> + onInputsChange({ ...inputs, thirdCategory: value })} /> getAgentWorkflowStoreOptionsByBrand({ config, brand: inputs.brand }), [config, inputs.brand] ); + const thirdCategoryOptions = useMemo( + () => + getAgentWorkflowThirdCategoryOptionsByBrandStore({ + config, + brand: inputs.brand, + store: inputs.store, + }), + [config, inputs.brand, inputs.store] + ); const priceBandOptions = useMemo( () => getAgentWorkflowPriceBandOptions(config), [config] @@ -294,7 +305,7 @@ export function AgentWorkflowPanelNew({ onReset(); return; } - onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "", priceBand: "" }); + onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "", thirdCategory: "", priceBand: "" }); onMaterialsChange({ primary: createEmptyAgentWorkflowMaterialSelection(), secondary: createEmptyAgentWorkflowMaterialSelection(), @@ -346,7 +357,7 @@ export function AgentWorkflowPanelNew({ 基础配置 -
+
@@ -371,7 +386,25 @@ export function AgentWorkflowPanelNew({ searchPlaceholder="输入店铺名筛选" emptyText={inputs.brand ? "当前品牌下暂无店铺可选" : "请先选择品牌"} disabled={!inputs.brand || (brandStoreEntries.length > 0 && storeOptions.length === 0)} - onValueChange={(value) => onInputsChange({ ...inputs, store: value })} + onValueChange={(value) => + onInputsChange({ + ...inputs, + store: value, + thirdCategory: + value.trim().toLowerCase() === inputs.store.trim().toLowerCase() + ? inputs.thirdCategory + : "", + }) + } + /> + onInputsChange({ ...inputs, thirdCategory: value })} /> ).secondarySku || "" : "").trim(), brand: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record).brand || "" : "").trim(), store: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record).store || "" : "").trim(), + thirdCategory: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record).thirdCategory || "" : "").trim(), priceBand: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record).priceBand || "" : "").trim(), }, materials: normalizeAgentWorkflowMaterialSelections(raw.materials), diff --git a/lib/agents/workflow-composer.ts b/lib/agents/workflow-composer.ts index 98ff5ae83..e5d2c2659 100644 --- a/lib/agents/workflow-composer.ts +++ b/lib/agents/workflow-composer.ts @@ -20,6 +20,7 @@ export interface AgentWorkflowInputsValue { secondarySku: string; brand: string; store: string; + thirdCategory: string; priceBand: string; } @@ -212,6 +213,9 @@ export function buildAgentWorkflowComposerContext(params: { if (params.inputs.store.trim()) { summaryParts.push(`店铺:${params.inputs.store.trim()}`); } + if (params.inputs.thirdCategory.trim()) { + summaryParts.push(`三级分类:${params.inputs.thirdCategory.trim()}`); + } if (params.inputs.priceBand.trim()) { summaryParts.push(`价格带:${params.inputs.priceBand.trim()}`); } @@ -269,6 +273,9 @@ export function buildAgentWorkflowComposerContext(params: { if (params.inputs.store.trim()) { promptLines.push(`店铺:${params.inputs.store.trim()}`); } + if (params.inputs.thirdCategory.trim()) { + promptLines.push(`三级分类:${params.inputs.thirdCategory.trim()}`); + } if (params.inputs.priceBand.trim()) { promptLines.push(`价格带:${params.inputs.priceBand.trim()}`); } @@ -354,6 +361,7 @@ export function buildAgentWorkflowComposerContext(params: { secondarySku: params.inputs.secondarySku.trim() || undefined, brand: params.inputs.brand.trim() || undefined, store: params.inputs.store.trim() || undefined, + thirdCategory: params.inputs.thirdCategory.trim() || undefined, priceBand: params.inputs.priceBand.trim() || undefined, selections, referenceImages, diff --git a/lib/agents/workflow-ui-config.ts b/lib/agents/workflow-ui-config.ts index bbfe01f84..b881484bb 100644 --- a/lib/agents/workflow-ui-config.ts +++ b/lib/agents/workflow-ui-config.ts @@ -1,6 +1,7 @@ import type { AgentWorkflowBrandStoreEntry, AgentWorkflowOptionUiConfig, + AgentWorkflowStoreEntry, AgentWorkflowStepKey, AgentWorkflowStepUiConfig, AgentWorkflowUiConfig, @@ -139,6 +140,7 @@ export const defaultAgentWorkflowUiConfig: AgentWorkflowUiConfig = { inputOptions: { brands: [], stores: [], + thirdCategories: [], priceBands: [], brandStoreEntries: [], }, @@ -432,6 +434,58 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC const normalizeBrandStoreEntries = (input: unknown) => { if (!Array.isArray(input)) return [] as AgentWorkflowBrandStoreEntry[]; + const normalizeStoreEntries = ( + rawStoreEntries: unknown, + legacyStores: string[] + ): AgentWorkflowStoreEntry[] => { + const mappedEntries: Array = Array.isArray(rawStoreEntries) + ? rawStoreEntries.map((storeItem, storeIndex) => { + if (typeof storeItem === "string") { + const storeName = normalizeText(storeItem, 64); + if (!storeName) return null; + return { + storeName, + thirdCategories: [], + sortOrder: storeIndex, + }; + } + + const storeRecord = + storeItem && typeof storeItem === "object" && !Array.isArray(storeItem) + ? (storeItem as Record) + : {}; + const storeName = normalizeText(storeRecord.storeName, 64); + if (!storeName) return null; + return { + storeName, + thirdCategories: normalizeStringList(storeRecord.thirdCategories).slice(0, 300), + sortOrder: normalizeNumber(storeRecord.sortOrder, storeIndex), + }; + }) + : []; + + const normalizedFromEntries = mappedEntries + .filter((item): item is AgentWorkflowStoreEntry => item !== null) + .sort((left, right) => (left.sortOrder || 0) - (right.sortOrder || 0)) + .map((entry, index) => ({ + ...entry, + thirdCategories: Array.from( + new Set((entry.thirdCategories || []).map((item) => item.trim()).filter(Boolean)) + ).slice(0, 300), + sortOrder: index, + })); + + if (normalizedFromEntries.length > 0) { + return normalizedFromEntries; + } + + return legacyStores.map((storeName, index) => ({ + storeName, + thirdCategories: [], + sortOrder: index, + })); + }; + const mappedEntries: Array = input.map((item, index) => { const record = item && typeof item === "object" && !Array.isArray(item) @@ -439,11 +493,14 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC : {}; const brandName = normalizeText(record.brandName, 64); if (!brandName) return null; - const stores = normalizeStringList(record.stores).slice(0, 300); + const legacyStores = normalizeStringList(record.stores).slice(0, 300); + const storeEntries = normalizeStoreEntries(record.storeEntries, legacyStores); + const stores = storeEntries.map((entry) => entry.storeName); return { brandName, stores, + storeEntries, sortOrder: normalizeNumber(record.sortOrder, index), }; }); @@ -461,6 +518,7 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC ); const normalizedBrands = normalizeStringList(rawInputOptions.brands); const normalizedStores = normalizeStringList(rawInputOptions.stores); + const normalizedThirdCategories = normalizeStringList(rawInputOptions.thirdCategories); const normalizedPriceBands = normalizeStringList(rawInputOptions.priceBands); const fallbackBrandStoreEntries: AgentWorkflowBrandStoreEntry[] = normalizedBrandStoreEntries.length > 0 @@ -468,6 +526,14 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC : normalizedBrands.map((brandName, index) => ({ brandName, stores: normalizedBrands.length === 1 ? normalizedStores : [], + storeEntries: + normalizedBrands.length === 1 + ? normalizedStores.map((storeName, storeIndex) => ({ + storeName, + thirdCategories: [], + sortOrder: storeIndex, + })) + : [], sortOrder: index, })); const defaultStepByKey = new Map( @@ -633,9 +699,25 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC stores: fallbackBrandStoreEntries.length > 0 ? Array.from( - new Set(fallbackBrandStoreEntries.flatMap((entry) => entry.stores)) + new Set( + fallbackBrandStoreEntries.flatMap((entry) => + (entry.storeEntries || []).map((storeEntry) => storeEntry.storeName) + ) + ) ) : normalizedStores, + thirdCategories: + fallbackBrandStoreEntries.length > 0 + ? Array.from( + new Set( + fallbackBrandStoreEntries.flatMap((entry) => + (entry.storeEntries || []).flatMap( + (storeEntry) => storeEntry.thirdCategories || [] + ) + ) + ) + ) + : normalizedThirdCategories, priceBands: normalizedPriceBands, brandStoreEntries: fallbackBrandStoreEntries, }, @@ -660,6 +742,12 @@ export function getAgentWorkflowStoreOptionsByBrand(params: { const matchedEntry = entries.find( (entry) => entry.brandName.trim().toLowerCase() === normalizedBrand ); + const storeOptions = matchedEntry + ? (matchedEntry.storeEntries || []).map((entry) => entry.storeName).filter(Boolean) + : []; + if (storeOptions.length > 0) { + return storeOptions; + } if (matchedEntry && matchedEntry.stores.length > 0) { return matchedEntry.stores; } @@ -667,6 +755,31 @@ export function getAgentWorkflowStoreOptionsByBrand(params: { return params.config?.inputOptions?.stores || defaultAgentWorkflowUiConfig.inputOptions?.stores || []; } +export function getAgentWorkflowThirdCategoryOptionsByBrandStore(params: { + config?: AgentWorkflowUiConfig | null; + brand?: string | null; + store?: string | null; +}) { + const entries = getAgentWorkflowBrandStoreEntries(params.config); + const normalizedBrand = String(params.brand || "").trim().toLowerCase(); + const normalizedStore = String(params.store || "").trim().toLowerCase(); + if (!normalizedBrand || !normalizedStore) { + return []; + } + + const matchedEntry = entries.find( + (entry) => entry.brandName.trim().toLowerCase() === normalizedBrand + ); + const matchedStoreEntry = matchedEntry?.storeEntries?.find( + (storeEntry) => storeEntry.storeName.trim().toLowerCase() === normalizedStore + ); + if (matchedStoreEntry && matchedStoreEntry.thirdCategories.length > 0) { + return matchedStoreEntry.thirdCategories; + } + + return params.config?.inputOptions?.thirdCategories || defaultAgentWorkflowUiConfig.inputOptions?.thirdCategories || []; +} + export function getAgentWorkflowPriceBandOptions(config?: AgentWorkflowUiConfig | null) { return config?.inputOptions?.priceBands || defaultAgentWorkflowUiConfig.inputOptions?.priceBands || []; } @@ -749,6 +862,24 @@ export function getAgentWorkflowBlockedOptionKeys(params: { ); } +function collectAgentWorkflowOptionBranchKeys( + options: AgentWorkflowOptionUiConfig[], + targetKey: string +): string[] { + for (const option of options) { + if (option.key === targetKey) { + return flattenAgentWorkflowOptionTree([option]).map(({ option: currentOption }) => currentOption.key); + } + + const childKeys = collectAgentWorkflowOptionBranchKeys(option.children || [], targetKey); + if (childKeys.length > 0) { + return childKeys; + } + } + + return []; +} + export function getAgentWorkflowBlockedOptionKeysForSelections(params: { config?: AgentWorkflowUiConfig | null; selections: Partial>; @@ -759,7 +890,7 @@ export function getAgentWorkflowBlockedOptionKeysForSelections(params: { params.selections ).map(({ step }) => step); - return Array.from( + const directlyBlockedKeys = Array.from( new Set( visibleSteps.flatMap((step) => getAgentWorkflowBlockedOptionKeys({ @@ -769,6 +900,18 @@ export function getAgentWorkflowBlockedOptionKeysForSelections(params: { ) ) ); + + return Array.from( + new Set( + directlyBlockedKeys.flatMap((blockedKey) => { + const branchKeys = collectAgentWorkflowOptionBranchKeys( + visibleSteps.flatMap((step) => step.options || []), + blockedKey + ); + return branchKeys.length > 0 ? branchKeys : [blockedKey]; + }) + ) + ); } export function isAgentWorkflowStepSelectionComplete(params: { diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 92810b984..8f4efe438 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -617,6 +617,8 @@ export const canvasProject = pgTable("CanvasProject", { secondarySku?: string; brand?: string; store?: string; + thirdCategory?: string; + priceBand?: string; }; materials?: { primary?: { diff --git a/types/agent.ts b/types/agent.ts index 81bed8964..89058cab5 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -105,15 +105,23 @@ export interface AgentWorkflowUiConfig { inputOptions?: { brands: string[]; stores: string[]; + thirdCategories?: string[]; priceBands?: string[]; brandStoreEntries?: AgentWorkflowBrandStoreEntry[]; }; steps: AgentWorkflowStepUiConfig[]; } +export interface AgentWorkflowStoreEntry { + storeName: string; + thirdCategories: string[]; + sortOrder?: number; +} + export interface AgentWorkflowBrandStoreEntry { brandName: string; stores: string[]; + storeEntries?: AgentWorkflowStoreEntry[]; sortOrder?: number; } @@ -249,6 +257,7 @@ export interface AgentWorkflowComposerContext { secondarySku?: string; brand?: string; store?: string; + thirdCategory?: string; priceBand?: string; selections: AgentWorkflowComposerSelection[]; referenceImages: AgentWorkflowReferenceImage[]; @@ -382,6 +391,7 @@ export interface AgentWorkflowPersistedState { secondarySku: string; brand: string; store: string; + thirdCategory: string; priceBand: string; }; materials?: AgentWorkflowMaterialSelections;