feat: add brand import and brand-aware psd settings
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-02 14:42:18 +08:00
parent ea6a9a9d76
commit ab0dc8bce3
7 changed files with 844 additions and 79 deletions

View File

@ -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<Record<string, string>>({});
const [thirdCategoryDrafts, setThirdCategoryDrafts] = useState<Record<string, string>>({});
const [fourthCategoryDrafts, setFourthCategoryDrafts] = useState<Record<string, string>>({});
const [fifthCategoryDrafts, setFifthCategoryDrafts] = useState<Record<string, string>>({});
const [blockedOptionSearches, setBlockedOptionSearches] = useState<Record<string, string>>({});
const [isImportingBrandSheet, setIsImportingBrandSheet] = useState(false);
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const brandImportInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
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({
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">品牌树</Label>
<span className="text-xs text-muted-foreground">
{brandStoreEntries.length} 个品牌
</span>
<div className="space-y-1">
<Label className="text-xs">品牌树</Label>
<p className="text-xs text-muted-foreground">
支持一键下载模板并导入品牌、店铺、三级、四级、五级分类表格
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<span className="text-xs text-muted-foreground">
{brandStoreEntries.length} 个品牌
</span>
<Button
type="button"
variant="outline"
onClick={handleDownloadBrandTemplate}
className="h-8"
>
<Download className="mr-2 h-4 w-4" />
下载模板
</Button>
<Button
type="button"
variant="outline"
onClick={() => brandImportInputRef.current?.click()}
className="h-8"
disabled={isImportingBrandSheet}
>
{isImportingBrandSheet ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
导入表格
</Button>
</div>
</div>
<input
ref={brandImportInputRef}
type="file"
accept={AGENT_WORKFLOW_BRAND_IMPORT_ACCEPT}
className="hidden"
onChange={handleBrandSheetImport}
/>
<div className="rounded-xl border bg-muted/10 p-3">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
@ -3647,29 +3808,57 @@ export function AgentWorkflowSettingsClient({
<div>
<CardTitle className="text-base">PSD 文字颜色选项</CardTitle>
<CardDescription>
控制套用 PSD 模板时,前台是否允许用户在两种预设文字颜色之间切换。
按品牌配置套用 PSD 模板时可选的两组文字颜色,前台会跟随当前品牌读取。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{psdTextColorBrandOptions.length > 0 ? (
<div className="space-y-2">
<Label className="text-xs">选择品牌</Label>
<SearchableSelect
value={selectedPsdTextColorBrand}
options={psdTextColorBrandOptions}
placeholder="选择一个品牌后配置颜色"
searchPlaceholder="输入品牌名筛选"
emptyText="暂无品牌可选"
onValueChange={setSelectedPsdTextColorBrand}
/>
<p className="text-xs text-muted-foreground">
未单独配置的品牌会回退系统默认颜色;当前编辑品牌:{selectedPsdTextColorBrand}
</p>
</div>
) : (
<div className="rounded-lg border border-dashed bg-muted/10 px-4 py-3 text-sm text-muted-foreground">
请先在上方“品牌树”里配置品牌,再按品牌设置 PSD 文字颜色。
</div>
)}
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
<div>
<div className="text-sm font-medium">启用 PSD 文字双色切换</div>
<div className="text-sm font-medium">
{selectedPsdTextColorBrand
? `启用“${selectedPsdTextColorBrand}”PSD 文字双色切换`
: "启用 PSD 文字双色切换"}
</div>
<div className="text-xs text-muted-foreground">
开启后,用户在 PSD 模板编辑弹窗里可为文字图层选择两种系统预设颜色。
开启后,用户在 PSD 模板编辑弹窗里可为当前品牌的文字图层选择两种预设颜色。
</div>
</div>
<Switch
checked={psdTextColorConfig.enabled}
checked={selectedBrandPsdTextColorConfig.enabled}
onCheckedChange={(checked) =>
setPsdTextColorConfig((prev) => ({ ...prev, enabled: checked }))
updateSelectedBrandPsdTextColorConfig((current) => ({
enabled: checked,
colors: current.colors,
}))
}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
{psdTextColorConfig.colors.map((color, index) => (
{selectedBrandPsdTextColorConfig.colors.map((color, index) => (
<div
key={`psd-text-color-${index + 1}`}
className="rounded-xl border bg-muted/20 p-4"

View File

@ -15,6 +15,7 @@ import {
AGENT_PSD_TEXT_COLOR_CONFIG_KEY,
defaultAgentPsdTextColorConfig,
normalizeAgentPsdTextColorConfig,
resolveAgentPsdTextColorConfigForBrand,
} from "@/lib/agents/psd-text-color-config";
import {
AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY,
@ -141,9 +142,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
success: true,
templates,
textColorConfig: textColorSetting
? normalizeAgentPsdTextColorConfig(textColorSetting.value)
: defaultAgentPsdTextColorConfig,
textColorConfig: resolveAgentPsdTextColorConfigForBrand(
textColorSetting
? normalizeAgentPsdTextColorConfig(textColorSetting.value)
: defaultAgentPsdTextColorConfig,
requestedBrand
),
secondaryCategoryConfig: secondaryCategorySetting
? normalizeAgentPsdTemplateSecondaryCategoryConfig(secondaryCategorySetting.value)
: defaultAgentPsdTemplateSecondaryCategoryConfig,

View File

@ -872,6 +872,16 @@ const updateSchema = z.object({
.object({
enabled: z.boolean().optional(),
colors: z.array(z.string().trim().max(32)).length(2).optional(),
brandConfigs: z
.array(
z.object({
brandName: z.string().trim().min(1).max(64),
enabled: z.boolean().optional(),
colors: z.array(z.string().trim().max(32)).length(2).optional(),
})
)
.max(300)
.optional(),
})
.optional(),
agentPsdTemplateSecondaryCategoryConfig: z

View File

@ -326,8 +326,85 @@ export function AgentWorkflowBrandStorePanel({
() => 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 (
<div
className={cn(
@ -389,55 +466,61 @@ export function AgentWorkflowBrandStorePanel({
})
}
/>
<SearchableSelect
value={inputs.thirdCategory}
options={thirdCategoryOptions}
placeholder="选择三级分类"
searchPlaceholder="输入三级分类筛选"
emptyText={inputs.store ? "当前店铺下暂无三级分类" : "请先选择店铺"}
theme={theme}
disabled={!inputs.store || thirdCategoryOptions.length === 0}
onValueChange={(value) =>
onInputsChange({
...inputs,
thirdCategory: value,
fourthCategory:
value.trim().toLowerCase() === inputs.thirdCategory.trim().toLowerCase()
? inputs.fourthCategory
: "",
fifthCategory: "",
})
}
/>
<SearchableSelect
value={inputs.fourthCategory}
options={fourthCategoryOptions}
placeholder="选择四级分类"
searchPlaceholder="输入四级分类筛选"
emptyText={inputs.thirdCategory ? "当前三级分类下暂无四级分类" : "请先选择三级分类"}
theme={theme}
disabled={!inputs.thirdCategory || fourthCategoryOptions.length === 0}
onValueChange={(value) =>
onInputsChange({
...inputs,
fourthCategory: value,
fifthCategory:
value.trim().toLowerCase() === inputs.fourthCategory.trim().toLowerCase()
? inputs.fifthCategory
: "",
})
}
/>
<SearchableSelect
value={inputs.fifthCategory}
options={fifthCategoryOptions}
placeholder="选择五级分类"
searchPlaceholder="输入五级分类筛选"
emptyText={inputs.fourthCategory ? "当前四级分类下暂无五级分类" : "请先选择四级分类"}
theme={theme}
disabled={!inputs.fourthCategory || fifthCategoryOptions.length === 0}
onValueChange={(value) => onInputsChange({ ...inputs, fifthCategory: value })}
/>
{hasThirdCategoryLevel ? (
<SearchableSelect
value={inputs.thirdCategory}
options={thirdCategoryOptions}
placeholder="选择三级分类"
searchPlaceholder="输入三级分类筛选"
emptyText={inputs.store ? "当前店铺下暂无三级分类" : "请先选择店铺"}
theme={theme}
disabled={!inputs.store || thirdCategoryOptions.length === 0}
onValueChange={(value) =>
onInputsChange({
...inputs,
thirdCategory: value,
fourthCategory:
value.trim().toLowerCase() === inputs.thirdCategory.trim().toLowerCase()
? inputs.fourthCategory
: "",
fifthCategory: "",
})
}
/>
) : null}
{hasFourthCategoryLevel ? (
<SearchableSelect
value={inputs.fourthCategory}
options={fourthCategoryOptions}
placeholder="选择四级分类"
searchPlaceholder="输入四级分类筛选"
emptyText={inputs.thirdCategory ? "当前三级分类下暂无四级分类" : "请先选择三级分类"}
theme={theme}
disabled={!inputs.thirdCategory || fourthCategoryOptions.length === 0}
onValueChange={(value) =>
onInputsChange({
...inputs,
fourthCategory: value,
fifthCategory:
value.trim().toLowerCase() === inputs.fourthCategory.trim().toLowerCase()
? inputs.fifthCategory
: "",
})
}
/>
) : null}
{hasFifthCategoryLevel ? (
<SearchableSelect
value={inputs.fifthCategory}
options={fifthCategoryOptions}
placeholder="选择五级分类"
searchPlaceholder="输入五级分类筛选"
emptyText={inputs.fourthCategory ? "当前四级分类下暂无五级分类" : "请先选择四级分类"}
theme={theme}
disabled={!inputs.fourthCategory || fifthCategoryOptions.length === 0}
onValueChange={(value) => onInputsChange({ ...inputs, fifthCategory: value })}
/>
) : null}
<SearchableSelect
value={inputs.priceBand}
options={priceBandOptions}

View File

@ -1,4 +1,4 @@
import type { AgentPsdTextColorConfig } from "@/types/agent";
import type { AgentPsdTextColorBrandConfig, AgentPsdTextColorConfig } from "@/types/agent";
import { normalizeHexColor } from "@/lib/agents/psd-text-layout";
export const AGENT_PSD_TEXT_COLOR_CONFIG_KEY = "agent_psd_text_color_config";
@ -6,8 +6,27 @@ export const AGENT_PSD_TEXT_COLOR_CONFIG_KEY = "agent_psd_text_color_config";
export const defaultAgentPsdTextColorConfig: AgentPsdTextColorConfig = {
enabled: false,
colors: ["#ffffff", "#111111"],
brandConfigs: [],
};
function normalizeBrandName(value: unknown) {
return String(value || "").trim().slice(0, 64);
}
function normalizeColors(value: unknown, fallback: [string, string]) {
const colors = Array.isArray(value)
? value
.map((item) => 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<string, unknown>)
: {};
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<string, AgentPsdTextColorBrandConfig>();
if (Array.isArray(raw.brandConfigs)) {
raw.brandConfigs.forEach((item) => {
const record =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
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,
};
}

View File

@ -0,0 +1,417 @@
import Papa from "papaparse";
import type {
AgentWorkflowBrandStoreEntry,
AgentWorkflowFourthCategoryEntry,
AgentWorkflowStoreEntry,
AgentWorkflowThirdCategoryEntry,
} from "@/types/agent";
type SpreadsheetRecord = Record<string, unknown>;
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<SpreadsheetRecord>(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<SpreadsheetRecord>(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<ParsedAgentWorkflowBrandImportResult> {
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<string, AgentWorkflowBrandStoreEntry>();
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,
};
}

View File

@ -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 {