From ab0dc8bce3d383289b1a890d8142d5a76258b3be Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 2 Apr 2026 14:42:18 +0800 Subject: [PATCH] feat: add brand import and brand-aware psd settings --- .../agent-workflow-settings-client.tsx | 219 ++++++++- app/api/psd-templates/route.ts | 10 +- app/api/site-settings/route.ts | 10 + .../agents/AgentWorkflowPanelDarkV2.tsx | 181 ++++++-- lib/agents/psd-text-color-config.ts | 79 +++- lib/agents/workflow-brand-import.ts | 417 ++++++++++++++++++ types/agent.ts | 7 + 7 files changed, 844 insertions(+), 79 deletions(-) create mode 100644 lib/agents/workflow-brand-import.ts 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 f3e36cd51..5ab468b46 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { motion, AnimatePresence } from "framer-motion"; @@ -11,6 +11,7 @@ import { ArrowUp, ChevronDown, ChevronRight, + Download, Image as ImageIcon, Layers, Loader2, @@ -67,12 +68,18 @@ import { import { defaultAgentPsdTextColorConfig, normalizeAgentPsdTextColorConfig, + resolveAgentPsdTextColorConfigForBrand, } from "@/lib/agents/psd-text-color-config"; import { defaultAgentPsdTemplateSecondaryCategoryConfig, type AgentPsdTemplateSecondaryCategoryConfig, normalizeAgentPsdTemplateSecondaryCategoryConfig, } from "@/lib/agents/psd-template-secondary-category-config"; +import { + AGENT_WORKFLOW_BRAND_IMPORT_ACCEPT, + downloadAgentWorkflowBrandImportTemplate, + parseAgentWorkflowBrandImportFile, +} from "@/lib/agents/workflow-brand-import"; import { normalizeHexColor } from "@/lib/agents/psd-text-layout"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { Button } from "@/components/ui/button"; @@ -85,10 +92,12 @@ import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Separator } from "@/components/ui/separator"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { cn } from "@/lib/utils"; import type { AgentPresetLibraryCategoryNode, AgentPresetLibraryConfig, + AgentPsdTextColorBrandConfig, AgentPresetLibrarySection, AgentWorkflowBrandModelConfig, AgentWorkflowBrandStoreEntry, @@ -685,12 +694,15 @@ export function AgentWorkflowSettingsClient({ const [activeTab, setActiveTab] = useState("showcase"); const [brandDraft, setBrandDraft] = useState(""); const [priceBandDraft, setPriceBandDraft] = useState(""); + const [selectedPsdTextColorBrand, setSelectedPsdTextColorBrand] = useState(""); const [storeDrafts, setStoreDrafts] = useState>({}); const [thirdCategoryDrafts, setThirdCategoryDrafts] = useState>({}); const [fourthCategoryDrafts, setFourthCategoryDrafts] = useState>({}); const [fifthCategoryDrafts, setFifthCategoryDrafts] = useState>({}); const [blockedOptionSearches, setBlockedOptionSearches] = useState>({}); + const [isImportingBrandSheet, setIsImportingBrandSheet] = useState(false); const fileInputRefs = useRef>({}); + const brandImportInputRef = useRef(null); useEffect(() => { if (!data) return; @@ -760,6 +772,36 @@ export function AgentWorkflowSettingsClient({ ]) ).sort((left, right) => left.localeCompare(right, "zh-CN")); }, [brandModelConfig.entries, configuredBrands]); + const psdTextColorBrandOptions = useMemo( + () => + Array.from( + new Set([ + ...configuredBrands, + ...(psdTextColorConfig.brandConfigs || []).map((entry) => entry.brandName), + ]) + ).sort((left, right) => left.localeCompare(right, "zh-CN")), + [configuredBrands, psdTextColorConfig.brandConfigs] + ); + const selectedBrandPsdTextColorConfig = useMemo( + () => resolveAgentPsdTextColorConfigForBrand(psdTextColorConfig, selectedPsdTextColorBrand), + [psdTextColorConfig, selectedPsdTextColorBrand] + ); + + useEffect(() => { + if (psdTextColorBrandOptions.length === 0) { + if (selectedPsdTextColorBrand) { + setSelectedPsdTextColorBrand(""); + } + return; + } + + const hasCurrentSelection = psdTextColorBrandOptions.some( + (brandName) => brandName === selectedPsdTextColorBrand + ); + if (!hasCurrentSelection) { + setSelectedPsdTextColorBrand(psdTextColorBrandOptions[0] || ""); + } + }, [psdTextColorBrandOptions, selectedPsdTextColorBrand]); const sortedSteps = useMemo( () => [...config.steps].sort((left, right) => left.sortOrder - right.sortOrder), @@ -1670,13 +1712,67 @@ export function AgentWorkflowSettingsClient({ } }; - const updatePsdTextColor = (index: 0 | 1, value: string) => { - const normalized = normalizeHexColor(value, psdTextColorConfig.colors[index] || "#111111"); + const updateSelectedBrandPsdTextColorConfig = ( + updater: (current: AgentPsdTextColorBrandConfig | AgentPsdTextColorConfig) => { + enabled: boolean; + colors: [string, string]; + } + ) => { + if (!selectedPsdTextColorBrand) { + setPsdTextColorConfig((prev) => { + const next = updater(prev); + return { + ...prev, + enabled: next.enabled, + colors: next.colors, + }; + }); + return; + } + setPsdTextColorConfig((prev) => { - const nextColors = [...prev.colors] as [string, string]; - nextColors[index] = normalized; + const nextBrandConfigs = [...(prev.brandConfigs || [])]; + const matchedIndex = nextBrandConfigs.findIndex( + (entry) => + entry.brandName.trim().toLowerCase() === + selectedPsdTextColorBrand.trim().toLowerCase() + ); + const currentBrandConfig = + matchedIndex >= 0 + ? nextBrandConfigs[matchedIndex] + : resolveAgentPsdTextColorConfigForBrand(prev, selectedPsdTextColorBrand); + const next = updater(currentBrandConfig); + const nextEntry: AgentPsdTextColorBrandConfig = { + brandName: selectedPsdTextColorBrand, + enabled: next.enabled, + colors: next.colors, + }; + + if (matchedIndex >= 0) { + nextBrandConfigs[matchedIndex] = nextEntry; + } else { + nextBrandConfigs.push(nextEntry); + } + return { ...prev, + brandConfigs: nextBrandConfigs.sort((left, right) => + left.brandName.localeCompare(right.brandName, "zh-CN") + ), + }; + }); + }; + + const updatePsdTextColor = (index: 0 | 1, value: string) => { + const normalized = normalizeHexColor( + value, + selectedBrandPsdTextColorConfig.colors[index] || "#111111" + ); + updateSelectedBrandPsdTextColorConfig((current) => { + const nextColors = [...current.colors] as [string, string]; + nextColors[index] = normalized; + return { + enabled: current.enabled, colors: nextColors, }; }); @@ -1713,6 +1809,34 @@ export function AgentWorkflowSettingsClient({ } }; + const handleDownloadBrandTemplate = async () => { + try { + await downloadAgentWorkflowBrandImportTemplate(); + toast.success("品牌导入模板已开始下载"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "下载模板失败"); + } + }; + + const handleBrandSheetImport = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) { + return; + } + + try { + setIsImportingBrandSheet(true); + const { entries, rowCount } = await parseAgentWorkflowBrandImportFile(file); + updateInputOptions([...brandStoreEntries, ...entries]); + toast.success(`已从表格导入 ${rowCount} 行,品牌树已自动合并去重`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "导入表格失败"); + } finally { + setIsImportingBrandSheet(false); + } + }; + const renderPresetLibraryNodeCard = ( sectionId: string, node: AgentPresetLibraryCategoryNode, @@ -3158,11 +3282,48 @@ export function AgentWorkflowSettingsClient({
- - - {brandStoreEntries.length} 个品牌 - +
+ +

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

+
+
+ + {brandStoreEntries.length} 个品牌 + + + +
+
PSD 文字颜色选项 - 控制套用 PSD 模板时,前台是否允许用户在两种预设文字颜色之间切换。 + 按品牌配置套用 PSD 模板时可选的两组文字颜色,前台会跟随当前品牌读取。
+ {psdTextColorBrandOptions.length > 0 ? ( +
+ + +

+ 未单独配置的品牌会回退系统默认颜色;当前编辑品牌:{selectedPsdTextColorBrand} +

+
+ ) : ( +
+ 请先在上方“品牌树”里配置品牌,再按品牌设置 PSD 文字颜色。 +
+ )} +
-
启用 PSD 文字双色切换
+
+ {selectedPsdTextColorBrand + ? `启用“${selectedPsdTextColorBrand}”PSD 文字双色切换` + : "启用 PSD 文字双色切换"} +
- 开启后,用户在 PSD 模板编辑弹窗里可为文字图层选择两种系统预设颜色。 + 开启后,用户在 PSD 模板编辑弹窗里可为当前品牌的文字图层选择两种预设颜色。
- setPsdTextColorConfig((prev) => ({ ...prev, enabled: checked })) + updateSelectedBrandPsdTextColorConfig((current) => ({ + enabled: checked, + colors: current.colors, + })) } />
- {psdTextColorConfig.colors.map((color, index) => ( + {selectedBrandPsdTextColorConfig.colors.map((color, index) => (
getAgentWorkflowPriceBandOptions(config), [config] ); + const hasThirdCategoryLevel = useMemo( + () => + brandStoreEntries.some((entry) => + (entry.storeEntries || []).some( + (storeEntry) => + (storeEntry.thirdCategoryEntries || []).length > 0 || + (storeEntry.thirdCategories || []).length > 0 + ) + ) || Boolean(config?.inputOptions?.thirdCategories?.length), + [brandStoreEntries, config?.inputOptions?.thirdCategories] + ); + const hasFourthCategoryLevel = useMemo( + () => + hasThirdCategoryLevel && + (brandStoreEntries.some((entry) => + (entry.storeEntries || []).some((storeEntry) => + (storeEntry.thirdCategoryEntries || []).some( + (thirdEntry) => + (thirdEntry.fourthCategoryEntries || []).length > 0 || + (thirdEntry.fourthCategories || []).length > 0 + ) + ) + ) || Boolean(config?.inputOptions?.fourthCategories?.length)), + [brandStoreEntries, config?.inputOptions?.fourthCategories, hasThirdCategoryLevel] + ); + const hasFifthCategoryLevel = useMemo( + () => + hasFourthCategoryLevel && + (brandStoreEntries.some((entry) => + (entry.storeEntries || []).some((storeEntry) => + (storeEntry.thirdCategoryEntries || []).some((thirdEntry) => + (thirdEntry.fourthCategoryEntries || []).some( + (fourthEntry) => (fourthEntry.fifthCategories || []).length > 0 + ) + ) + ) + ) || Boolean(config?.inputOptions?.fifthCategories?.length)), + [brandStoreEntries, config?.inputOptions?.fifthCategories, hasFourthCategoryLevel] + ); const isDark = theme === "dark"; + useEffect(() => { + if (!hasThirdCategoryLevel) { + if (inputs.thirdCategory || inputs.fourthCategory || inputs.fifthCategory) { + onInputsChange({ + ...inputs, + thirdCategory: "", + fourthCategory: "", + fifthCategory: "", + }); + } + return; + } + + if (!hasFourthCategoryLevel) { + if (inputs.fourthCategory || inputs.fifthCategory) { + onInputsChange({ + ...inputs, + fourthCategory: "", + fifthCategory: "", + }); + } + return; + } + + if (!hasFifthCategoryLevel && inputs.fifthCategory) { + onInputsChange({ + ...inputs, + fifthCategory: "", + }); + } + }, [ + hasFifthCategoryLevel, + hasFourthCategoryLevel, + hasThirdCategoryLevel, + inputs, + onInputsChange, + ]); + return (
- - onInputsChange({ - ...inputs, - thirdCategory: value, - fourthCategory: - value.trim().toLowerCase() === inputs.thirdCategory.trim().toLowerCase() - ? inputs.fourthCategory - : "", - fifthCategory: "", - }) - } - /> - - onInputsChange({ - ...inputs, - fourthCategory: value, - fifthCategory: - value.trim().toLowerCase() === inputs.fourthCategory.trim().toLowerCase() - ? inputs.fifthCategory - : "", - }) - } - /> - onInputsChange({ ...inputs, fifthCategory: value })} - /> + {hasThirdCategoryLevel ? ( + + onInputsChange({ + ...inputs, + thirdCategory: value, + fourthCategory: + value.trim().toLowerCase() === inputs.thirdCategory.trim().toLowerCase() + ? inputs.fourthCategory + : "", + fifthCategory: "", + }) + } + /> + ) : null} + {hasFourthCategoryLevel ? ( + + onInputsChange({ + ...inputs, + fourthCategory: value, + fifthCategory: + value.trim().toLowerCase() === inputs.fourthCategory.trim().toLowerCase() + ? inputs.fifthCategory + : "", + }) + } + /> + ) : null} + {hasFifthCategoryLevel ? ( + onInputsChange({ ...inputs, fifthCategory: value })} + /> + ) : null} normalizeHexColor(String(item || ""), "")) + .filter(Boolean) + .slice(0, 2) + : []; + + return [ + colors[0] || fallback[0], + colors[1] || colors[0] || fallback[1], + ] as [string, string]; +} + export function normalizeAgentPsdTextColorConfig( value: unknown ): AgentPsdTextColorConfig { @@ -16,20 +35,56 @@ export function normalizeAgentPsdTextColorConfig( ? (value as Record) : {}; - const colors = Array.isArray(raw.colors) - ? raw.colors - .map((item) => normalizeHexColor(String(item || ""), "")) - .filter(Boolean) - .slice(0, 2) - : []; + const baseColors = normalizeColors(raw.colors, defaultAgentPsdTextColorConfig.colors); + const uniqueBrandConfigs = new Map(); + if (Array.isArray(raw.brandConfigs)) { + raw.brandConfigs.forEach((item) => { + const record = + item && typeof item === "object" && !Array.isArray(item) + ? (item as Record) + : {}; + const brandName = normalizeBrandName(record.brandName); + if (!brandName) { + return; + } - const [firstColor, secondColor] = [ - colors[0] || defaultAgentPsdTextColorConfig.colors[0], - colors[1] || colors[0] || defaultAgentPsdTextColorConfig.colors[1], - ]; + uniqueBrandConfigs.set(brandName.toLowerCase(), { + brandName, + enabled: record.enabled === true, + colors: normalizeColors(record.colors, baseColors), + }); + }); + } return { enabled: raw.enabled === true, - colors: [firstColor, secondColor], + colors: baseColors, + brandConfigs: Array.from(uniqueBrandConfigs.values()).sort((left, right) => + left.brandName.localeCompare(right.brandName, "zh-CN") + ), + }; +} + +export function resolveAgentPsdTextColorConfigForBrand( + config: AgentPsdTextColorConfig | null | undefined, + brandName?: string | null +): AgentPsdTextColorConfig { + const normalized = normalizeAgentPsdTextColorConfig(config); + const requestedBrand = normalizeBrandName(brandName); + if (!requestedBrand) { + return normalized; + } + + const matchedBrandConfig = (normalized.brandConfigs || []).find( + (entry) => entry.brandName.toLowerCase() === requestedBrand.toLowerCase() + ); + if (!matchedBrandConfig) { + return normalized; + } + + return { + ...normalized, + enabled: matchedBrandConfig.enabled, + colors: matchedBrandConfig.colors, }; } diff --git a/lib/agents/workflow-brand-import.ts b/lib/agents/workflow-brand-import.ts new file mode 100644 index 000000000..9925dea97 --- /dev/null +++ b/lib/agents/workflow-brand-import.ts @@ -0,0 +1,417 @@ +import Papa from "papaparse"; +import type { + AgentWorkflowBrandStoreEntry, + AgentWorkflowFourthCategoryEntry, + AgentWorkflowStoreEntry, + AgentWorkflowThirdCategoryEntry, +} from "@/types/agent"; + +type SpreadsheetRecord = Record; + +export const AGENT_WORKFLOW_BRAND_IMPORT_ACCEPT = ".xlsx,.xls,.csv"; + +const BRAND_SHEET_NAME = "品牌树模板"; +const INSTRUCTION_SHEET_NAME = "填写说明"; +const TEMPLATE_FILE_NAME = "智能体品牌树导入模板"; + +const TEMPLATE_HEADERS = { + brandName: "品牌", + storeName: "店铺", + thirdCategory: "三级分类", + fourthCategory: "四级分类", + fifthCategory: "五级分类", +} as const; + +const HEADER_ALIASES = { + brandName: ["品牌", "品牌名称", "brand", "brandname"], + storeName: ["店铺", "店铺名称", "shop", "shopname", "store", "storename"], + thirdCategory: ["三级分类", "三级类目", "thirdcategory", "third"], + fourthCategory: ["四级分类", "四级类目", "fourthcategory", "fourth"], + fifthCategory: ["五级分类", "五级类目", "fifthcategory", "fifth"], +} as const; + +const TEMPLATE_SAMPLE_ROWS = [ + { + brandName: "朴美", + storeName: "天猫朴美家居旗舰店", + thirdCategory: "桌类", + fourthCategory: "餐桌", + fifthCategory: "岩板餐桌", + }, + { + brandName: "朴美", + storeName: "天猫朴美家居旗舰店", + thirdCategory: "桌类", + fourthCategory: "茶几", + fifthCategory: "圆形茶几", + }, + { + brandName: "朴美", + storeName: "天猫朴美家居旗舰店", + thirdCategory: "椅类", + fourthCategory: "餐椅", + fifthCategory: "软包餐椅", + }, + { + brandName: "朴美", + storeName: "京东朴美家居旗舰店", + thirdCategory: "柜类", + fourthCategory: "电视柜", + fifthCategory: "落地电视柜", + }, +]; + +function normalizeCell(value: unknown, limit = 64) { + const normalized = String(value ?? "") + .replace(/\s+/g, " ") + .trim(); + if (!normalized) { + return ""; + } + return normalized.slice(0, limit); +} + +function normalizeHeader(value: unknown) { + return String(value ?? "") + .replace(/\s+/g, "") + .replace(/[_-]+/g, "") + .trim() + .toLowerCase(); +} + +function triggerBlobDownload(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +} + +function matchColumn(record: SpreadsheetRecord, aliases: readonly string[]) { + const aliasSet = new Set(aliases.map((alias) => normalizeHeader(alias))); + return Object.keys(record).find((key) => aliasSet.has(normalizeHeader(key))) || null; +} + +async function readSpreadsheetRecords(file: File) { + const extension = file.name.toLowerCase().split(".").pop() || ""; + if (extension === "csv") { + const text = await file.text(); + const parsed = Papa.parse(text, { + header: true, + skipEmptyLines: true, + transformHeader: (header) => normalizeCell(header, 128), + }); + + if (parsed.errors.length > 0) { + throw new Error(`CSV 解析失败:${parsed.errors[0]?.message || "未知错误"}`); + } + + return parsed.data; + } + + const { read, utils } = await import("xlsx"); + const buffer = await file.arrayBuffer(); + const workbook = read(buffer, { type: "array" }); + const sheetName = workbook.SheetNames[0]; + if (!sheetName) { + throw new Error("表格中没有可读取的工作表"); + } + + const worksheet = workbook.Sheets[sheetName]; + return utils.sheet_to_json(worksheet, { + defval: "", + }); +} + +function createBrandEntry(brandName: string, sortOrder: number): AgentWorkflowBrandStoreEntry { + return { + brandName, + stores: [], + storeEntries: [], + sortOrder, + }; +} + +function createStoreEntry(storeName: string, sortOrder: number): AgentWorkflowStoreEntry { + return { + storeName, + thirdCategories: [], + thirdCategoryEntries: [], + sortOrder, + }; +} + +function createThirdCategoryEntry( + categoryName: string, + sortOrder: number +): AgentWorkflowThirdCategoryEntry { + return { + categoryName, + fourthCategories: [], + fourthCategoryEntries: [], + sortOrder, + }; +} + +function createFourthCategoryEntry( + categoryName: string, + sortOrder: number +): AgentWorkflowFourthCategoryEntry { + return { + categoryName, + fifthCategories: [], + sortOrder, + }; +} + +export interface ParsedAgentWorkflowBrandImportResult { + entries: AgentWorkflowBrandStoreEntry[]; + rowCount: number; +} + +export async function downloadAgentWorkflowBrandImportTemplate() { + const { Workbook } = await import("exceljs"); + + const workbook = new Workbook(); + const sheet = workbook.addWorksheet(BRAND_SHEET_NAME); + sheet.columns = [ + { header: TEMPLATE_HEADERS.brandName, key: "brandName", width: 20 }, + { header: TEMPLATE_HEADERS.storeName, key: "storeName", width: 28 }, + { header: TEMPLATE_HEADERS.thirdCategory, key: "thirdCategory", width: 18 }, + { header: TEMPLATE_HEADERS.fourthCategory, key: "fourthCategory", width: 18 }, + { header: TEMPLATE_HEADERS.fifthCategory, key: "fifthCategory", width: 18 }, + ]; + sheet.getRow(1).font = { bold: true }; + sheet.views = [{ state: "frozen", ySplit: 1 }]; + + TEMPLATE_SAMPLE_ROWS.forEach((row) => { + sheet.addRow(row); + }); + + const instructionSheet = workbook.addWorksheet(INSTRUCTION_SHEET_NAME); + instructionSheet.columns = [ + { header: "字段", key: "field", width: 18 }, + { header: "是否必填", key: "required", width: 12 }, + { header: "说明", key: "description", width: 54 }, + { header: "示例", key: "example", width: 28 }, + ]; + instructionSheet.getRow(1).font = { bold: true }; + instructionSheet.views = [{ state: "frozen", ySplit: 1 }]; + instructionSheet.addRows([ + { + field: "品牌", + required: "是", + description: "每一行都必须填写品牌名称;相同品牌会自动合并。", + example: "朴美", + }, + { + field: "店铺", + required: "否", + description: "不填时只导入品牌;填写后会挂到对应品牌下。", + example: "天猫朴美家居旗舰店", + }, + { + field: "三级分类", + required: "否", + description: "填写后会挂到对应店铺下;如果不填,四级/五级也必须留空。", + example: "桌类", + }, + { + field: "四级分类", + required: "否", + description: "填写后会挂到对应三级分类下;如果不填,五级也必须留空。", + example: "餐桌", + }, + { + field: "五级分类", + required: "否", + description: "填写后会挂到对应四级分类下。", + example: "岩板餐桌", + }, + ]); + + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + triggerBlobDownload(blob, `${TEMPLATE_FILE_NAME}.xlsx`); +} + +export async function parseAgentWorkflowBrandImportFile( + file: File +): Promise { + const extension = file.name.toLowerCase().split(".").pop() || ""; + if (!["xls", "xlsx", "csv"].includes(extension)) { + throw new Error("仅支持 xls、xlsx、csv 文件"); + } + + const records = await readSpreadsheetRecords(file); + if (records.length === 0) { + throw new Error("表格中没有有效数据"); + } + + const firstRecord = records[0]; + const brandColumn = + matchColumn(firstRecord, HEADER_ALIASES.brandName) || + Object.keys(firstRecord).find((key) => normalizeHeader(key) === normalizeHeader(TEMPLATE_HEADERS.brandName)); + if (!brandColumn) { + throw new Error("未找到品牌列,请使用模板文件填写"); + } + + const storeColumn = matchColumn(firstRecord, HEADER_ALIASES.storeName); + const thirdColumn = matchColumn(firstRecord, HEADER_ALIASES.thirdCategory); + const fourthColumn = matchColumn(firstRecord, HEADER_ALIASES.fourthCategory); + const fifthColumn = matchColumn(firstRecord, HEADER_ALIASES.fifthCategory); + + const brandEntries: AgentWorkflowBrandStoreEntry[] = []; + const brandLookup = new Map(); + let effectiveRowCount = 0; + + const getOrCreateBrand = (brandName: string) => { + const lookupKey = brandName.toLowerCase(); + const existing = brandLookup.get(lookupKey); + if (existing) { + return existing; + } + const nextEntry = createBrandEntry(brandName, brandEntries.length); + brandEntries.push(nextEntry); + brandLookup.set(lookupKey, nextEntry); + return nextEntry; + }; + + const getOrCreateStore = (brandEntry: AgentWorkflowBrandStoreEntry, storeName: string) => { + const storeEntries = brandEntry.storeEntries || []; + const existing = storeEntries.find( + (entry) => entry.storeName.toLowerCase() === storeName.toLowerCase() + ); + if (existing) { + return existing; + } + const nextEntry = createStoreEntry(storeName, storeEntries.length); + brandEntry.storeEntries = [...storeEntries, nextEntry]; + brandEntry.stores = brandEntry.storeEntries.map((entry) => entry.storeName); + return nextEntry; + }; + + const getOrCreateThirdCategory = (storeEntry: AgentWorkflowStoreEntry, categoryName: string) => { + const thirdEntries = storeEntry.thirdCategoryEntries || []; + const existing = thirdEntries.find( + (entry) => entry.categoryName.toLowerCase() === categoryName.toLowerCase() + ); + if (existing) { + return existing; + } + const nextEntry = createThirdCategoryEntry(categoryName, thirdEntries.length); + storeEntry.thirdCategoryEntries = [...thirdEntries, nextEntry]; + storeEntry.thirdCategories = storeEntry.thirdCategoryEntries.map((entry) => entry.categoryName); + return nextEntry; + }; + + const getOrCreateFourthCategory = ( + thirdEntry: AgentWorkflowThirdCategoryEntry, + categoryName: string + ) => { + const fourthEntries = thirdEntry.fourthCategoryEntries || []; + const existing = fourthEntries.find( + (entry) => entry.categoryName.toLowerCase() === categoryName.toLowerCase() + ); + if (existing) { + return existing; + } + const nextEntry = createFourthCategoryEntry(categoryName, fourthEntries.length); + thirdEntry.fourthCategoryEntries = [...fourthEntries, nextEntry]; + thirdEntry.fourthCategories = thirdEntry.fourthCategoryEntries.map((entry) => entry.categoryName); + return nextEntry; + }; + + records.forEach((record, index) => { + const lineNumber = index + 2; + const brandName = normalizeCell(record[brandColumn]); + const storeName = storeColumn ? normalizeCell(record[storeColumn]) : ""; + const thirdCategory = thirdColumn ? normalizeCell(record[thirdColumn]) : ""; + const fourthCategory = fourthColumn ? normalizeCell(record[fourthColumn]) : ""; + const fifthCategory = fifthColumn ? normalizeCell(record[fifthColumn]) : ""; + + const hasAnyValue = Boolean(brandName || storeName || thirdCategory || fourthCategory || fifthCategory); + if (!hasAnyValue) { + return; + } + + if (!brandName) { + throw new Error(`第 ${lineNumber} 行缺少品牌名称`); + } + if (!storeName && (thirdCategory || fourthCategory || fifthCategory)) { + throw new Error(`第 ${lineNumber} 行缺少店铺名称,不能直接填写分类`); + } + if (!thirdCategory && (fourthCategory || fifthCategory)) { + throw new Error(`第 ${lineNumber} 行缺少三级分类,不能直接填写四级或五级分类`); + } + if (!fourthCategory && fifthCategory) { + throw new Error(`第 ${lineNumber} 行缺少四级分类,不能直接填写五级分类`); + } + + effectiveRowCount += 1; + const brandEntry = getOrCreateBrand(brandName); + if (!storeName) { + return; + } + + const storeEntry = getOrCreateStore(brandEntry, storeName); + if (!thirdCategory) { + return; + } + + const thirdEntry = getOrCreateThirdCategory(storeEntry, thirdCategory); + if (!fourthCategory) { + return; + } + + const fourthEntry = getOrCreateFourthCategory(thirdEntry, fourthCategory); + if (!fifthCategory) { + return; + } + + if ( + !fourthEntry.fifthCategories.some( + (item) => item.toLowerCase() === fifthCategory.toLowerCase() + ) + ) { + fourthEntry.fifthCategories = [...fourthEntry.fifthCategories, fifthCategory]; + } + }); + + if (effectiveRowCount === 0) { + throw new Error("表格中没有可导入的有效行"); + } + + return { + entries: brandEntries.map((brandEntry, brandIndex) => ({ + ...brandEntry, + stores: (brandEntry.storeEntries || []).map((storeEntry) => storeEntry.storeName), + storeEntries: (brandEntry.storeEntries || []).map((storeEntry, storeIndex) => ({ + ...storeEntry, + thirdCategories: (storeEntry.thirdCategoryEntries || []).map((thirdEntry) => thirdEntry.categoryName), + thirdCategoryEntries: (storeEntry.thirdCategoryEntries || []).map((thirdEntry, thirdIndex) => ({ + ...thirdEntry, + fourthCategories: (thirdEntry.fourthCategoryEntries || []).map( + (fourthEntry) => fourthEntry.categoryName + ), + fourthCategoryEntries: (thirdEntry.fourthCategoryEntries || []).map( + (fourthEntry, fourthIndex) => ({ + ...fourthEntry, + fifthCategories: Array.from(new Set((fourthEntry.fifthCategories || []).filter(Boolean))), + sortOrder: fourthIndex, + }) + ), + sortOrder: thirdIndex, + })), + sortOrder: storeIndex, + })), + sortOrder: brandIndex, + })), + rowCount: effectiveRowCount, + }; +} diff --git a/types/agent.ts b/types/agent.ts index 451b5b8c6..4b56ca41e 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -441,9 +441,16 @@ export interface AgentGenerationProject { updatedAt: Date | string; } +export interface AgentPsdTextColorBrandConfig { + brandName: string; + enabled: boolean; + colors: [string, string]; +} + export interface AgentPsdTextColorConfig { enabled: boolean; colors: [string, string]; + brandConfigs?: AgentPsdTextColorBrandConfig[]; } export interface AgentCategoryTreeConfig {