feat: align psd templates with brand category config
This commit is contained in:
parent
a90e28d115
commit
f4d488d104
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 })}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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[];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user