feat: align psd templates with brand category config
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 17:48:41 +08:00
parent a90e28d115
commit f4d488d104
13 changed files with 1095 additions and 409 deletions

View File

@ -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<AgentWorkflowCategoryLabels> = defaultAgentWorkflowCategoryLabels
) {
const normalizedEntries = entries.map((entry, index) => ({
brandName: entry.brandName.trim(),
storeEntries: (() => {
const uniqueStoreEntries = new Map<string, AgentWorkflowStoreEntry>();
const resolvedCategoryLabels = {
...defaultAgentWorkflowCategoryLabels,
...categoryLabels,
};
type FourthCategoryBuilder = {
categoryName: string;
fifthCategoryMap: Map<string, string>;
sortOrder: number;
};
(entry.storeEntries || []).forEach((storeEntry, storeIndex) => {
const storeName = String(storeEntry.storeName || "").trim();
if (!storeName) {
type ThirdCategoryBuilder = {
categoryName: string;
fourthCategoryMap: Map<string, FourthCategoryBuilder>;
sortOrder: number;
};
type StoreBuilder = {
storeName: string;
thirdCategoryMap: Map<string, ThirdCategoryBuilder>;
sortOrder: number;
};
type BrandBuilder = {
brandName: string;
storeMap: Map<string, StoreBuilder>;
sortOrder: number;
};
const brandMap = new Map<string, BrandBuilder>();
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<string, StoreBuilder>(),
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<string, ThirdCategoryBuilder>(),
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<string, FourthCategoryBuilder>(),
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<string, string>(),
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<string, AgentWorkflowThirdCategoryEntry>();
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<string, AgentWorkflowFourthCategoryEntry>();
(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({
</Card>
</motion.div>
<motion.div variants={itemVariants}>
<motion.div variants={itemVariants} className="lg:col-span-2">
<Card>
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
@ -3266,7 +3443,7 @@ export function AgentWorkflowSettingsClient({
</Card>
</motion.div>
<motion.div variants={itemVariants}>
<motion.div variants={itemVariants} className="lg:col-span-2">
<Card>
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
@ -3275,7 +3452,7 @@ export function AgentWorkflowSettingsClient({
</div>
<div>
<CardTitle className="text-base">基础下拉选项</CardTitle>
<CardDescription>配置品牌、店铺到五级分类的层级关系,以及第一页可选的价格带区间</CardDescription>
<CardDescription>配置品牌、店铺与分类层级关系,以及第一页可选的价格带区间</CardDescription>
</div>
</div>
</CardHeader>
@ -3285,7 +3462,7 @@ export function AgentWorkflowSettingsClient({
<div className="space-y-1">
<Label className="text-xs">品牌树</Label>
<p className="text-xs text-muted-foreground">
支持一键下载模板并导入品牌、店铺、三级、四级、五级分类表格
支持一键下载模板并导入品牌、店铺、{thirdCategoryLabel}、{fourthCategoryLabel}、{fifthCategoryLabel}表格
</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
@ -3324,6 +3501,32 @@ export function AgentWorkflowSettingsClient({
className="hidden"
onChange={handleBrandSheetImport}
/>
<div className="grid grid-cols-1 gap-3 rounded-xl border bg-background/70 p-3 md:grid-cols-3">
<div className="space-y-2">
<Label className="text-xs">第三级名称</Label>
<Input
value={thirdCategoryLabel}
onChange={(e) => handleCategoryLabelChange("thirdCategory", e.target.value)}
placeholder="例如:系列分类"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">第四级名称</Label>
<Input
value={fourthCategoryLabel}
onChange={(e) => handleCategoryLabelChange("fourthCategory", e.target.value)}
placeholder="例如:产品类型"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">第五级名称</Label>
<Input
value={fifthCategoryLabel}
onChange={(e) => handleCategoryLabelChange("fifthCategory", e.target.value)}
placeholder="例如:细分类目"
/>
</div>
</div>
<div className="rounded-xl border bg-muted/10 p-3">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
@ -3427,7 +3630,7 @@ export function AgentWorkflowSettingsClient({
</button>
</div>
<span className="text-xs text-muted-foreground">
{(storeEntry.thirdCategoryEntries || []).length} 个三级分类
{(storeEntry.thirdCategoryEntries || []).length} 个{thirdCategoryLabel}
</span>
</div>
</div>
@ -3451,7 +3654,7 @@ export function AgentWorkflowSettingsClient({
);
}
}}
placeholder={`给店铺“${storeEntry.storeName}”添加三级分类`}
placeholder={`给店铺“${storeEntry.storeName}”添加${thirdCategoryLabel}`}
/>
<Button
type="button"
@ -3465,7 +3668,7 @@ export function AgentWorkflowSettingsClient({
variant="outline"
>
<Plus className="mr-2 h-4 w-4" />
添加三级分类
添加{thirdCategoryLabel}
</Button>
</div>
@ -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}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<span className="text-xs text-muted-foreground">
{(thirdEntry.fourthCategoryEntries || []).length} 个四级分类
{(thirdEntry.fourthCategoryEntries || []).length} 个{fourthCategoryLabel}
</span>
</div>
</div>
@ -3527,7 +3730,7 @@ export function AgentWorkflowSettingsClient({
);
}
}}
placeholder={`给三级分类“${thirdEntry.categoryName}”添加四级分类`}
placeholder={`给${thirdCategoryLabel}“${thirdEntry.categoryName}”添加${fourthCategoryLabel}`}
/>
<Button
type="button"
@ -3542,7 +3745,7 @@ export function AgentWorkflowSettingsClient({
variant="outline"
>
<Plus className="mr-2 h-4 w-4" />
添加四级分类
添加{fourthCategoryLabel}
</Button>
</div>
@ -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}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<span className="text-xs text-muted-foreground">
{(fourthEntry.fifthCategories || []).length} 个五级分类
{(fourthEntry.fifthCategories || []).length} 个{fifthCategoryLabel}
</span>
</div>
</div>
@ -3608,7 +3811,7 @@ export function AgentWorkflowSettingsClient({
);
}
}}
placeholder={`给四级分类“${fourthEntry.categoryName}”添加五级分类`}
placeholder={`给${fourthCategoryLabel}“${fourthEntry.categoryName}”添加${fifthCategoryLabel}`}
/>
<Button
type="button"
@ -3624,7 +3827,7 @@ export function AgentWorkflowSettingsClient({
variant="outline"
>
<Plus className="mr-2 h-4 w-4" />
添加五级分类
添加{fifthCategoryLabel}
</Button>
</div>
@ -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}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@ -3659,7 +3862,7 @@ export function AgentWorkflowSettingsClient({
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed bg-background/70 px-3 py-4 text-sm text-muted-foreground">
这个四级分类下还没有添加任何五级分类。
这个{fourthCategoryLabel}下还没有添加任何{fifthCategoryLabel}。
</div>
)}
</div>
@ -3670,7 +3873,7 @@ export function AgentWorkflowSettingsClient({
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed bg-background/70 px-3 py-4 text-sm text-muted-foreground">
这个三级分类下还没有添加任何四级分类。
这个{thirdCategoryLabel}下还没有添加任何{fourthCategoryLabel}。
</div>
)}
</div>
@ -3680,7 +3883,7 @@ export function AgentWorkflowSettingsClient({
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed bg-background/70 px-3 py-4 text-sm text-muted-foreground">
这个店铺下还没有添加任何三级分类。
这个店铺下还没有添加任何{thirdCategoryLabel}。
</div>
)}
</div>
@ -3699,7 +3902,7 @@ export function AgentWorkflowSettingsClient({
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed bg-background/70 px-3 py-4 text-sm text-muted-foreground">
还没有添加品牌。先添加品牌,再在每个品牌下继续添加店铺以及三级到五级分类。
还没有添加品牌。先添加品牌,再在每个品牌下继续添加店铺以及{thirdCategoryLabel}到{fifthCategoryLabel}。
</div>
)}
</div>

View File

@ -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<AgentPsdTemplateListResponse>("/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<AgentPsdFontListResponse>(
"/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<AgentPsdTemplateMode>("main");
const [uploadSecondaryCategory, setUploadSecondaryCategory] = useState("");
const [uploadDescription, setUploadDescription] = useState("");
const [uploadFile, setUploadFile] = useState<File | null>(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({
)}
</div>
<div className="space-y-2">
<Label htmlFor="psdTemplateSecondaryCategory">二级分类</Label>
<Input
id="psdTemplateSecondaryCategory"
value={uploadSecondaryCategory}
onChange={(event) => setUploadSecondaryCategory(event.target.value)}
placeholder="例如:凳子类 / 升降桌类"
/>
<Label>套版模式</Label>
<Select
value={uploadMode}
onValueChange={(value) => setUploadMode(value === "sku" ? "sku" : "main")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PSD_TEMPLATE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
开启前台模板二级分类后,会按这里的分类分组展示模板。
{PSD_TEMPLATE_MODE_OPTIONS.find((option) => option.value === uploadMode)?.description}
</p>
</div>
<div className="space-y-2">
<Label>二级分类</Label>
{uploadBrandCategoryOptions.length > 0 ? (
<Select
value={uploadSecondaryCategory.trim() || EMPTY_CATEGORY_SELECT_VALUE}
onValueChange={(value) =>
setUploadSecondaryCategory(
value === EMPTY_CATEGORY_SELECT_VALUE ? "" : value
)
}
>
<SelectTrigger>
<SelectValue placeholder="请选择模板分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_CATEGORY_SELECT_VALUE}>不设置分类</SelectItem>
{categoryLevelOptions.map((group) => {
const groupOptions = uploadBrandCategoryOptions.filter(
(option) => option.level === group.key
);
if (groupOptions.length === 0) {
return null;
}
return (
<SelectGroup key={group.key}>
<SelectLabel>{group.label}</SelectLabel>
{groupOptions.map((option) => (
<SelectItem key={`${group.key}-${option.value}`} value={option.value}>
{option.value}
</SelectItem>
))}
</SelectGroup>
);
})}
{uploadBrandCategoryOptions.some((option) => option.level === "custom") ? (
<SelectGroup>
<SelectLabel>当前值</SelectLabel>
{uploadBrandCategoryOptions
.filter((option) => option.level === "custom")
.map((option) => (
<SelectItem key={`custom-${option.value}`} value={option.value}>
{option.value}
</SelectItem>
))}
</SelectGroup>
) : null}
</SelectContent>
</Select>
) : (
<Input
id="psdTemplateSecondaryCategory"
value={uploadSecondaryCategory}
onChange={(event) => setUploadSecondaryCategory(event.target.value)}
placeholder="例如:凳子类 / 升降桌类"
/>
)}
<p className="text-xs text-muted-foreground">
会优先读取当前品牌下已配置的分类层级作为选项;开启前台模板二级分类后,会按这里的分类分组展示模板。
</p>
</div>
<div className="space-y-2">
@ -940,6 +1095,9 @@ export function PsdTemplatesSettingsClient({
<div className="mt-1 text-xs text-muted-foreground">
品牌:{template.brandName}
</div>
<div className="mt-1 text-xs text-muted-foreground">
模式:{getPsdTemplateModeLabel(template.mode)}
</div>
{template.secondaryCategory ? (
<div className="mt-1 text-xs text-muted-foreground">
二级分类:{template.secondaryCategory}
@ -1175,19 +1333,109 @@ export function PsdTemplatesSettingsClient({
)}
</div>
<div className="space-y-2">
<Label htmlFor="draftTemplateSecondaryCategory">二级分类</Label>
<Input
id="draftTemplateSecondaryCategory"
value={draftTemplate.secondaryCategory || ""}
onChange={(event) =>
<Label>套版模式</Label>
<Select
value={draftTemplate.mode}
onValueChange={(value) =>
setDraftTemplate((current) =>
current
? { ...current, secondaryCategory: event.target.value }
? { ...current, mode: value === "sku" ? "sku" : "main" }
: current
)
}
placeholder="例如:凳子类 / 升降桌类"
/>
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PSD_TEMPLATE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>二级分类</Label>
{draftBrandCategoryOptions.length > 0 ? (
<Select
value={
String(draftTemplate.secondaryCategory || "").trim() ||
EMPTY_CATEGORY_SELECT_VALUE
}
onValueChange={(value) =>
setDraftTemplate((current) =>
current
? {
...current,
secondaryCategory:
value === EMPTY_CATEGORY_SELECT_VALUE ? "" : value,
}
: current
)
}
>
<SelectTrigger>
<SelectValue placeholder="请选择模板分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_CATEGORY_SELECT_VALUE}>不设置分类</SelectItem>
{categoryLevelOptions.map((group) => {
const groupOptions = draftBrandCategoryOptions.filter(
(option) => option.level === group.key
);
if (groupOptions.length === 0) {
return null;
}
return (
<SelectGroup key={group.key}>
<SelectLabel>{group.label}</SelectLabel>
{groupOptions.map((option) => (
<SelectItem
key={`${group.key}-${option.value}`}
value={option.value}
>
{option.value}
</SelectItem>
))}
</SelectGroup>
);
})}
{draftBrandCategoryOptions.some(
(option) => option.level === "custom"
) ? (
<SelectGroup>
<SelectLabel>当前值</SelectLabel>
{draftBrandCategoryOptions
.filter((option) => option.level === "custom")
.map((option) => (
<SelectItem
key={`draft-custom-${option.value}`}
value={option.value}
>
{option.value}
</SelectItem>
))}
</SelectGroup>
) : null}
</SelectContent>
</Select>
) : (
<Input
id="draftTemplateSecondaryCategory"
value={draftTemplate.secondaryCategory || ""}
onChange={(event) =>
setDraftTemplate((current) =>
current
? { ...current, secondaryCategory: event.target.value }
: current
)
}
placeholder="例如:凳子类 / 升降桌类"
/>
)}
</div>
</div>
@ -1240,6 +1488,7 @@ export function PsdTemplatesSettingsClient({
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary">品牌:{draftTemplate.brandName}</Badge>
<Badge variant="secondary">模式:{getPsdTemplateModeLabel(draftTemplate.mode)}</Badge>
{draftTemplate.secondaryCategory ? (
<Badge variant="secondary">分类:{draftTemplate.secondaryCategory}</Badge>
) : null}

View File

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

View File

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

View File

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

View File

@ -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(),

View File

@ -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<string | null>(null);
const [sceneResizeEditorTargetUrl, setSceneResizeEditorTargetUrl] = useState<string | null>(null);
const [psdTemplateDialogSourceUrl, setPsdTemplateDialogSourceUrl] = useState<string | null>(null);
const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState<AgentPsdTemplateMode>("main");
const [batchDownloadState, setBatchDownloadState] = useState<null | "scene" | "detail" | "sku">(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}
<ClientErrorBoundary
resetKeys={[psdTemplateDialogSourceUrl || "", workflowContext?.brand || ""]}
resetKeys={[psdTemplateDialogSourceUrl || "", workflowContext?.brand || "", psdTemplateDialogMode]}
fallback={
<Dialog
open={Boolean(psdTemplateDialogSourceUrl)}
onOpenChange={(open) => {
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}
/>
</ClientErrorBoundary>

View File

@ -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"
}
>
<div
className={
compact
? "flex h-12 w-12 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/5"
: "flex h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/5"
: "flex h-[46px] w-[46px] shrink-0 overflow-hidden rounded-lg border border-white/10 bg-white/5"
}
>
<img
@ -150,11 +150,11 @@ function renderTemplateSelectContent(
/>
</div>
<div className="min-w-0 text-left">
<div className={compact ? "truncate text-sm font-medium leading-tight" : "truncate text-base font-semibold leading-tight"}>
<div className={compact ? "truncate text-sm font-medium leading-tight" : "truncate text-[15px] font-semibold leading-tight"}>
{template.name}
</div>
{metaLabel ? (
<div className={compact ? "mt-1 truncate text-[11px] text-white/45" : "mt-1 truncate text-xs text-white/45"}>
<div className={compact ? "mt-1 truncate text-[11px] text-white/45" : "mt-0.5 truncate text-[11px] text-white/45"}>
{metaLabel}
</div>
) : 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<AgentPsdTemplateListResponse>(
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 ? (
<div className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.03] p-3.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Move className="h-3.5 w-3.5" />
选中画布中的图片后可直接拖动位置
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<ZoomIn className="h-3.5 w-3.5" />
缩放
</div>
<span className="rounded-full border border-emerald-300/35 bg-emerald-500/15 px-2 py-0.5 font-medium text-emerald-100">
{Math.round(selectedImagePlacement.scale * 100)}%
</span>
</div>
<div className="rounded-xl border border-white/12 bg-black/45 p-2.5">
<div className="grid grid-cols-[34px_minmax(0,1fr)_34px] items-center gap-2">
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/[0.04] text-white transition-colors hover:bg-white/[0.12]"
onClick={() => nudgeImageScale(selectedImageLayer.id, -0.05)}
aria-label="缩小图片"
title="缩小"
>
<ZoomOut className="h-4 w-4" />
</button>
<Slider
min={MIN_IMAGE_SCALE}
max={MAX_IMAGE_SCALE}
step={0.01}
value={[selectedImagePlacement.scale]}
className="py-1"
trackClassName="h-2.5 rounded-full border border-white/20 bg-[#0f1014]"
rangeClassName="bg-gradient-to-r from-emerald-300 via-green-400 to-teal-400 shadow-[0_0_12px_rgba(16,185,129,0.38)]"
thumbClassName="h-5 w-5 border-2 border-emerald-100 bg-[#0a0a0d] shadow-[0_0_0_5px_rgba(16,185,129,0.2)]"
onValueChange={(value) => {
const newScale = clampImageScale(value[0] || 1);
updateImagePlacement(selectedImageLayer.id, (current) => ({
...current,
scale: newScale,
}));
}}
/>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/[0.04] text-white transition-colors hover:bg-white/[0.12]"
onClick={() => nudgeImageScale(selectedImageLayer.id, 0.05)}
aria-label="放大图片"
title="放大"
>
<ZoomIn className="h-4 w-4" />
</button>
</div>
<div className="mt-2 flex items-center justify-between px-0.5 text-[10px] text-white/45">
<span>40%</span>
<span className="text-emerald-200">100%</span>
<span>300%</span>
</div>
</div>
</div>
<div className="flex justify-end">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{sourceImageUrl ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-10 justify-center"
onClick={() => {
setImageAssignments((current) => ({
...current,
[selectedImageLayer.id]: sourceImageUrl,
}));
setImagePlacements((current) => ({
...current,
[selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(),
}));
setImageSizes((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
}}
>
使用当前图片
</Button>
) : null}
<Button
type="button"
variant="ghost"
variant="outline"
size="sm"
className="px-2 text-white/70 hover:text-white"
onClick={() => resetImagePlacement(selectedImageLayer.id)}
className="h-10 justify-center"
disabled={uploadingSlotId === selectedImageLayer.id}
onClick={() => openImageUpload(selectedImageLayer.id)}
>
重置位置与缩放
{uploadingSlotId === selectedImageLayer.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传替换图片
</Button>
</div>
{selectedImageAssignmentUrl ? (
<>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 text-white/70 hover:text-white"
onClick={() => {
setImageAssignments((current) => {
const nextAssignments = { ...current };
delete nextAssignments[selectedImageLayer.id];
return nextAssignments;
});
setImagePlacements((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
setImageSizes((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
}}
>
清空当前替换图
</Button>
</div>
<div className="h-px bg-white/10" />
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<ZoomIn className="h-3.5 w-3.5" />
缩放
</div>
<span className="rounded-full border border-emerald-300/35 bg-emerald-500/15 px-2 py-0.5 font-medium text-emerald-100">
{Math.round(selectedImagePlacement.scale * 100)}%
</span>
</div>
<div className="rounded-xl border border-white/12 bg-black/45 p-2.5">
<div className="grid grid-cols-[34px_minmax(0,1fr)_34px] items-center gap-2">
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/[0.04] text-white transition-colors hover:bg-white/[0.12]"
onClick={() => nudgeImageScale(selectedImageLayer.id, -0.05)}
aria-label="缩小图片"
title="缩小"
>
<ZoomOut className="h-4 w-4" />
</button>
<Slider
min={MIN_IMAGE_SCALE}
max={MAX_IMAGE_SCALE}
step={0.01}
value={[selectedImagePlacement.scale]}
className="py-1"
trackClassName="h-2.5 rounded-full border border-white/20 bg-[#0f1014]"
rangeClassName="bg-gradient-to-r from-emerald-300 via-green-400 to-teal-400 shadow-[0_0_12px_rgba(16,185,129,0.38)]"
thumbClassName="h-5 w-5 border-2 border-emerald-100 bg-[#0a0a0d] shadow-[0_0_0_5px_rgba(16,185,129,0.2)]"
onValueChange={(value) => {
const newScale = clampImageScale(value[0] || 1);
updateImagePlacement(selectedImageLayer.id, (current) => ({
...current,
scale: newScale,
}));
}}
/>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/[0.04] text-white transition-colors hover:bg-white/[0.12]"
onClick={() => nudgeImageScale(selectedImageLayer.id, 0.05)}
aria-label="放大图片"
title="放大"
>
<ZoomIn className="h-4 w-4" />
</button>
</div>
<div className="mt-2 flex items-center justify-between px-0.5 text-[10px] text-white/45">
<span>40%</span>
<span className="text-emerald-200">100%</span>
<span>300%</span>
</div>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 text-white/70 hover:text-white"
onClick={() => resetImagePlacement(selectedImageLayer.id)}
>
重置位置与缩放
</Button>
</div>
</>
) : null}
</div>
) : null;
const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]);
@ -1154,7 +1244,7 @@ export function AgentPsdTemplateEditorDialog({
<Label>选择模板</Label>
{hasTemplateOptions ? (
<Select value={selectedTemplateSelectValue} onValueChange={setSelectedTemplateId}>
<SelectTrigger className="h-auto min-h-[84px] items-center px-3 py-3 [&>span]:contents">
<SelectTrigger className="h-auto min-h-[58px] items-center px-2.5 py-2 [&>span]:contents">
{selectedTemplate ? (
<div className="min-w-0 flex-1 pr-3">
{renderTemplateSelectContent(selectedTemplate, {
@ -1685,166 +1775,82 @@ export function AgentPsdTemplateEditorDialog({
</Button>
</div>
</div>
) : (
) : selectedTextLayer ? (
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-3.5 xl:p-4">
{selectedImageLayer ? (
<div className="flex h-full flex-col gap-3">
<div className="rounded-xl border border-white/10 bg-white/[0.02] p-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{sourceImageUrl ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-10 justify-center"
onClick={() => {
setImageAssignments((current) => ({
...current,
[selectedImageLayer.id]: sourceImageUrl,
}));
setImagePlacements((current) => ({
...current,
[selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(),
}));
setImageSizes((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
}}
>
使用当前图片
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
className="h-10 justify-center"
disabled={uploadingSlotId === selectedImageLayer.id}
onClick={() => openImageUpload(selectedImageLayer.id)}
>
{uploadingSlotId === selectedImageLayer.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传替换图片
</Button>
</div>
<div className="mt-2 flex items-center justify-end gap-2">
{effectiveImageAssignments[selectedImageLayer.id] ? (
<Button
type="button"
variant="ghost"
size="sm"
className="px-2 text-white/70 hover:text-white"
onClick={() => {
setImageAssignments((current) => {
const nextAssignments = { ...current };
delete nextAssignments[selectedImageLayer.id];
return nextAssignments;
});
setImagePlacements((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
setImageSizes((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
}}
>
清空当前替换图
</Button>
) : null}
</div>
</div>
<div className="flex h-full flex-col gap-3">
<Textarea
rows={4}
value={effectiveTextAssignments[selectedTextLayer.id] || ""}
placeholder={selectedTextLayer.placeholder || "请输入文字"}
className="min-h-[180px] flex-1 resize-none xl:min-h-[200px]"
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[selectedTextLayer.id]: event.target.value,
}))
}
/>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>字体:{selectedTextLayer.fontLabel || selectedTextLayer.fontFamily || "系统字体"}</span>
<span>字号:{selectedTextLayer.fontSize}px</span>
<span>行高:{selectedTextLayer.lineHeight}</span>
<span>字距:{selectedTextLayer.letterSpacing}px</span>
</div>
) : selectedTextLayer ? (
<div className="flex h-full flex-col gap-3">
<Textarea
rows={4}
value={effectiveTextAssignments[selectedTextLayer.id] || ""}
placeholder={selectedTextLayer.placeholder || "请输入文字"}
className="min-h-[180px] flex-1 resize-none xl:min-h-[200px]"
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[selectedTextLayer.id]: event.target.value,
}))
}
/>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>字体:{selectedTextLayer.fontLabel || selectedTextLayer.fontFamily || "系统字体"}</span>
<span>字号:{selectedTextLayer.fontSize}px</span>
<span>行高:{selectedTextLayer.lineHeight}</span>
<span>字距:{selectedTextLayer.letterSpacing}px</span>
</div>
{textColorConfig.enabled ? (
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">文字颜色</div>
<div className="text-xs text-muted-foreground">
当前模板允许在两种系统预设颜色之间切换。
</div>
{textColorConfig.enabled ? (
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">文字颜色</div>
<div className="text-xs text-muted-foreground">
当前模板允许在两种系统预设颜色之间切换。
</div>
<div
className="h-8 w-8 rounded-full border border-white/15"
style={{ backgroundColor: selectedTextColor }}
/>
</div>
<div className="grid grid-cols-2 gap-2">
{textColorConfig.colors.map((color, index) => {
const isActive =
getResolvedTextLayerColor(selectedTextLayer, color) ===
getResolvedTextLayerColor(selectedTextLayer, selectedTextColor);
return (
<button
key={`text-color-option-${index + 1}`}
type="button"
onClick={() =>
setTextColorAssignments((current) => ({
...current,
[selectedTextLayer.id]: color,
}))
}
className={`flex items-center gap-3 rounded-xl border px-3 py-3 text-left transition ${
isActive
? "border-emerald-400/55 bg-emerald-500/[0.12] text-white"
: "border-white/10 bg-white/[0.02] text-white/80 hover:bg-white/[0.06]"
}`}
>
<span
className="h-6 w-6 rounded-full border border-white/20"
style={{ backgroundColor: color }}
/>
<span className="flex min-w-0 flex-col">
<span className="text-xs font-medium">{`颜色 ${index + 1}`}</span>
<span className="font-mono text-[11px] uppercase text-white/55">
{color}
</span>
</span>
</button>
);
})}
</div>
<div
className="h-8 w-8 rounded-full border border-white/15"
style={{ backgroundColor: selectedTextColor }}
/>
</div>
<div className="grid grid-cols-2 gap-2">
{textColorConfig.colors.map((color, index) => {
const isActive =
getResolvedTextLayerColor(selectedTextLayer, color) ===
getResolvedTextLayerColor(selectedTextLayer, selectedTextColor);
return (
<button
key={`text-color-option-${index + 1}`}
type="button"
onClick={() =>
setTextColorAssignments((current) => ({
...current,
[selectedTextLayer.id]: color,
}))
}
className={`flex items-center gap-3 rounded-xl border px-3 py-3 text-left transition ${
isActive
? "border-emerald-400/55 bg-emerald-500/[0.12] text-white"
: "border-white/10 bg-white/[0.02] text-white/80 hover:bg-white/[0.06]"
}`}
>
<span
className="h-6 w-6 rounded-full border border-white/20"
style={{ backgroundColor: color }}
/>
<span className="flex min-w-0 flex-col">
<span className="text-xs font-medium">{`颜色 ${index + 1}`}</span>
<span className="font-mono text-[11px] uppercase text-white/55">
{color}
</span>
</span>
</button>
);
})}
</div>
) : null}
</div>
) : (
<div className="flex min-h-[160px] flex-1 flex-col items-center justify-center text-center">
<MousePointerClick className="h-8 w-8 text-white/35" />
<div className="mt-3 text-sm font-medium">点模板中的图层开始编辑</div>
<div className="mt-2 text-xs leading-6 text-muted-foreground">
图片区域可直接替换图片,文字区域可直接改现有文字。
</div>
</div>
)}
) : null}
</div>
</div>
) : (
<div className="flex-1" />
)}
</div>
</div>

View File

@ -9,6 +9,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
getAgentWorkflowCategoryLabels,
defaultAgentWorkflowUiConfig,
flattenAgentWorkflowSteps,
flattenVisibleAgentWorkflowSteps,
@ -278,8 +279,15 @@ export function AgentWorkflowBrandStorePanel({
onInputsChange,
theme = "dark",
title = "品牌层级选择",
description = "先选择品牌、店铺到五级分类和价格带,后续品牌模特与风格步骤会按这里的选择联动。",
description = "先选择品牌、店铺、分类层级和价格带,后续品牌模特与风格步骤会按这里的选择联动。",
}: AgentWorkflowBrandStorePanelProps) {
const categoryLabels = useMemo(
() => getAgentWorkflowCategoryLabels(config),
[config]
);
const thirdCategoryLabel = categoryLabels.thirdCategory;
const fourthCategoryLabel = categoryLabels.fourthCategory;
const fifthCategoryLabel = categoryLabels.fifthCategory;
const brandOptions = useMemo(
() => config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
[config?.inputOptions?.brands]
@ -470,9 +478,9 @@ export function AgentWorkflowBrandStorePanel({
<SearchableSelect
value={inputs.thirdCategory}
options={thirdCategoryOptions}
placeholder="选择三级分类"
searchPlaceholder="输入三级分类筛选"
emptyText={inputs.store ? "当前店铺下暂无三级分类" : "请先选择店铺"}
placeholder={`选择${thirdCategoryLabel}`}
searchPlaceholder={`输入${thirdCategoryLabel}筛选`}
emptyText={inputs.store ? `当前店铺下暂无${thirdCategoryLabel}` : "请先选择店铺"}
theme={theme}
disabled={!inputs.store || thirdCategoryOptions.length === 0}
onValueChange={(value) =>
@ -492,9 +500,13 @@ export function AgentWorkflowBrandStorePanel({
<SearchableSelect
value={inputs.fourthCategory}
options={fourthCategoryOptions}
placeholder="选择四级分类"
searchPlaceholder="输入四级分类筛选"
emptyText={inputs.thirdCategory ? "当前三级分类下暂无四级分类" : "请先选择三级分类"}
placeholder={`选择${fourthCategoryLabel}`}
searchPlaceholder={`输入${fourthCategoryLabel}筛选`}
emptyText={
inputs.thirdCategory
? `当前${thirdCategoryLabel}下暂无${fourthCategoryLabel}`
: `请先选择${thirdCategoryLabel}`
}
theme={theme}
disabled={!inputs.thirdCategory || fourthCategoryOptions.length === 0}
onValueChange={(value) =>
@ -513,9 +525,13 @@ export function AgentWorkflowBrandStorePanel({
<SearchableSelect
value={inputs.fifthCategory}
options={fifthCategoryOptions}
placeholder="选择五级分类"
searchPlaceholder="输入五级分类筛选"
emptyText={inputs.fourthCategory ? "当前四级分类下暂无五级分类" : "请先选择四级分类"}
placeholder={`选择${fifthCategoryLabel}`}
searchPlaceholder={`输入${fifthCategoryLabel}筛选`}
emptyText={
inputs.fourthCategory
? `当前${fourthCategoryLabel}下暂无${fifthCategoryLabel}`
: `请先选择${fourthCategoryLabel}`
}
theme={theme}
disabled={!inputs.fourthCategory || fifthCategoryOptions.length === 0}
onValueChange={(value) => onInputsChange({ ...inputs, fifthCategory: value })}

View File

@ -10,6 +10,7 @@ export type AgentPsdTemplateParseStatus =
| "processing"
| "completed"
| "failed";
export type AgentPsdTemplateMode = "main" | "sku";
export type AgentPsdTemplateReplaceMode = "cover" | "contain" | "stretch";
export type AgentPsdTemplateSlotType = "image" | "text";
export type AgentPsdTextAlign = "left" | "center" | "right";
@ -83,6 +84,7 @@ export interface AgentPsdTemplate {
id: string;
name: string;
brandName: string;
mode: AgentPsdTemplateMode;
secondaryCategory?: string;
description?: string;
status: AgentPsdTemplateStatus;
@ -188,6 +190,10 @@ export function normalizeAgentPsdTemplateStatus(value: unknown): AgentPsdTemplat
return normalizeText(value) === "active" ? "active" : "disabled";
}
export function normalizeAgentPsdTemplateMode(value: unknown): AgentPsdTemplateMode {
return normalizeText(value) === "sku" ? "sku" : "main";
}
export function normalizeAgentPsdTemplateParseStatus(
value: unknown
): AgentPsdTemplateParseStatus {
@ -442,6 +448,7 @@ export function parseAgentPsdTemplates(value: unknown) {
id,
name,
brandName,
mode: normalizeAgentPsdTemplateMode(record.mode),
secondaryCategory: normalizeText(record.secondaryCategory) || undefined,
description: normalizeText(record.description) || undefined,
status: normalizeAgentPsdTemplateStatus(record.status),

View File

@ -1,10 +1,14 @@
import Papa from "papaparse";
import type {
AgentWorkflowBrandStoreEntry,
AgentWorkflowCategoryLabels,
AgentWorkflowFourthCategoryEntry,
AgentWorkflowStoreEntry,
AgentWorkflowThirdCategoryEntry,
} from "@/types/agent";
import {
normalizeAgentWorkflowCategoryLabels,
} from "@/lib/agents/workflow-ui-config";
type SpreadsheetRecord = Record<string, unknown>;
@ -14,15 +18,7 @@ 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 = {
const STATIC_HEADER_ALIASES = {
brandName: ["品牌", "品牌名称", "brand", "brandname"],
storeName: ["店铺", "店铺名称", "shop", "shopname", "store", "storename"],
thirdCategory: ["三级分类", "三级类目", "thirdcategory", "third"],
@ -61,6 +57,34 @@ const TEMPLATE_SAMPLE_ROWS = [
},
];
function getTemplateHeaders(labels?: Partial<AgentWorkflowCategoryLabels>) {
const resolvedLabels = normalizeAgentWorkflowCategoryLabels(labels);
return {
brandName: "品牌",
storeName: "店铺",
thirdCategory: resolvedLabels.thirdCategory,
fourthCategory: resolvedLabels.fourthCategory,
fifthCategory: resolvedLabels.fifthCategory,
} as const;
}
function getHeaderAliases(labels?: Partial<AgentWorkflowCategoryLabels>) {
const headers = getTemplateHeaders(labels);
return {
brandName: STATIC_HEADER_ALIASES.brandName,
storeName: STATIC_HEADER_ALIASES.storeName,
thirdCategory: Array.from(
new Set([headers.thirdCategory, ...STATIC_HEADER_ALIASES.thirdCategory].filter(Boolean))
),
fourthCategory: Array.from(
new Set([headers.fourthCategory, ...STATIC_HEADER_ALIASES.fourthCategory].filter(Boolean))
),
fifthCategory: Array.from(
new Set([headers.fifthCategory, ...STATIC_HEADER_ALIASES.fifthCategory].filter(Boolean))
),
} as const;
}
function normalizeCell(value: unknown, limit = 64) {
const normalized = String(value ?? "")
.replace(/\s+/g, " ")
@ -172,17 +196,21 @@ export interface ParsedAgentWorkflowBrandImportResult {
rowCount: number;
}
export async function downloadAgentWorkflowBrandImportTemplate() {
export async function downloadAgentWorkflowBrandImportTemplate(
labels?: Partial<AgentWorkflowCategoryLabels>
) {
const { Workbook } = await import("exceljs");
const templateHeaders = getTemplateHeaders(labels);
const resolvedLabels = normalizeAgentWorkflowCategoryLabels(labels);
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 },
{ header: templateHeaders.brandName, key: "brandName", width: 20 },
{ header: templateHeaders.storeName, key: "storeName", width: 28 },
{ header: templateHeaders.thirdCategory, key: "thirdCategory", width: 18 },
{ header: templateHeaders.fourthCategory, key: "fourthCategory", width: 18 },
{ header: templateHeaders.fifthCategory, key: "fifthCategory", width: 18 },
];
sheet.getRow(1).font = { bold: true };
sheet.views = [{ state: "frozen", ySplit: 1 }];
@ -214,21 +242,21 @@ export async function downloadAgentWorkflowBrandImportTemplate() {
example: "天猫朴美家居旗舰店",
},
{
field: "三级分类",
field: resolvedLabels.thirdCategory,
required: "否",
description: "填写后会挂到对应店铺下;如果不填,四级/五级也必须留空。",
description: `填写后会挂到对应店铺下;如果不填,${resolvedLabels.fourthCategory}/${resolvedLabels.fifthCategory}也必须留空。`,
example: "桌类",
},
{
field: "四级分类",
field: resolvedLabels.fourthCategory,
required: "否",
description: "填写后会挂到对应三级分类下;如果不填,五级也必须留空。",
description: `填写后会挂到对应${resolvedLabels.thirdCategory}下;如果不填,${resolvedLabels.fifthCategory}也必须留空。`,
example: "餐桌",
},
{
field: "五级分类",
field: resolvedLabels.fifthCategory,
required: "否",
description: "填写后会挂到对应四级分类下。",
description: `填写后会挂到对应${resolvedLabels.fourthCategory}下。`,
example: "岩板餐桌",
},
]);
@ -241,8 +269,12 @@ export async function downloadAgentWorkflowBrandImportTemplate() {
}
export async function parseAgentWorkflowBrandImportFile(
file: File
file: File,
labels?: Partial<AgentWorkflowCategoryLabels>
): Promise<ParsedAgentWorkflowBrandImportResult> {
const templateHeaders = getTemplateHeaders(labels);
const headerAliases = getHeaderAliases(labels);
const resolvedLabels = normalizeAgentWorkflowCategoryLabels(labels);
const extension = file.name.toLowerCase().split(".").pop() || "";
if (!["xls", "xlsx", "csv"].includes(extension)) {
throw new Error("仅支持 xls、xlsx、csv 文件");
@ -255,16 +287,16 @@ export async function parseAgentWorkflowBrandImportFile(
const firstRecord = records[0];
const brandColumn =
matchColumn(firstRecord, HEADER_ALIASES.brandName) ||
Object.keys(firstRecord).find((key) => normalizeHeader(key) === normalizeHeader(TEMPLATE_HEADERS.brandName));
matchColumn(firstRecord, headerAliases.brandName) ||
Object.keys(firstRecord).find((key) => normalizeHeader(key) === normalizeHeader(templateHeaders.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 storeColumn = matchColumn(firstRecord, headerAliases.storeName);
const thirdColumn = matchColumn(firstRecord, headerAliases.thirdCategory);
const fourthColumn = matchColumn(firstRecord, headerAliases.fourthCategory);
const fifthColumn = matchColumn(firstRecord, headerAliases.fifthCategory);
const brandEntries: AgentWorkflowBrandStoreEntry[] = [];
const brandLookup = new Map<string, AgentWorkflowBrandStoreEntry>();
@ -347,10 +379,14 @@ export async function parseAgentWorkflowBrandImportFile(
throw new Error(`第 ${lineNumber} 行缺少店铺名称,不能直接填写分类`);
}
if (!thirdCategory && (fourthCategory || fifthCategory)) {
throw new Error(`第 ${lineNumber} 行缺少三级分类,不能直接填写四级或五级分类`);
throw new Error(
`第 ${lineNumber} 行缺少${resolvedLabels.thirdCategory},不能直接填写${resolvedLabels.fourthCategory}或${resolvedLabels.fifthCategory}`
);
}
if (!fourthCategory && fifthCategory) {
throw new Error(`第 ${lineNumber} 行缺少四级分类,不能直接填写五级分类`);
throw new Error(
`第 ${lineNumber} 行缺少${resolvedLabels.fourthCategory},不能直接填写${resolvedLabels.fifthCategory}`
);
}
effectiveRowCount += 1;

View File

@ -1,5 +1,6 @@
import type {
AgentWorkflowBrandStoreEntry,
AgentWorkflowCategoryLabels,
AgentWorkflowFourthCategoryEntry,
AgentWorkflowOptionUiConfig,
AgentWorkflowStoreEntry,
@ -12,6 +13,17 @@ import type {
export const AGENT_WORKFLOW_UI_CONFIG_KEY = "agent_workflow_ui_config";
export const AGENT_WORKFLOW_MAX_TREE_DEPTH = 8;
export const AGENT_WORKFLOW_MAX_OPTION_DEPTH = 8;
export const defaultAgentWorkflowCategoryLabels: AgentWorkflowCategoryLabels = {
thirdCategory: "三级分类",
fourthCategory: "四级分类",
fifthCategory: "五级分类",
};
export interface AgentWorkflowBrandCategoryOption {
value: string;
level: "third" | "fourth" | "fifth";
levelLabel: string;
}
export interface FlattenedAgentWorkflowStep {
step: AgentWorkflowStepUiConfig;
@ -142,6 +154,7 @@ export const defaultAgentWorkflowUiConfig: AgentWorkflowUiConfig = {
inputOptions: {
brands: [],
stores: [],
categoryLabels: defaultAgentWorkflowCategoryLabels,
thirdCategories: [],
fourthCategories: [],
fifthCategories: [],
@ -156,6 +169,35 @@ export const defaultAgentWorkflowUiConfig: AgentWorkflowUiConfig = {
),
};
export function normalizeAgentWorkflowCategoryLabels(
value: unknown
): AgentWorkflowCategoryLabels {
const record =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const normalizeLabel = (candidate: unknown, fallback: string) => {
const text = normalizeText(candidate, 24);
return text || fallback;
};
return {
thirdCategory: normalizeLabel(
record.thirdCategory ?? record.thirdCategoryLabel,
defaultAgentWorkflowCategoryLabels.thirdCategory
),
fourthCategory: normalizeLabel(
record.fourthCategory ?? record.fourthCategoryLabel,
defaultAgentWorkflowCategoryLabels.fourthCategory
),
fifthCategory: normalizeLabel(
record.fifthCategory ?? record.fifthCategoryLabel,
defaultAgentWorkflowCategoryLabels.fifthCategory
),
};
}
export function flattenAgentWorkflowSteps(
steps: AgentWorkflowStepUiConfig[],
depth = 1,
@ -686,6 +728,9 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
const normalizedBrandStoreEntries = normalizeBrandStoreEntries(
rawInputOptions.brandStoreEntries
);
const normalizedCategoryLabels = normalizeAgentWorkflowCategoryLabels(
rawInputOptions.categoryLabels ?? rawInputOptions
);
const normalizedBrands = normalizeStringList(rawInputOptions.brands);
const normalizedStores = normalizeStringList(rawInputOptions.stores);
const normalizedThirdCategories = normalizeStringList(rawInputOptions.thirdCategories);
@ -921,6 +966,7 @@ export function normalizeAgentWorkflowUiConfig(value: unknown): AgentWorkflowUiC
)
)
: normalizedFifthCategories,
categoryLabels: normalizedCategoryLabels,
priceBands: normalizedPriceBands,
brandStoreEntries: fallbackBrandStoreEntries,
},
@ -932,6 +978,84 @@ export function getAgentWorkflowBrandStoreEntries(config?: AgentWorkflowUiConfig
return config?.inputOptions?.brandStoreEntries || defaultAgentWorkflowUiConfig.inputOptions?.brandStoreEntries || [];
}
export function getAgentWorkflowCategoryLabels(
config?: AgentWorkflowUiConfig | null
) {
return normalizeAgentWorkflowCategoryLabels(config?.inputOptions?.categoryLabels);
}
export function getAgentWorkflowCategoryOptionsByBrand(params: {
config?: AgentWorkflowUiConfig | null;
brand?: string | null;
}) {
const entries = getAgentWorkflowBrandStoreEntries(params.config);
const labels = getAgentWorkflowCategoryLabels(params.config);
const normalizedBrand = String(params.brand || "").trim().toLowerCase();
const options: AgentWorkflowBrandCategoryOption[] = [];
const seenValues = new Set<string>();
const pushOption = (
value: string,
level: AgentWorkflowBrandCategoryOption["level"],
levelLabel: string
) => {
const normalizedValue = String(value || "").trim();
if (!normalizedValue) {
return;
}
const dedupeKey = normalizedValue.toLowerCase();
if (seenValues.has(dedupeKey)) {
return;
}
seenValues.add(dedupeKey);
options.push({
value: normalizedValue,
level,
levelLabel,
});
};
if (normalizedBrand) {
const matchedEntry = entries.find(
(entry) => entry.brandName.trim().toLowerCase() === normalizedBrand
);
if (matchedEntry) {
for (const storeEntry of matchedEntry.storeEntries || []) {
for (const thirdEntry of storeEntry.thirdCategoryEntries || []) {
pushOption(thirdEntry.categoryName, "third", labels.thirdCategory);
for (const fourthEntry of thirdEntry.fourthCategoryEntries || []) {
pushOption(fourthEntry.categoryName, "fourth", labels.fourthCategory);
for (const fifthCategory of fourthEntry.fifthCategories || []) {
pushOption(fifthCategory, "fifth", labels.fifthCategory);
}
}
}
}
if (options.length > 0) {
return options;
}
}
}
for (const thirdCategory of params.config?.inputOptions?.thirdCategories || []) {
pushOption(thirdCategory, "third", labels.thirdCategory);
}
for (const fourthCategory of params.config?.inputOptions?.fourthCategories || []) {
pushOption(fourthCategory, "fourth", labels.fourthCategory);
}
for (const fifthCategory of params.config?.inputOptions?.fifthCategories || []) {
pushOption(fifthCategory, "fifth", labels.fifthCategory);
}
return options;
}
export function getAgentWorkflowStoreOptionsByBrand(params: {
config?: AgentWorkflowUiConfig | null;
brand?: string | null;

View File

@ -105,6 +105,7 @@ export interface AgentWorkflowUiConfig {
inputOptions?: {
brands: string[];
stores: string[];
categoryLabels?: AgentWorkflowCategoryLabels;
thirdCategories?: string[];
fourthCategories?: string[];
fifthCategories?: string[];
@ -114,6 +115,12 @@ export interface AgentWorkflowUiConfig {
steps: AgentWorkflowStepUiConfig[];
}
export interface AgentWorkflowCategoryLabels {
thirdCategory: string;
fourthCategory: string;
fifthCategory: string;
}
export interface AgentWorkflowFourthCategoryEntry {
categoryName: string;
fifthCategories: string[];