From f4d488d1048f670e88074b0a719f8f95dccc4484 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 2 Apr 2026 17:48:41 +0800 Subject: [PATCH] feat: align psd templates with brand category config --- .../agent-workflow-settings-client.tsx | 441 ++++++++++++----- .../psd-templates-settings-client.tsx | 293 ++++++++++- app/api/admin/psd-templates/[id]/route.ts | 5 + app/api/admin/psd-templates/route.ts | 3 + app/api/psd-templates/route.ts | 8 + app/api/site-settings/route.ts | 7 + .../agents/AgentGenerationWorkspaceDarkV2.tsx | 17 +- .../agents/AgentPsdTemplateEditorDialog.tsx | 460 +++++++++--------- .../agents/AgentWorkflowPanelDarkV2.tsx | 36 +- lib/agents/psd-template-config.ts | 7 + lib/agents/workflow-brand-import.ts | 96 ++-- lib/agents/workflow-ui-config.ts | 124 +++++ types/agent.ts | 7 + 13 files changed, 1095 insertions(+), 409 deletions(-) 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 3872ad89e..45c7d8b66 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -33,11 +33,13 @@ import { countAgentWorkflowOptions, countAgentWorkflowOptionTree, countAgentWorkflowSteps, + defaultAgentWorkflowCategoryLabels, defaultAgentWorkflowUiConfig, findAgentWorkflowOptionByKey, findAgentWorkflowStepByKey, flattenAgentWorkflowOptionTree, getEnabledAgentWorkflowOptionTree, + getAgentWorkflowCategoryLabels, normalizeAgentWorkflowOptionOrdering, flattenAgentWorkflowSteps, normalizeAgentWorkflowStepOrdering, @@ -101,6 +103,7 @@ import type { AgentPresetLibrarySection, AgentWorkflowBrandModelConfig, AgentWorkflowBrandStoreEntry, + AgentWorkflowCategoryLabels, AgentWorkflowFourthCategoryEntry, AgentWorkflowGoodsInfoApiConfig, AgentWorkflowMaterialApiConfig, @@ -463,110 +466,255 @@ function createPresetLibrarySectionDraft( function buildWorkflowInputOptionsFromBrandEntries( entries: AgentWorkflowBrandStoreEntry[], - priceBands: string[] = [] + priceBands: string[] = [], + categoryLabels: Partial = defaultAgentWorkflowCategoryLabels ) { - const normalizedEntries = entries.map((entry, index) => ({ - brandName: entry.brandName.trim(), - storeEntries: (() => { - const uniqueStoreEntries = new Map(); + const resolvedCategoryLabels = { + ...defaultAgentWorkflowCategoryLabels, + ...categoryLabels, + }; + type FourthCategoryBuilder = { + categoryName: string; + fifthCategoryMap: Map; + sortOrder: number; + }; - (entry.storeEntries || []).forEach((storeEntry, storeIndex) => { - const storeName = String(storeEntry.storeName || "").trim(); - if (!storeName) { + type ThirdCategoryBuilder = { + categoryName: string; + fourthCategoryMap: Map; + sortOrder: number; + }; + + type StoreBuilder = { + storeName: string; + thirdCategoryMap: Map; + sortOrder: number; + }; + + type BrandBuilder = { + brandName: string; + storeMap: Map; + sortOrder: number; + }; + + const brandMap = new Map(); + + const ensureBrandBuilder = (brandName: string, sortOrder: number) => { + const normalizedBrandName = String(brandName || "").trim(); + if (!normalizedBrandName) { + return null; + } + + const key = normalizedBrandName.toLowerCase(); + const existing = brandMap.get(key); + if (existing) { + return existing; + } + + const nextBuilder: BrandBuilder = { + brandName: normalizedBrandName, + storeMap: new Map(), + sortOrder, + }; + brandMap.set(key, nextBuilder); + return nextBuilder; + }; + + const ensureStoreBuilder = ( + brandBuilder: BrandBuilder, + storeName: string, + sortOrder: number + ) => { + const normalizedStoreName = String(storeName || "").trim(); + if (!normalizedStoreName) { + return null; + } + + const key = normalizedStoreName.toLowerCase(); + const existing = brandBuilder.storeMap.get(key); + if (existing) { + return existing; + } + + const nextBuilder: StoreBuilder = { + storeName: normalizedStoreName, + thirdCategoryMap: new Map(), + sortOrder, + }; + brandBuilder.storeMap.set(key, nextBuilder); + return nextBuilder; + }; + + const ensureThirdCategoryBuilder = ( + storeBuilder: StoreBuilder, + categoryName: string, + sortOrder: number + ) => { + const normalizedCategoryName = String(categoryName || "").trim(); + if (!normalizedCategoryName) { + return null; + } + + const key = normalizedCategoryName.toLowerCase(); + const existing = storeBuilder.thirdCategoryMap.get(key); + if (existing) { + return existing; + } + + const nextBuilder: ThirdCategoryBuilder = { + categoryName: normalizedCategoryName, + fourthCategoryMap: new Map(), + sortOrder, + }; + storeBuilder.thirdCategoryMap.set(key, nextBuilder); + return nextBuilder; + }; + + const ensureFourthCategoryBuilder = ( + thirdCategoryBuilder: ThirdCategoryBuilder, + categoryName: string, + sortOrder: number + ) => { + const normalizedCategoryName = String(categoryName || "").trim(); + if (!normalizedCategoryName) { + return null; + } + + const key = normalizedCategoryName.toLowerCase(); + const existing = thirdCategoryBuilder.fourthCategoryMap.get(key); + if (existing) { + return existing; + } + + const nextBuilder: FourthCategoryBuilder = { + categoryName: normalizedCategoryName, + fifthCategoryMap: new Map(), + sortOrder, + }; + thirdCategoryBuilder.fourthCategoryMap.set(key, nextBuilder); + return nextBuilder; + }; + + entries.forEach((entry, brandIndex) => { + const brandBuilder = ensureBrandBuilder(entry.brandName, brandIndex); + if (!brandBuilder) { + return; + } + + const legacyStoreNames = Array.from( + new Set( + (entry.stores || []) + .map((storeName) => String(storeName || "").trim()) + .filter(Boolean) + ) + ); + legacyStoreNames.forEach((storeName, storeIndex) => { + ensureStoreBuilder(brandBuilder, storeName, storeIndex); + }); + + (entry.storeEntries || []).forEach((storeEntry, storeIndex) => { + const storeBuilder = ensureStoreBuilder(brandBuilder, storeEntry.storeName, storeIndex); + if (!storeBuilder) { + return; + } + + const legacyThirdCategoryNames = Array.from( + new Set( + (storeEntry.thirdCategories || []) + .map((thirdCategory) => String(thirdCategory || "").trim()) + .filter(Boolean) + ) + ); + legacyThirdCategoryNames.forEach((thirdCategory, thirdIndex) => { + ensureThirdCategoryBuilder(storeBuilder, thirdCategory, thirdIndex); + }); + + (storeEntry.thirdCategoryEntries || []).forEach((thirdEntry, thirdIndex) => { + const thirdCategoryBuilder = ensureThirdCategoryBuilder( + storeBuilder, + thirdEntry.categoryName, + thirdIndex + ); + if (!thirdCategoryBuilder) { return; } - const uniqueThirdCategoryEntries = new Map(); + const legacyFourthCategoryNames = Array.from( + new Set( + (thirdEntry.fourthCategories || []) + .map((fourthCategory) => String(fourthCategory || "").trim()) + .filter(Boolean) + ) + ); + legacyFourthCategoryNames.forEach((fourthCategory, fourthIndex) => { + ensureFourthCategoryBuilder(thirdCategoryBuilder, fourthCategory, fourthIndex); + }); - (storeEntry.thirdCategoryEntries || []).forEach((thirdEntry, thirdIndex) => { - const categoryName = String(thirdEntry.categoryName || "").trim(); - if (!categoryName) { + (thirdEntry.fourthCategoryEntries || []).forEach((fourthEntry, fourthIndex) => { + const fourthCategoryBuilder = ensureFourthCategoryBuilder( + thirdCategoryBuilder, + fourthEntry.categoryName, + fourthIndex + ); + if (!fourthCategoryBuilder) { return; } - const uniqueFourthCategoryEntries = new Map(); - (thirdEntry.fourthCategoryEntries || []).forEach((fourthEntry, fourthIndex) => { - const fourthCategoryName = String(fourthEntry.categoryName || "").trim(); - if (!fourthCategoryName) { + (fourthEntry.fifthCategories || []).forEach((fifthCategory) => { + const normalizedFifthCategory = String(fifthCategory || "").trim(); + if (!normalizedFifthCategory) { return; } - uniqueFourthCategoryEntries.set(fourthCategoryName.toLowerCase(), { - categoryName: fourthCategoryName, - fifthCategories: Array.from( - new Set( - (fourthEntry.fifthCategories || []) - .map((fifthCategory) => fifthCategory.trim()) - .filter(Boolean) - ) - ), - sortOrder: fourthIndex, - }); + const key = normalizedFifthCategory.toLowerCase(); + if (!fourthCategoryBuilder.fifthCategoryMap.has(key)) { + fourthCategoryBuilder.fifthCategoryMap.set(key, normalizedFifthCategory); + } }); - - const fourthCategoryEntries = Array.from(uniqueFourthCategoryEntries.values()).map( - (fourthEntry, fourthIndex) => ({ - ...fourthEntry, - sortOrder: fourthIndex, - }) - ); - - uniqueThirdCategoryEntries.set(categoryName.toLowerCase(), { - categoryName, - fourthCategories: - fourthCategoryEntries.length > 0 - ? fourthCategoryEntries.map((fourthEntry) => fourthEntry.categoryName) - : Array.from( - new Set( - (thirdEntry.fourthCategories || []) - .map((fourthCategory) => fourthCategory.trim()) - .filter(Boolean) - ) - ), - fourthCategoryEntries, - sortOrder: thirdIndex, - }); - }); - - const thirdCategoryEntries = Array.from(uniqueThirdCategoryEntries.values()).map((thirdEntry, thirdIndex) => ({ - ...thirdEntry, - fourthCategories: - (thirdEntry.fourthCategoryEntries || []).length > 0 - ? (thirdEntry.fourthCategoryEntries || []).map((fourthEntry) => fourthEntry.categoryName) - : Array.from( - new Set( - (thirdEntry.fourthCategories || []) - .map((fourthCategory) => fourthCategory.trim()) - .filter(Boolean) - ) - ), - sortOrder: thirdIndex, - })); - - uniqueStoreEntries.set(storeName.toLowerCase(), { - storeName, - thirdCategories: - thirdCategoryEntries.length > 0 - ? thirdCategoryEntries.map((thirdEntry) => thirdEntry.categoryName) - : Array.from( - new Set( - (storeEntry.thirdCategories || []) - .map((thirdCategory) => thirdCategory.trim()) - .filter(Boolean) - ) - ), - thirdCategoryEntries, - sortOrder: storeIndex, }); }); + }); + }); - return Array.from(uniqueStoreEntries.values()).map((storeEntry, storeIndex) => ({ - ...storeEntry, - sortOrder: storeIndex, - })); - })(), - sortOrder: index, - })); + const normalizedEntries = Array.from(brandMap.values()) + .sort((left, right) => left.sortOrder - right.sortOrder) + .map((brandEntry, brandIndex) => { + const storeEntries = Array.from(brandEntry.storeMap.values()) + .sort((left, right) => left.sortOrder - right.sortOrder) + .map((storeEntry, storeIndex) => { + const thirdCategoryEntries = Array.from(storeEntry.thirdCategoryMap.values()) + .sort((left, right) => left.sortOrder - right.sortOrder) + .map((thirdEntry, thirdIndex) => { + const fourthCategoryEntries = Array.from(thirdEntry.fourthCategoryMap.values()) + .sort((left, right) => left.sortOrder - right.sortOrder) + .map((fourthEntry, fourthIndex) => ({ + categoryName: fourthEntry.categoryName, + fifthCategories: Array.from(fourthEntry.fifthCategoryMap.values()), + sortOrder: fourthIndex, + })); + + return { + categoryName: thirdEntry.categoryName, + fourthCategories: fourthCategoryEntries.map((fourthEntry) => fourthEntry.categoryName), + fourthCategoryEntries, + sortOrder: thirdIndex, + }; + }); + + return { + storeName: storeEntry.storeName, + thirdCategories: thirdCategoryEntries.map((thirdEntry) => thirdEntry.categoryName), + thirdCategoryEntries, + sortOrder: storeIndex, + }; + }); + + return { + brandName: brandEntry.brandName, + storeEntries, + sortOrder: brandIndex, + }; + }); const normalizedPriceBands = Array.from( new Set( priceBands @@ -619,6 +767,7 @@ function buildWorkflowInputOptionsFromBrandEntries( ) ) ), + categoryLabels: resolvedCategoryLabels, priceBands: normalizedPriceBands, }; } @@ -760,6 +909,13 @@ export function AgentWorkflowSettingsClient({ [], [config.inputOptions?.priceBands] ); + const categoryLabels = useMemo( + () => getAgentWorkflowCategoryLabels(config), + [config] + ); + const thirdCategoryLabel = categoryLabels.thirdCategory; + const fourthCategoryLabel = categoryLabels.fourthCategory; + const fifthCategoryLabel = categoryLabels.fifthCategory; const configuredBrands = useMemo( () => brandStoreEntries.map((entry) => entry.brandName), [brandStoreEntries] @@ -825,7 +981,8 @@ export function AgentWorkflowSettingsClient({ ...prev, inputOptions: buildWorkflowInputOptionsFromBrandEntries( nextEntries, - prev.inputOptions?.priceBands || [] + prev.inputOptions?.priceBands || [], + prev.inputOptions?.categoryLabels || categoryLabels ), })); }; @@ -835,6 +992,24 @@ export function AgentWorkflowSettingsClient({ .replace(/\s+/g, " ") .trim(); + const handleCategoryLabelChange = ( + key: keyof AgentWorkflowCategoryLabels, + value: string + ) => { + const nextValue = normalizeInputOptionValue(value).slice(0, 24); + setConfig((prev) => ({ + ...prev, + inputOptions: buildWorkflowInputOptionsFromBrandEntries( + prev.inputOptions?.brandStoreEntries || [], + prev.inputOptions?.priceBands || [], + { + ...(prev.inputOptions?.categoryLabels || categoryLabels), + [key]: nextValue || defaultAgentWorkflowCategoryLabels[key], + } + ), + })); + }; + const handleAddBrandEntry = () => { const nextValue = normalizeInputOptionValue(brandDraft); if (!nextValue) { @@ -988,7 +1163,7 @@ export function AgentWorkflowSettingsClient({ const draftKey = createStoreDraftKey(brandName, storeName); const nextValue = normalizeInputOptionValue(thirdCategoryDrafts[draftKey] || ""); if (!nextValue) { - toast.error("请输入三级分类名称"); + toast.error(`请输入${thirdCategoryLabel}名称`); return; } @@ -1004,7 +1179,7 @@ export function AgentWorkflowSettingsClient({ (thirdCategory) => thirdCategory.toLowerCase() === nextValue.toLowerCase() ) ) { - toast.error("该三级分类已存在"); + toast.error(`该${thirdCategoryLabel}已存在`); return; } @@ -1096,7 +1271,7 @@ export function AgentWorkflowSettingsClient({ const draftKey = createThirdCategoryDraftKey(brandName, storeName, thirdCategory); const nextValue = normalizeInputOptionValue(fourthCategoryDrafts[draftKey] || ""); if (!nextValue) { - toast.error("请输入四级分类名称"); + toast.error(`请输入${fourthCategoryLabel}名称`); return; } @@ -1115,7 +1290,7 @@ export function AgentWorkflowSettingsClient({ (fourthCategory) => fourthCategory.toLowerCase() === nextValue.toLowerCase() ) ) { - toast.error("该四级分类已存在"); + toast.error(`该${fourthCategoryLabel}已存在`); return; } @@ -1218,7 +1393,7 @@ export function AgentWorkflowSettingsClient({ ); const nextValue = normalizeInputOptionValue(fifthCategoryDrafts[draftKey] || ""); if (!nextValue) { - toast.error("请输入五级分类名称"); + toast.error(`请输入${fifthCategoryLabel}名称`); return; } @@ -1237,7 +1412,7 @@ export function AgentWorkflowSettingsClient({ (fifthCategory) => fifthCategory.toLowerCase() === nextValue.toLowerCase() ) ) { - toast.error("该五级分类已存在"); + toast.error(`该${fifthCategoryLabel}已存在`); return; } @@ -1336,7 +1511,8 @@ export function AgentWorkflowSettingsClient({ ...prev, inputOptions: buildWorkflowInputOptionsFromBrandEntries( prev.inputOptions?.brandStoreEntries || [], - [...(prev.inputOptions?.priceBands || []), nextValue] + [...(prev.inputOptions?.priceBands || []), nextValue], + prev.inputOptions?.categoryLabels || categoryLabels ), })); setPriceBandDraft(""); @@ -1347,7 +1523,8 @@ export function AgentWorkflowSettingsClient({ ...prev, inputOptions: buildWorkflowInputOptionsFromBrandEntries( prev.inputOptions?.brandStoreEntries || [], - (prev.inputOptions?.priceBands || []).filter((item) => item !== priceBand) + (prev.inputOptions?.priceBands || []).filter((item) => item !== priceBand), + prev.inputOptions?.categoryLabels || categoryLabels ), })); }; @@ -1811,7 +1988,7 @@ export function AgentWorkflowSettingsClient({ const handleDownloadBrandTemplate = async () => { try { - await downloadAgentWorkflowBrandImportTemplate(); + await downloadAgentWorkflowBrandImportTemplate(categoryLabels); toast.success("品牌导入模板已开始下载"); } catch (error) { toast.error(error instanceof Error ? error.message : "下载模板失败"); @@ -1827,7 +2004,7 @@ export function AgentWorkflowSettingsClient({ try { setIsImportingBrandSheet(true); - const { entries, rowCount } = await parseAgentWorkflowBrandImportFile(file); + const { entries, rowCount } = await parseAgentWorkflowBrandImportFile(file, categoryLabels); updateInputOptions([...brandStoreEntries, ...entries]); toast.success(`已从表格导入 ${rowCount} 行,品牌树已自动合并去重`); } catch (error) { @@ -3193,7 +3370,7 @@ export function AgentWorkflowSettingsClient({ - +
@@ -3266,7 +3443,7 @@ export function AgentWorkflowSettingsClient({ - +
@@ -3275,7 +3452,7 @@ export function AgentWorkflowSettingsClient({
基础下拉选项 - 配置品牌、店铺到五级分类的层级关系,以及第一页可选的价格带区间 + 配置品牌、店铺与分类层级关系,以及第一页可选的价格带区间
@@ -3285,7 +3462,7 @@ export function AgentWorkflowSettingsClient({

- 支持一键下载模板并导入品牌、店铺、三级、四级、五级分类表格 + 支持一键下载模板并导入品牌、店铺、{thirdCategoryLabel}、{fourthCategoryLabel}、{fifthCategoryLabel}表格

@@ -3324,6 +3501,32 @@ export function AgentWorkflowSettingsClient({ className="hidden" onChange={handleBrandSheetImport} /> +
+
+ + handleCategoryLabelChange("thirdCategory", e.target.value)} + placeholder="例如:系列分类" + /> +
+
+ + handleCategoryLabelChange("fourthCategory", e.target.value)} + placeholder="例如:产品类型" + /> +
+
+ + handleCategoryLabelChange("fifthCategory", e.target.value)} + placeholder="例如:细分类目" + /> +
+
- {(storeEntry.thirdCategoryEntries || []).length} 个三级分类 + {(storeEntry.thirdCategoryEntries || []).length} 个{thirdCategoryLabel}
@@ -3451,7 +3654,7 @@ export function AgentWorkflowSettingsClient({ ); } }} - placeholder={`给店铺“${storeEntry.storeName}”添加三级分类`} + placeholder={`给店铺“${storeEntry.storeName}”添加${thirdCategoryLabel}`} /> @@ -3496,13 +3699,13 @@ export function AgentWorkflowSettingsClient({ ) } className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive" - aria-label={`删除三级分类 ${thirdEntry.categoryName}`} + aria-label={`删除${thirdCategoryLabel} ${thirdEntry.categoryName}`} > - {(thirdEntry.fourthCategoryEntries || []).length} 个四级分类 + {(thirdEntry.fourthCategoryEntries || []).length} 个{fourthCategoryLabel} @@ -3527,7 +3730,7 @@ export function AgentWorkflowSettingsClient({ ); } }} - placeholder={`给三级分类“${thirdEntry.categoryName}”添加四级分类`} + placeholder={`给${thirdCategoryLabel}“${thirdEntry.categoryName}”添加${fourthCategoryLabel}`} /> @@ -3576,13 +3779,13 @@ export function AgentWorkflowSettingsClient({ ) } className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive" - aria-label={`删除四级分类 ${fourthEntry.categoryName}`} + aria-label={`删除${fourthCategoryLabel} ${fourthEntry.categoryName}`} > - {(fourthEntry.fifthCategories || []).length} 个五级分类 + {(fourthEntry.fifthCategories || []).length} 个{fifthCategoryLabel} @@ -3608,7 +3811,7 @@ export function AgentWorkflowSettingsClient({ ); } }} - placeholder={`给四级分类“${fourthEntry.categoryName}”添加五级分类`} + placeholder={`给${fourthCategoryLabel}“${fourthEntry.categoryName}”添加${fifthCategoryLabel}`} /> @@ -3649,7 +3852,7 @@ export function AgentWorkflowSettingsClient({ ) } className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive" - aria-label={`删除五级分类 ${fifthCategory}`} + aria-label={`删除${fifthCategoryLabel} ${fifthCategory}`} > @@ -3659,7 +3862,7 @@ export function AgentWorkflowSettingsClient({ ) : (
- 这个四级分类下还没有添加任何五级分类。 + 这个{fourthCategoryLabel}下还没有添加任何{fifthCategoryLabel}。
)} @@ -3670,7 +3873,7 @@ export function AgentWorkflowSettingsClient({ ) : (
- 这个三级分类下还没有添加任何四级分类。 + 这个{thirdCategoryLabel}下还没有添加任何{fourthCategoryLabel}。
)} @@ -3680,7 +3883,7 @@ export function AgentWorkflowSettingsClient({ ) : (
- 这个店铺下还没有添加任何三级分类。 + 这个店铺下还没有添加任何{thirdCategoryLabel}。
)} @@ -3699,7 +3902,7 @@ export function AgentWorkflowSettingsClient({ ) : (
- 还没有添加品牌。先添加品牌,再在每个品牌下继续添加店铺以及三级到五级分类。 + 还没有添加品牌。先添加品牌,再在每个品牌下继续添加店铺以及{thirdCategoryLabel}到{fifthCategoryLabel}。
)} diff --git a/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx b/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx index f5eb94279..2d810780c 100644 --- a/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx @@ -25,21 +25,35 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Progress } from "@/components/ui/progress"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; +import { + getAgentWorkflowCategoryLabels, + getAgentWorkflowCategoryOptionsByBrand, +} from "@/lib/agents/workflow-ui-config"; import type { AgentPsdFont, AgentPsdFontListResponse, AgentPsdFontMutationResponse, } from "@/lib/agents/psd-font-config"; import type { + AgentPsdTemplateMode, AgentPsdTemplate, AgentPsdTemplateLayerSlot, AgentPsdTemplateListResponse, AgentPsdTemplateMutationResponse, } from "@/lib/agents/psd-template-config"; +import type { AgentWorkflowUiConfig } from "@/types/agent"; const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json()); @@ -49,6 +63,28 @@ const SYSTEM_FONT_OPTIONS = [ { id: "__system_mono", label: "系统等宽", family: "monospace" }, ]; +const EMPTY_CATEGORY_SELECT_VALUE = "__none__"; +const PSD_TEMPLATE_MODE_OPTIONS: Array<{ + value: AgentPsdTemplateMode; + label: string; + description: string; +}> = [ + { + value: "main", + label: "主图模式", + description: "用于主图结果区的 PSD 套版模板。", + }, + { + value: "sku", + label: "SKU图模式", + description: "用于 SKU 结果区的 PSD 套版模板。", + }, +]; + +function getPsdTemplateModeLabel(mode?: AgentPsdTemplateMode | null) { + return mode === "sku" ? "SKU图模式" : "主图模式"; +} + function cloneTemplate(template: AgentPsdTemplate | null) { return template ? (JSON.parse(JSON.stringify(template)) as AgentPsdTemplate) : null; } @@ -130,11 +166,7 @@ export function PsdTemplatesSettingsClient({ }) { const { data, mutate } = useSWR("/api/admin/psd-templates", fetcher); const { data: siteSettingsData } = useSWR<{ - agentWorkflowUiConfig?: { - inputOptions?: { - brands?: string[]; - }; - }; + agentWorkflowUiConfig?: AgentWorkflowUiConfig; }>("/api/site-settings", fetcher); const { data: fontsData, mutate: mutateFonts } = useSWR( "/api/admin/psd-fonts", @@ -163,6 +195,7 @@ export function PsdTemplatesSettingsClient({ const [layerKeyword, setLayerKeyword] = useState(""); const [uploadName, setUploadName] = useState(""); const [uploadBrandName, setUploadBrandName] = useState(""); + const [uploadMode, setUploadMode] = useState("main"); const [uploadSecondaryCategory, setUploadSecondaryCategory] = useState(""); const [uploadDescription, setUploadDescription] = useState(""); const [uploadFile, setUploadFile] = useState(null); @@ -184,6 +217,56 @@ export function PsdTemplatesSettingsClient({ () => templates.find((template) => template.id === selectedTemplateId) || null, [selectedTemplateId, templates] ); + const workflowConfig = siteSettingsData?.agentWorkflowUiConfig || null; + const workflowCategoryLabels = useMemo( + () => getAgentWorkflowCategoryLabels(workflowConfig), + [workflowConfig] + ); + const categoryLevelOptions = useMemo( + () => [ + { + key: "third", + label: workflowCategoryLabels.thirdCategory, + }, + { + key: "fourth", + label: workflowCategoryLabels.fourthCategory, + }, + { + key: "fifth", + label: workflowCategoryLabels.fifthCategory, + }, + ], + [workflowCategoryLabels] + ); + const uploadBrandCategoryOptions = useMemo(() => { + const options = getAgentWorkflowCategoryOptionsByBrand({ + config: workflowConfig, + brand: uploadBrandName, + }); + const currentValue = uploadSecondaryCategory.trim(); + if ( + currentValue && + !options.some((option) => option.value.toLowerCase() === currentValue.toLowerCase()) + ) { + return [{ value: currentValue, level: "custom" as const, levelLabel: "当前值" }, ...options]; + } + return options; + }, [workflowConfig, uploadBrandName, uploadSecondaryCategory]); + const draftBrandCategoryOptions = useMemo(() => { + const options = getAgentWorkflowCategoryOptionsByBrand({ + config: workflowConfig, + brand: draftTemplate?.brandName, + }); + const currentValue = String(draftTemplate?.secondaryCategory || "").trim(); + if ( + currentValue && + !options.some((option) => option.value.toLowerCase() === currentValue.toLowerCase()) + ) { + return [{ value: currentValue, level: "custom" as const, levelLabel: "当前值" }, ...options]; + } + return options; + }, [workflowConfig, draftTemplate?.brandName, draftTemplate?.secondaryCategory]); const fontOptions = useMemo( () => [ @@ -306,6 +389,7 @@ export function PsdTemplatesSettingsClient({ const formData = new FormData(); formData.set("name", uploadName.trim() || uploadFile.name.replace(/\.[^.]+$/, "")); formData.set("brandName", uploadBrandName.trim()); + formData.set("mode", uploadMode); formData.set("secondaryCategory", uploadSecondaryCategory.trim()); formData.set("description", uploadDescription.trim()); formData.set("status", "disabled"); @@ -475,6 +559,7 @@ export function PsdTemplatesSettingsClient({ body: JSON.stringify({ name: draftTemplate.name, brandName: draftTemplate.brandName, + mode: draftTemplate.mode, secondaryCategory: draftTemplate.secondaryCategory || "", description: draftTemplate.description || "", status: draftTemplate.status, @@ -736,15 +821,85 @@ export function PsdTemplatesSettingsClient({ )}
- - setUploadSecondaryCategory(event.target.value)} - placeholder="例如:凳子类 / 升降桌类" - /> + +

- 开启前台模板二级分类后,会按这里的分类分组展示模板。 + {PSD_TEMPLATE_MODE_OPTIONS.find((option) => option.value === uploadMode)?.description} +

+
+
+ + {uploadBrandCategoryOptions.length > 0 ? ( + + ) : ( + setUploadSecondaryCategory(event.target.value)} + placeholder="例如:凳子类 / 升降桌类" + /> + )} +

+ 会优先读取当前品牌下已配置的分类层级作为选项;开启前台模板二级分类后,会按这里的分类分组展示模板。

@@ -940,6 +1095,9 @@ export function PsdTemplatesSettingsClient({
品牌:{template.brandName}
+
+ 模式:{getPsdTemplateModeLabel(template.mode)} +
{template.secondaryCategory ? (
二级分类:{template.secondaryCategory} @@ -1175,19 +1333,109 @@ export function PsdTemplatesSettingsClient({ )}
- - + + +
+
+ + {draftBrandCategoryOptions.length > 0 ? ( + + ) : ( + + setDraftTemplate((current) => + current + ? { ...current, secondaryCategory: event.target.value } + : current + ) + } + placeholder="例如:凳子类 / 升降桌类" + /> + )}
@@ -1240,6 +1488,7 @@ export function PsdTemplatesSettingsClient({
品牌:{draftTemplate.brandName} + 模式:{getPsdTemplateModeLabel(draftTemplate.mode)} {draftTemplate.secondaryCategory ? ( 分类:{draftTemplate.secondaryCategory} ) : null} diff --git a/app/api/admin/psd-templates/[id]/route.ts b/app/api/admin/psd-templates/[id]/route.ts index 8b8673aa9..1673e063c 100644 --- a/app/api/admin/psd-templates/[id]/route.ts +++ b/app/api/admin/psd-templates/[id]/route.ts @@ -8,6 +8,7 @@ import { AGENT_PSD_TEMPLATE_ASSET_VERSION, getEditableAgentPsdTemplateLayers, isAgentPsdTextLayerSlot, + normalizeAgentPsdTemplateMode, normalizeAgentPsdTemplateStatus, parseAgentPsdTemplateLayers, type AgentPsdTemplateMutationResponse, @@ -28,6 +29,7 @@ export const runtime = "nodejs"; const updateTemplateSchema = z.object({ name: z.string().trim().min(1).max(80), brandName: z.string().trim().min(1).max(64), + mode: z.string().optional().nullable(), secondaryCategory: z.string().trim().max(64).optional().nullable(), description: z.string().optional().nullable(), status: z.string().optional(), @@ -105,6 +107,7 @@ export async function PUT( } const nextStatus = normalizeAgentPsdTemplateStatus(body.status); + const nextMode = normalizeAgentPsdTemplateMode(body.mode); let nextLayers = parseAgentPsdTemplateLayers(body.layers); if (nextLayers.length === 0) { nextLayers = currentTemplate.layers; @@ -119,6 +122,7 @@ export async function PUT( ...currentTemplate, name: body.name.trim(), brandName: body.brandName.trim(), + mode: nextMode, secondaryCategory: String(body.secondaryCategory || "").trim() || undefined, description: String(body.description || "").trim() || undefined, status: nextStatus, @@ -208,6 +212,7 @@ export async function PUT( ...currentTemplate, name: body.name.trim(), brandName: body.brandName.trim(), + mode: nextMode, secondaryCategory: String(body.secondaryCategory || "").trim() || undefined, description: String(body.description || "").trim() || undefined, status: nextStatus, diff --git a/app/api/admin/psd-templates/route.ts b/app/api/admin/psd-templates/route.ts index a7519bbbf..89257be19 100644 --- a/app/api/admin/psd-templates/route.ts +++ b/app/api/admin/psd-templates/route.ts @@ -6,6 +6,7 @@ import { uploadFile } from "@/lib/storage/service"; import { AGENT_PSD_TEMPLATES_KEY, AGENT_PSD_TEMPLATE_ASSET_VERSION, + normalizeAgentPsdTemplateMode, normalizeAgentPsdTemplateStatus, upsertAgentPsdTemplate, type AgentPsdTemplate, @@ -112,6 +113,7 @@ export async function POST(request: NextRequest) { String(formData.get("name") || "").trim() || (file instanceof File ? getDefaultTemplateName(file.name) : ""); const brandName = String(formData.get("brandName") || "").trim(); + const mode = normalizeAgentPsdTemplateMode(formData.get("mode")); const secondaryCategory = String(formData.get("secondaryCategory") || "").trim(); const description = String(formData.get("description") || "").trim(); const status = normalizeAgentPsdTemplateStatus(formData.get("status")); @@ -169,6 +171,7 @@ export async function POST(request: NextRequest) { id: templateId, name, brandName, + mode, secondaryCategory: secondaryCategory || undefined, description: description || undefined, status, diff --git a/app/api/psd-templates/route.ts b/app/api/psd-templates/route.ts index f0013e3f9..fb0596a34 100644 --- a/app/api/psd-templates/route.ts +++ b/app/api/psd-templates/route.ts @@ -9,6 +9,7 @@ import { getEditableAgentPsdTemplateLayers, isAgentPsdTextLayerSlot, isAgentPsdTemplateReady, + normalizeAgentPsdTemplateMode, type AgentPsdTemplateListResponse, } from "@/lib/agents/psd-template-config"; import { @@ -47,6 +48,11 @@ export async function GET(request: NextRequest) { try { const requestedBrand = String(request.nextUrl.searchParams.get("brand") || "").trim(); + const requestedModeRaw = String(request.nextUrl.searchParams.get("mode") || "").trim(); + const requestedMode = + requestedModeRaw === "main" || requestedModeRaw === "sku" + ? normalizeAgentPsdTemplateMode(requestedModeRaw) + : null; const [allTemplates, textColorSetting, secondaryCategorySetting] = await Promise.all([ readAgentPsdTemplates(), db @@ -74,6 +80,7 @@ export async function GET(request: NextRequest) { template.status !== "active" || !isAgentPsdTemplateReady(template) || (requestedBrand ? template.brandName !== requestedBrand : false) || + (requestedMode ? template.mode !== requestedMode : false) || getEditableAgentPsdTemplateLayers(template).length === 0 ) { continue; @@ -111,6 +118,7 @@ export async function GET(request: NextRequest) { .filter((template) => requestedBrand ? template.brandName === requestedBrand : true ) + .filter((template) => (requestedMode ? template.mode === requestedMode : true)) .filter((template) => getEditableAgentPsdTemplateLayers(template).length > 0) .map(async (template) => { const resolvedTemplate = await resolveAgentPsdTemplateTextStyles(template).catch( diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 07f70da49..121d6638d 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -730,6 +730,13 @@ 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(), + categoryLabels: z + .object({ + thirdCategory: z.string().trim().min(1).max(24).optional(), + fourthCategory: z.string().trim().min(1).max(24).optional(), + fifthCategory: z.string().trim().min(1).max(24).optional(), + }) + .optional(), thirdCategories: z.array(z.string().trim().min(1).max(64)).max(300).optional(), fourthCategories: z.array(z.string().trim().min(1).max(64)).max(300).optional(), fifthCategories: z.array(z.string().trim().min(1).max(64)).max(300).optional(), diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 1ebd99daa..396bd7024 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -84,6 +84,7 @@ import { normalizeAgentPresetModeConfig, } from "@/lib/agents/preset-mode-config"; import { buildAgentSceneImageRolePrompt } from "@/lib/agents/scene-image-role-prompt"; +import type { AgentPsdTemplateMode } from "@/lib/agents/psd-template-config"; import type { Attachment, ChatMessage } from "@/lib/types"; import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils"; import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark"; @@ -199,6 +200,10 @@ function normalizeChatMediaType(value?: string | null): string { return "image/jpeg"; } +function getPsdTemplateModeByResultMode(modeType: AgentGenerationResultModeType): AgentPsdTemplateMode { + return modeType === "sku_replacement" ? "sku" : "main"; +} + function sanitizeChatAttachment( attachment?: PickedAttachment | null, ): PickedAttachment | null { @@ -1038,6 +1043,7 @@ export function AgentGenerationWorkspaceDarkV2({ const [fullscreenPreview, setFullscreenPreview] = useState(null); const [sceneResizeEditorTargetUrl, setSceneResizeEditorTargetUrl] = useState(null); const [psdTemplateDialogSourceUrl, setPsdTemplateDialogSourceUrl] = useState(null); + const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState("main"); const [batchDownloadState, setBatchDownloadState] = useState(null); const [detailReplacementSearchState, setDetailReplacementSearchState] = useState<{ isLoading: boolean; @@ -5837,6 +5843,9 @@ export function AgentGenerationWorkspaceDarkV2({ event.stopPropagation(); setSelectedResultUrl(card.url!); setPsdTemplateDialogSourceUrl(card.url!); + setPsdTemplateDialogMode( + getPsdTemplateModeByResultMode(activeResultModeType) + ); }} className="absolute left-14 top-3 flex items-center gap-1 rounded-full border border-white/10 bg-black/60 px-2.5 py-1 text-[11px] text-white transition-colors hover:border-white/20" aria-label="套用 PSD 模板" @@ -5856,6 +5865,9 @@ export function AgentGenerationWorkspaceDarkV2({ event.stopPropagation(); setSelectedResultUrl(card.url!); setPsdTemplateDialogSourceUrl(card.url!); + setPsdTemplateDialogMode( + getPsdTemplateModeByResultMode(activeResultModeType) + ); }} className="absolute left-3 top-3 flex items-center gap-1 rounded-full border border-white/10 bg-black/60 px-2.5 py-1 text-[11px] text-white transition-colors hover:border-white/20" aria-label="套用 PSD 模板" @@ -5977,13 +5989,14 @@ export function AgentGenerationWorkspaceDarkV2({ ) : null} { if (!open) { setPsdTemplateDialogSourceUrl(null); + setPsdTemplateDialogMode("main"); } }} > @@ -6010,10 +6023,12 @@ export function AgentGenerationWorkspaceDarkV2({ onOpenChange={(open) => { if (!open) { setPsdTemplateDialogSourceUrl(null); + setPsdTemplateDialogMode("main"); } }} sourceImageUrl={psdTemplateDialogSourceUrl} brandName={workflowContext?.brand || null} + templateMode={psdTemplateDialogMode} /> diff --git a/components/agents/AgentPsdTemplateEditorDialog.tsx b/components/agents/AgentPsdTemplateEditorDialog.tsx index f42c4bc49..3ca634253 100644 --- a/components/agents/AgentPsdTemplateEditorDialog.tsx +++ b/components/agents/AgentPsdTemplateEditorDialog.tsx @@ -8,7 +8,6 @@ import { Image as ImageIcon, Loader2, Move, - MousePointerClick, RefreshCcw, Sparkles, Type, @@ -51,6 +50,7 @@ import { isAgentPsdImageLayerSlot, isAgentPsdTextLayerSlot, parseAgentPsdTemplates, + type AgentPsdTemplateMode, type AgentPsdTemplate, type AgentPsdTemplateImageLayerSlot, type AgentPsdTemplateLayerSlot, @@ -133,14 +133,14 @@ function renderTemplateSelectContent( className={ compact ? "grid min-w-0 flex-1 grid-cols-[48px_minmax(0,1fr)] items-center gap-3" - : "grid min-w-0 flex-1 grid-cols-[56px_minmax(0,1fr)] items-center gap-3" + : "grid min-w-0 flex-1 grid-cols-[46px_minmax(0,1fr)] items-center gap-2.5" } >
-
+
{template.name}
{metaLabel ? ( -
+
{metaLabel}
) : null} @@ -465,17 +465,30 @@ export function AgentPsdTemplateEditorDialog({ onOpenChange, sourceImageUrl, brandName, + templateMode, }: { open: boolean; onOpenChange: (open: boolean) => void; sourceImageUrl: string | null; brandName?: string | null; + templateMode?: AgentPsdTemplateMode | null; }) { const normalizedBrandName = String(brandName || "").trim(); + const normalizedTemplateMode = templateMode === "sku" ? "sku" : templateMode === "main" ? "main" : ""; + const templateListRequestUrl = useMemo(() => { + if (!open || !normalizedBrandName) { + return null; + } + + const searchParams = new URLSearchParams(); + searchParams.set("brand", normalizedBrandName); + if (normalizedTemplateMode) { + searchParams.set("mode", normalizedTemplateMode); + } + return `/api/psd-templates?${searchParams.toString()}`; + }, [open, normalizedBrandName, normalizedTemplateMode]); const { data, isLoading } = useSWR( - open && normalizedBrandName - ? `/api/psd-templates?brand=${encodeURIComponent(normalizedBrandName)}` - : null, + templateListRequestUrl, fetcher ); const templates = useMemo(() => (data?.success ? data.templates : []), [data]); @@ -663,6 +676,9 @@ export function AgentPsdTemplateEditorDialog({ ); const selectedImageLayer = selectedLayer && isAgentPsdImageLayerSlot(selectedLayer) ? selectedLayer : null; const selectedTextLayer = selectedLayer && isAgentPsdTextLayerSlot(selectedLayer) ? selectedLayer : null; + const selectedImageAssignmentUrl = selectedImageLayer + ? String(effectiveImageAssignments[selectedImageLayer.id] || "").trim() + : ""; const selectedImagePlacement = selectedImageLayer ? normalizeAgentPsdImagePlacement(effectiveImagePlacements[selectedImageLayer.id]) : getDefaultAgentPsdImagePlacement(); @@ -670,78 +686,152 @@ export function AgentPsdTemplateEditorDialog({ ? effectiveTextColorAssignments[selectedTextLayer.id] || selectedTextLayer.textColor : "#111111"; const selectedImageAdjustmentPanel = - selectedImageLayer && effectiveImageAssignments[selectedImageLayer.id] ? ( + selectedImageLayer ? (
-
- - 选中画布中的图片后可直接拖动位置 -
-
-
-
- - 缩放 -
- - {Math.round(selectedImagePlacement.scale * 100)}% - -
-
-
- - { - const newScale = clampImageScale(value[0] || 1); - updateImagePlacement(selectedImageLayer.id, (current) => ({ - ...current, - scale: newScale, - })); - }} - /> - -
-
- 40% - 100% - 300% -
-
-
-
+
+ {sourceImageUrl ? ( + + ) : null}
+ + {selectedImageAssignmentUrl ? ( + <> +
+ +
+ +
+ +
+
+
+ + 缩放 +
+ + {Math.round(selectedImagePlacement.scale * 100)}% + +
+
+
+ + { + const newScale = clampImageScale(value[0] || 1); + updateImagePlacement(selectedImageLayer.id, (current) => ({ + ...current, + scale: newScale, + })); + }} + /> + +
+
+ 40% + 100% + 300% +
+
+
+ +
+ +
+ + ) : null}
) : null; const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]); @@ -1154,7 +1244,7 @@ export function AgentPsdTemplateEditorDialog({ {hasTemplateOptions ? (