feat: 支持PSD模板品类标签筛选
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-03 14:30:42 +08:00
parent 5037a1a1eb
commit f6d8a4b8ea
9 changed files with 730 additions and 27 deletions

View File

@ -44,6 +44,12 @@ import {
getAgentWorkflowCategoryLabels,
getAgentWorkflowCategoryOptionsByBrand,
} from "@/lib/agents/workflow-ui-config";
import {
AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG,
getAgentPsdTemplateCategoryTagOptions,
normalizeAgentPsdTemplateCategoryTagConfig,
normalizeAgentPsdTemplateCategoryTags,
} from "@/lib/agents/psd-template-category-tag-config";
import type {
AgentPsdFont,
AgentPsdFontListResponse,
@ -98,6 +104,30 @@ function normalizeManagedBrandName(value?: string | null) {
return normalized;
}
function hasTemplateCategoryTag(tags: string[] | undefined, target: string) {
const normalizedTarget = String(target || "").trim().toLowerCase();
if (!normalizedTarget) {
return false;
}
return (tags || []).some((tag) => tag.trim().toLowerCase() === normalizedTarget);
}
function toggleTemplateCategoryTag(tags: string[] | undefined, target: string) {
const normalizedTags = normalizeAgentPsdTemplateCategoryTags(tags);
const normalizedTarget = normalizeAgentPsdTemplateCategoryTags([target])[0];
if (!normalizedTarget) {
return normalizedTags;
}
if (hasTemplateCategoryTag(normalizedTags, normalizedTarget)) {
return normalizedTags.filter((tag) => tag.trim().toLowerCase() !== normalizedTarget.toLowerCase());
}
return normalizeAgentPsdTemplateCategoryTags([...normalizedTags, normalizedTarget]);
}
function cloneTemplate(template: AgentPsdTemplate | null) {
return template ? (JSON.parse(JSON.stringify(template)) as AgentPsdTemplate) : null;
}
@ -178,8 +208,11 @@ export function PsdTemplatesSettingsClient({
backLabel: string;
}) {
const { data, mutate } = useSWR<AgentPsdTemplateListResponse>("/api/admin/psd-templates", fetcher);
const { data: siteSettingsData } = useSWR<{
const { data: siteSettingsData, mutate: mutateSiteSettings } = useSWR<{
agentWorkflowUiConfig?: AgentWorkflowUiConfig;
agentPsdTemplateCategoryTagConfig?: {
tags?: string[];
};
}>("/api/site-settings", fetcher);
const { data: fontsData, mutate: mutateFonts } = useSWR<AgentPsdFontListResponse>(
"/api/admin/psd-fonts",
@ -218,6 +251,7 @@ export function PsdTemplatesSettingsClient({
const [uploadBrandName, setUploadBrandName] = useState("");
const [uploadMode, setUploadMode] = useState<AgentPsdTemplateMode>("main");
const [uploadSecondaryCategory, setUploadSecondaryCategory] = useState("");
const [uploadCategoryTags, setUploadCategoryTags] = useState<string[]>([]);
const [uploadDescription, setUploadDescription] = useState("");
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [fontUploadName, setFontUploadName] = useState("");
@ -235,6 +269,8 @@ export function PsdTemplatesSettingsClient({
const [deletingFontId, setDeletingFontId] = useState<string | null>(null);
const [savingTemplateBrandId, setSavingTemplateBrandId] = useState<string | null>(null);
const [savingFontBrandId, setSavingFontBrandId] = useState<string | null>(null);
const [categoryTagDraft, setCategoryTagDraft] = useState("");
const [isSavingCategoryTags, setIsSavingCategoryTags] = useState(false);
const [isFontListExpanded, setIsFontListExpanded] = useState(false);
const [templateBrandDrafts, setTemplateBrandDrafts] = useState<Record<string, string>>({});
const [fontBrandDrafts, setFontBrandDrafts] = useState<Record<string, string>>({});
@ -249,6 +285,25 @@ export function PsdTemplatesSettingsClient({
() => getAgentWorkflowCategoryLabels(workflowConfig),
[workflowConfig]
);
const categoryTagConfig = useMemo(
() =>
normalizeAgentPsdTemplateCategoryTagConfig(
siteSettingsData?.agentPsdTemplateCategoryTagConfig
),
[siteSettingsData?.agentPsdTemplateCategoryTagConfig]
);
const availableCategoryTagOptions = useMemo(
() => getAgentPsdTemplateCategoryTagOptions(categoryTagConfig),
[categoryTagConfig]
);
const draftTemplateCategoryTagOptions = useMemo(
() =>
getAgentPsdTemplateCategoryTagOptions(
categoryTagConfig,
draftTemplate?.categoryTags || []
),
[categoryTagConfig, draftTemplate?.categoryTags]
);
const categoryLevelOptions = useMemo(
() => [
{
@ -445,6 +500,81 @@ export function PsdTemplatesSettingsClient({
}
}
async function saveCategoryTagConfig(nextTags: string[]) {
const normalized = normalizeAgentPsdTemplateCategoryTagConfig({
tags: nextTags,
});
try {
setIsSavingCategoryTags(true);
const response = await fetch("/api/site-settings", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
agentPsdTemplateCategoryTagConfig: normalized,
}),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string }
| null;
if (!response.ok) {
throw new Error(payload?.error || "品类标签保存失败");
}
await mutateSiteSettings();
return normalized.tags;
} finally {
setIsSavingCategoryTags(false);
}
}
async function handleAddCategoryTag() {
const nextTag = normalizeAgentPsdTemplateCategoryTags([categoryTagDraft])[0];
if (!nextTag) {
toast.error("请先输入品类标签");
return;
}
if (nextTag === AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG) {
toast.error("“适合所有”是固定标签,无需重复添加");
return;
}
if (hasTemplateCategoryTag(categoryTagConfig.tags, nextTag)) {
toast.error("该品类标签已存在");
return;
}
try {
await saveCategoryTagConfig([...categoryTagConfig.tags, nextTag]);
setCategoryTagDraft("");
toast.success("品类标签已添加");
} catch (error) {
toast.error(error instanceof Error ? error.message : "品类标签保存失败");
}
}
async function handleRemoveCategoryTag(tag: string) {
const normalizedTag = normalizeAgentPsdTemplateCategoryTags([tag])[0];
if (!normalizedTag || normalizedTag === AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG) {
return;
}
try {
await saveCategoryTagConfig(
categoryTagConfig.tags.filter(
(item) => item.trim().toLowerCase() !== normalizedTag.toLowerCase()
)
);
toast.success("品类标签已移除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "品类标签保存失败");
}
}
async function handleCreateTemplate(options?: { parseAfterUpload?: boolean }) {
const parseAfterUpload = options?.parseAfterUpload === true;
if (!uploadFile) {
@ -462,6 +592,9 @@ export function PsdTemplatesSettingsClient({
formData.set("brandName", uploadBrandName.trim());
formData.set("mode", uploadMode);
formData.set("secondaryCategory", uploadSecondaryCategory.trim());
uploadCategoryTags.forEach((tag) => {
formData.append("categoryTags", tag);
});
formData.set("description", uploadDescription.trim());
formData.set("status", "disabled");
formData.set("file", uploadFile);
@ -528,6 +661,7 @@ export function PsdTemplatesSettingsClient({
setUploadName("");
setUploadBrandName("");
setUploadSecondaryCategory("");
setUploadCategoryTags([]);
setUploadDescription("");
setUploadFile(null);
await mutate();
@ -676,6 +810,7 @@ export function PsdTemplatesSettingsClient({
brandName: normalizedBrandName,
mode: template.mode,
secondaryCategory: template.secondaryCategory || "",
categoryTags: template.categoryTags || [],
description: template.description || "",
status: template.status,
layers: template.layers,
@ -721,6 +856,7 @@ export function PsdTemplatesSettingsClient({
brandName: draftTemplate.brandName,
mode: draftTemplate.mode,
secondaryCategory: draftTemplate.secondaryCategory || "",
categoryTags: draftTemplate.categoryTags || [],
description: draftTemplate.description || "",
status: draftTemplate.status,
layers: draftTemplate.layers,
@ -942,6 +1078,73 @@ export function PsdTemplatesSettingsClient({
<div className="container mx-auto grid gap-6 px-6 py-6 lg:grid-cols-[360px,minmax(0,1fr)]">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">品类标签</CardTitle>
<CardDescription>
管理 PSD 模板可绑定的品类标签。前端会根据用户当前选择的产品品类筛选模板。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="psdCategoryTagDraft">新增标签</Label>
<div className="flex gap-2">
<Input
id="psdCategoryTagDraft"
value={categoryTagDraft}
onChange={(event) => setCategoryTagDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleAddCategoryTag();
}
}}
placeholder="例如:椅子类 / 沙发类 / 餐边柜类"
disabled={isSavingCategoryTags}
/>
<Button
type="button"
variant="outline"
onClick={() => {
void handleAddCategoryTag();
}}
disabled={isSavingCategoryTags || !categoryTagDraft.trim()}
>
添加标签
</Button>
</div>
</div>
<div className="space-y-2">
<Label>当前标签</Label>
<div className="flex flex-wrap gap-2">
<Badge variant="default">{AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG}</Badge>
{categoryTagConfig.tags.map((tag) => (
<div
key={tag}
className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1 text-sm"
>
<span>{tag}</span>
<button
type="button"
className="text-muted-foreground transition hover:text-foreground"
onClick={() => {
void handleRemoveCategoryTag(tag);
}}
disabled={isSavingCategoryTags}
>
删除
</button>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
“适合所有”是固定标签,会让模板在所有品类下都显示;自定义标签删除后,不会再出现在新模板选择项中。
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">上传新模板</CardTitle>
@ -1062,6 +1265,35 @@ export function PsdTemplatesSettingsClient({
会优先读取当前品牌下已配置的分类层级作为选项;开启前台模板二级分类后,会按这里的分类分组展示模板。
</p>
</div>
<div className="space-y-2">
<Label>适用品类标签</Label>
<div className="flex flex-wrap gap-2">
{availableCategoryTagOptions.map((tag) => {
const isSelected = hasTemplateCategoryTag(uploadCategoryTags, tag);
return (
<button
key={tag}
type="button"
onClick={() =>
setUploadCategoryTags((current) =>
toggleTemplateCategoryTag(current, tag)
)
}
className={`rounded-full border px-3 py-1 text-sm transition ${
isSelected
? "border-orange-400 bg-orange-500/10 text-orange-600"
: "border-border bg-background text-muted-foreground hover:border-orange-300/50 hover:text-foreground"
}`}
>
{tag}
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
可多选。前端会根据用户当前选择的产品品类筛选模板;勾选“适合所有”后,这个模板会在所有品类下都显示。
</p>
</div>
<div className="space-y-2">
<Label htmlFor="psdTemplateDescription">模板说明</Label>
<Textarea
@ -1413,6 +1645,15 @@ export function PsdTemplatesSettingsClient({
二级分类:{template.secondaryCategory}
</div>
) : null}
{template.categoryTags?.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{template.categoryTags.map((tag) => (
<Badge key={`${template.id}-${tag}`} variant="secondary">
{tag}
</Badge>
))}
</div>
) : null}
{template.description ? (
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground">
{template.description}
@ -1820,6 +2061,46 @@ export function PsdTemplatesSettingsClient({
/>
)}
</div>
<div className="space-y-2 md:col-span-2">
<Label>适用品类标签</Label>
<div className="flex flex-wrap gap-2">
{draftTemplateCategoryTagOptions.map((tag) => {
const isSelected = hasTemplateCategoryTag(
draftTemplate.categoryTags,
tag
);
return (
<button
key={`draft-category-tag-${tag}`}
type="button"
onClick={() =>
setDraftTemplate((current) =>
current
? {
...current,
categoryTags: toggleTemplateCategoryTag(
current.categoryTags,
tag
),
}
: current
)
}
className={`rounded-full border px-3 py-1 text-sm transition ${
isSelected
? "border-orange-400 bg-orange-500/10 text-orange-600"
: "border-border bg-background text-muted-foreground hover:border-orange-300/50 hover:text-foreground"
}`}
>
{tag}
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
可多选。勾选“适合所有”后,模板会在所有品类下都显示;未设置标签的旧模板仍会保持可见,避免影响现有使用。
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
@ -1875,6 +2156,11 @@ export function PsdTemplatesSettingsClient({
{draftTemplate.secondaryCategory ? (
<Badge variant="secondary">分类:{draftTemplate.secondaryCategory}</Badge>
) : null}
{(draftTemplate.categoryTags || []).map((tag) => (
<Badge key={`draft-template-tag-${tag}`} variant="secondary">
品类:{tag}
</Badge>
))}
<Badge variant="outline">文件:{draftTemplate.fileName}</Badge>
<Badge variant={getTemplateParseBadgeVariant(draftTemplate)}>
{getTemplateParseLabel(draftTemplate)}

View File

@ -13,6 +13,7 @@ import {
parseAgentPsdTemplateLayers,
type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config";
import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config";
import {
preparePsdTemplateRenderAssets,
} from "@/lib/agents/psd-template-parser";
@ -31,6 +32,7 @@ const updateTemplateSchema = z.object({
brandName: z.string().trim().min(1).max(64),
mode: z.string().optional().nullable(),
secondaryCategory: z.string().trim().max(64).optional().nullable(),
categoryTags: z.array(z.string().trim().min(1).max(64)).max(30).optional().nullable(),
description: z.string().optional().nullable(),
status: z.string().optional(),
layers: z.array(z.any()).default([]),
@ -108,6 +110,7 @@ export async function PUT(
const nextStatus = normalizeAgentPsdTemplateStatus(body.status);
const nextMode = normalizeAgentPsdTemplateMode(body.mode);
const nextCategoryTags = normalizeAgentPsdTemplateCategoryTags(body.categoryTags);
let nextLayers = parseAgentPsdTemplateLayers(body.layers);
if (nextLayers.length === 0) {
nextLayers = currentTemplate.layers;
@ -124,6 +127,7 @@ export async function PUT(
brandName: body.brandName.trim(),
mode: nextMode,
secondaryCategory: String(body.secondaryCategory || "").trim() || undefined,
categoryTags: nextCategoryTags.length > 0 ? nextCategoryTags : undefined,
description: String(body.description || "").trim() || undefined,
status: nextStatus,
updatedAt: new Date().toISOString(),
@ -214,6 +218,7 @@ export async function PUT(
brandName: body.brandName.trim(),
mode: nextMode,
secondaryCategory: String(body.secondaryCategory || "").trim() || undefined,
categoryTags: nextCategoryTags.length > 0 ? nextCategoryTags : undefined,
description: String(body.description || "").trim() || undefined,
status: nextStatus,
renderBaseUrl: uploadedRenderBase.url,

View File

@ -14,6 +14,7 @@ import {
type AgentPsdTemplateListResponse,
type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config";
import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config";
import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
@ -115,6 +116,9 @@ export async function POST(request: NextRequest) {
const brandName = String(formData.get("brandName") || "").trim();
const mode = normalizeAgentPsdTemplateMode(formData.get("mode"));
const secondaryCategory = String(formData.get("secondaryCategory") || "").trim();
const categoryTags = normalizeAgentPsdTemplateCategoryTags(
formData.getAll("categoryTags").map((item) => String(item || ""))
);
const description = String(formData.get("description") || "").trim();
const status = normalizeAgentPsdTemplateStatus(formData.get("status"));
@ -173,6 +177,7 @@ export async function POST(request: NextRequest) {
brandName,
mode,
secondaryCategory: secondaryCategory || undefined,
categoryTags: categoryTags.length > 0 ? categoryTags : undefined,
description: description || undefined,
status,
parseStatus: "pending",

View File

@ -23,6 +23,11 @@ import {
defaultAgentPsdTemplateSecondaryCategoryConfig,
normalizeAgentPsdTemplateSecondaryCategoryConfig,
} from "@/lib/agents/psd-template-secondary-category-config";
import {
AGENT_PSD_TEMPLATE_CATEGORY_TAG_CONFIG_KEY,
defaultAgentPsdTemplateCategoryTagConfig,
normalizeAgentPsdTemplateCategoryTagConfig,
} from "@/lib/agents/psd-template-category-tag-config";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair";
import { resolveAgentPsdTemplateTextStyles } from "@/lib/agents/psd-template-style-resolver";
@ -53,7 +58,8 @@ export async function GET(request: NextRequest) {
requestedModeRaw === "main" || requestedModeRaw === "sku"
? normalizeAgentPsdTemplateMode(requestedModeRaw)
: null;
const [allTemplates, textColorSetting, secondaryCategorySetting] = await Promise.all([
const [allTemplates, textColorSetting, secondaryCategorySetting, categoryTagSetting] =
await Promise.all([
readAgentPsdTemplates(),
db
.select()
@ -69,6 +75,13 @@ export async function GET(request: NextRequest) {
.limit(1)
.then((rows) => rows[0])
.catch(() => null),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, AGENT_PSD_TEMPLATE_CATEGORY_TAG_CONFIG_KEY))
.limit(1)
.then((rows) => rows[0])
.catch(() => null),
]);
const repairedTemplateMap = new Map<
string,
@ -147,18 +160,25 @@ export async function GET(request: NextRequest) {
})
);
const normalizedTextColorConfig = textColorSetting
? normalizeAgentPsdTextColorConfig(textColorSetting.value)
: defaultAgentPsdTextColorConfig;
return NextResponse.json({
success: true,
templates,
textColorConfig: resolveAgentPsdTextColorConfigForBrand(
textColorSetting
? normalizeAgentPsdTextColorConfig(textColorSetting.value)
: defaultAgentPsdTextColorConfig,
requestedBrand
),
textColorConfig: requestedBrand
? resolveAgentPsdTextColorConfigForBrand(
normalizedTextColorConfig,
requestedBrand
)
: normalizedTextColorConfig,
secondaryCategoryConfig: secondaryCategorySetting
? normalizeAgentPsdTemplateSecondaryCategoryConfig(secondaryCategorySetting.value)
: defaultAgentPsdTemplateSecondaryCategoryConfig,
categoryTagConfig: categoryTagSetting
? normalizeAgentPsdTemplateCategoryTagConfig(categoryTagSetting.value)
: defaultAgentPsdTemplateCategoryTagConfig,
} satisfies AgentPsdTemplateListResponse);
} catch (error) {
console.error("[PSD Templates] list failed:", error);

View File

@ -67,6 +67,11 @@ import {
defaultAgentPsdTemplateSecondaryCategoryConfig,
normalizeAgentPsdTemplateSecondaryCategoryConfig,
} from "@/lib/agents/psd-template-secondary-category-config";
import {
AGENT_PSD_TEMPLATE_CATEGORY_TAG_CONFIG_KEY,
defaultAgentPsdTemplateCategoryTagConfig,
normalizeAgentPsdTemplateCategoryTagConfig,
} from "@/lib/agents/psd-template-category-tag-config";
import {
AGENT_PRESET_LIBRARY_CONFIG_KEY,
defaultAgentPresetLibraryConfig,
@ -278,6 +283,7 @@ export async function GET() {
agentWorkflowShowcaseConfigSetting,
agentPsdTextColorConfigSetting,
agentPsdTemplateSecondaryCategoryConfigSetting,
agentPsdTemplateCategoryTagConfigSetting,
] = await Promise.all([
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
db
@ -442,6 +448,12 @@ export async function GET() {
.where(eq(systemSettings.key, AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, AGENT_PSD_TEMPLATE_CATEGORY_TAG_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
]);
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
@ -515,6 +527,10 @@ export async function GET() {
normalizeAgentPsdTemplateSecondaryCategoryConfig(
agentPsdTemplateSecondaryCategoryConfigSetting?.value
);
const agentPsdTemplateCategoryTagConfig =
normalizeAgentPsdTemplateCategoryTagConfig(
agentPsdTemplateCategoryTagConfigSetting?.value
);
// 如果没有设置记录,返回默认值
const baseSettings = settings || {
@ -553,6 +569,8 @@ export async function GET() {
agentPsdTextColorConfig: defaultAgentPsdTextColorConfig,
agentPsdTemplateSecondaryCategoryConfig:
defaultAgentPsdTemplateSecondaryCategoryConfig,
agentPsdTemplateCategoryTagConfig:
defaultAgentPsdTemplateCategoryTagConfig,
realtimeCanvasEnabled: true,
realtimeCanvasServiceEnabled: true,
storageFailoverConfig,
@ -605,6 +623,7 @@ export async function GET() {
agentWorkflowShowcaseConfig,
agentPsdTextColorConfig,
agentPsdTemplateSecondaryCategoryConfig,
agentPsdTemplateCategoryTagConfig,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
@ -926,6 +945,11 @@ const updateSchema = z.object({
enabled: z.boolean().optional(),
})
.optional(),
agentPsdTemplateCategoryTagConfig: z
.object({
tags: z.array(z.string().trim().min(1).max(64)).max(200).optional(),
})
.optional(),
realtimeCanvasEnabled: z.boolean().optional(),
aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(),
@ -1015,6 +1039,7 @@ export async function PUT(request: NextRequest) {
"agentWorkflowShowcaseConfig",
"agentPsdTextColorConfig",
"agentPsdTemplateSecondaryCategoryConfig",
"agentPsdTemplateCategoryTagConfig",
]);
const hasRestrictedKeys = Object.keys(body || {}).some((key) => !allowedKeys.has(key));
if (hasRestrictedKeys) {
@ -1040,6 +1065,7 @@ export async function PUT(request: NextRequest) {
agentWorkflowShowcaseConfig,
agentPsdTextColorConfig,
agentPsdTemplateSecondaryCategoryConfig,
agentPsdTemplateCategoryTagConfig,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
@ -1474,6 +1500,50 @@ export async function PUT(request: NextRequest) {
});
}
if (agentPsdTemplateSecondaryCategoryConfig !== undefined) {
const normalized = normalizeAgentPsdTemplateSecondaryCategoryConfig(
agentPsdTemplateSecondaryCategoryConfig
);
await db
.insert(systemSettings)
.values({
key: AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (agentPsdTemplateCategoryTagConfig !== undefined) {
const normalized = normalizeAgentPsdTemplateCategoryTagConfig(
agentPsdTemplateCategoryTagConfig
);
await db
.insert(systemSettings)
.values({
key: AGENT_PSD_TEMPLATE_CATEGORY_TAG_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig);
await db
@ -1682,6 +1752,10 @@ export async function PUT(request: NextRequest) {
agentPsdTemplateSecondaryCategoryConfig
)
: defaultAgentPsdTemplateSecondaryCategoryConfig,
agentPsdTemplateCategoryTagConfig:
agentPsdTemplateCategoryTagConfig !== undefined
? normalizeAgentPsdTemplateCategoryTagConfig(agentPsdTemplateCategoryTagConfig)
: defaultAgentPsdTemplateCategoryTagConfig,
membershipConfig:
membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig)
@ -2082,6 +2156,28 @@ export async function PUT(request: NextRequest) {
});
}
if (agentPsdTemplateCategoryTagConfig !== undefined) {
const normalized = normalizeAgentPsdTemplateCategoryTagConfig(
agentPsdTemplateCategoryTagConfig
);
await db
.insert(systemSettings)
.values({
key: AGENT_PSD_TEMPLATE_CATEGORY_TAG_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig);
await db
@ -2282,6 +2378,16 @@ export async function PUT(request: NextRequest) {
agentPsdTextColorConfig !== undefined
? normalizeAgentPsdTextColorConfig(agentPsdTextColorConfig)
: defaultAgentPsdTextColorConfig,
agentPsdTemplateSecondaryCategoryConfig:
agentPsdTemplateSecondaryCategoryConfig !== undefined
? normalizeAgentPsdTemplateSecondaryCategoryConfig(
agentPsdTemplateSecondaryCategoryConfig
)
: defaultAgentPsdTemplateSecondaryCategoryConfig,
agentPsdTemplateCategoryTagConfig:
agentPsdTemplateCategoryTagConfig !== undefined
? normalizeAgentPsdTemplateCategoryTagConfig(agentPsdTemplateCategoryTagConfig)
: defaultAgentPsdTemplateCategoryTagConfig,
membershipConfig:
membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig)

View File

@ -5989,7 +5989,14 @@ export function AgentGenerationWorkspaceDarkV2({
) : null}
<ClientErrorBoundary
resetKeys={[psdTemplateDialogSourceUrl || "", workflowContext?.brand || "", psdTemplateDialogMode]}
resetKeys={[
psdTemplateDialogSourceUrl || "",
workflowContext?.brand || "",
workflowContext?.thirdCategory || "",
workflowContext?.fourthCategory || "",
workflowContext?.fifthCategory || "",
psdTemplateDialogMode,
]}
fallback={
<Dialog
open={Boolean(psdTemplateDialogSourceUrl)}
@ -6028,6 +6035,11 @@ export function AgentGenerationWorkspaceDarkV2({
}}
sourceImageUrl={psdTemplateDialogSourceUrl}
brandName={workflowContext?.brand || null}
selectedCategoryNames={[
workflowContext?.fifthCategory || "",
workflowContext?.fourthCategory || "",
workflowContext?.thirdCategory || "",
].filter(Boolean)}
sizeInfoText={workflowContext?.summaryText || null}
templateMode={psdTemplateDialogMode}
/>

View File

@ -62,9 +62,16 @@ import {
defaultAgentPsdTemplateSecondaryCategoryConfig,
normalizeAgentPsdTemplateSecondaryCategoryConfig,
} from "@/lib/agents/psd-template-secondary-category-config";
import {
AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG,
defaultAgentPsdTemplateCategoryTagConfig,
normalizeAgentPsdTemplateCategoryTagConfig,
normalizeAgentPsdTemplateCategoryTags,
} from "@/lib/agents/psd-template-category-tag-config";
import {
defaultAgentPsdTextColorConfig,
normalizeAgentPsdTextColorConfig,
resolveAgentPsdTextColorConfigForBrand,
} from "@/lib/agents/psd-text-color-config";
import {
normalizeHexColor,
@ -90,6 +97,7 @@ const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
templates: [],
textColorConfig: defaultAgentPsdTextColorConfig,
secondaryCategoryConfig: defaultAgentPsdTemplateSecondaryCategoryConfig,
categoryTagConfig: defaultAgentPsdTemplateCategoryTagConfig,
error:
typeof payload?.error === "string"
? payload.error
@ -106,6 +114,9 @@ const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
secondaryCategoryConfig: normalizeAgentPsdTemplateSecondaryCategoryConfig(
payload.secondaryCategoryConfig
),
categoryTagConfig: normalizeAgentPsdTemplateCategoryTagConfig(
payload.categoryTagConfig
),
};
} catch {
return {
@ -113,6 +124,7 @@ const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
templates: [],
textColorConfig: defaultAgentPsdTextColorConfig,
secondaryCategoryConfig: defaultAgentPsdTemplateSecondaryCategoryConfig,
categoryTagConfig: defaultAgentPsdTemplateCategoryTagConfig,
error: "PSD 模板加载失败",
};
}
@ -123,12 +135,86 @@ const PREVIEW_STAGE_TARGET_WIDTH = 760;
const PREVIEW_STAGE_TARGET_HEIGHT = 760;
const MIN_IMAGE_SCALE = 0.4;
const MAX_IMAGE_SCALE = 3;
const UNASSIGNED_BRAND_LABEL = "未分类";
function normalizeManagedBrandName(value?: string | null) {
const normalized = String(value || "").trim();
if (!normalized || normalized === "未分配品牌") {
return "";
}
return normalized;
}
function getEffectiveTemplateCategoryTags(
template: AgentPsdTemplate,
activeCategoryTags: string[]
) {
const templateCategoryTags = normalizeAgentPsdTemplateCategoryTags(
template.categoryTags
);
if (templateCategoryTags.length === 0) {
return [];
}
const activeCategoryTagSet = new Set(
activeCategoryTags.map((tag) => tag.trim().toLowerCase()).filter(Boolean)
);
if (activeCategoryTagSet.size === 0) {
return templateCategoryTags;
}
return templateCategoryTags.filter(
(tag) =>
tag === AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG ||
activeCategoryTagSet.has(tag.trim().toLowerCase())
);
}
function matchesTemplateSelectedCategories(
template: AgentPsdTemplate,
selectedCategoryNames: string[],
activeCategoryTags: string[]
) {
if (selectedCategoryNames.length === 0) {
return true;
}
const rawTemplateCategoryTags = normalizeAgentPsdTemplateCategoryTags(
template.categoryTags
);
if (rawTemplateCategoryTags.length === 0) {
return true;
}
const templateCategoryTags = getEffectiveTemplateCategoryTags(
template,
activeCategoryTags
);
if (templateCategoryTags.length === 0) {
return false;
}
if (templateCategoryTags.includes(AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG)) {
return true;
}
const selectedCategorySet = new Set(
selectedCategoryNames.map((tag) => tag.trim().toLowerCase()).filter(Boolean)
);
return templateCategoryTags.some((tag) =>
selectedCategorySet.has(tag.trim().toLowerCase())
);
}
function getTemplateMetaLabel(
template: AgentPsdTemplate,
showSecondaryCategory: boolean
showSecondaryCategory: boolean,
showBrand = false
) {
const metaParts = [
showBrand
? normalizeManagedBrandName(template.brandName) || UNASSIGNED_BRAND_LABEL
: "",
`${template.width} x ${template.height}`,
showSecondaryCategory
? String(template.secondaryCategory || "").trim()
@ -141,12 +227,14 @@ function renderTemplateSelectContent(
template: AgentPsdTemplate,
params?: {
showSecondaryCategory?: boolean;
showBrand?: boolean;
compact?: boolean;
}
) {
const showSecondaryCategory = params?.showSecondaryCategory === true;
const showBrand = params?.showBrand === true;
const compact = params?.compact === true;
const metaLabel = getTemplateMetaLabel(template, showSecondaryCategory);
const metaLabel = getTemplateMetaLabel(template, showSecondaryCategory, showBrand);
return (
<div
@ -199,12 +287,14 @@ function renderTemplatePickerCardContent(
template: AgentPsdTemplate,
params?: {
showSecondaryCategory?: boolean;
showBrand?: boolean;
emphasize?: boolean;
}
) {
const showSecondaryCategory = params?.showSecondaryCategory === true;
const showBrand = params?.showBrand === true;
const emphasize = params?.emphasize === true;
const metaLabel = getTemplateMetaLabel(template, showSecondaryCategory);
const metaLabel = getTemplateMetaLabel(template, showSecondaryCategory, showBrand);
return (
<div className="flex min-w-0 items-center gap-3">
@ -564,6 +654,7 @@ export function AgentPsdTemplateEditorDialog({
onOpenChange,
sourceImageUrl,
brandName,
selectedCategoryNames,
sizeInfoText,
templateMode,
}: {
@ -571,30 +662,43 @@ export function AgentPsdTemplateEditorDialog({
onOpenChange: (open: boolean) => void;
sourceImageUrl: string | null;
brandName?: string | null;
selectedCategoryNames?: string[] | null;
sizeInfoText?: string | null;
templateMode?: AgentPsdTemplateMode | null;
}) {
const normalizedBrandName = String(brandName || "").trim();
const normalizedSelectedCategoryNames = useMemo(
() =>
normalizeAgentPsdTemplateCategoryTags(selectedCategoryNames).filter(
(tag) => tag !== AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG
),
[selectedCategoryNames]
);
const normalizedTemplateMode =
templateMode === "sku" ? "sku" : templateMode === "main" ? "main" : "";
const templateListRequestUrl = useMemo(() => {
if (!open || !normalizedBrandName) {
if (!open) {
return null;
}
const searchParams = new URLSearchParams();
searchParams.set("brand", normalizedBrandName);
return `/api/psd-templates?${searchParams.toString()}`;
if (normalizedBrandName) {
searchParams.set("brand", normalizedBrandName);
}
return searchParams.size > 0
? `/api/psd-templates?${searchParams.toString()}`
: "/api/psd-templates";
}, [open, normalizedBrandName]);
const { data, isLoading } = useSWR<AgentPsdTemplateListResponse>(
templateListRequestUrl,
fetcher
);
const templates = useMemo(
const allTemplates = useMemo(
() => (data?.success ? data.templates : []),
[data]
);
const textColorConfig = useMemo(
const rawTextColorConfig = useMemo(
() => normalizeAgentPsdTextColorConfig(data?.textColorConfig),
[data?.textColorConfig]
);
@ -605,6 +709,25 @@ export function AgentPsdTemplateEditorDialog({
),
[data?.secondaryCategoryConfig]
);
const categoryTagConfig = useMemo(
() => normalizeAgentPsdTemplateCategoryTagConfig(data?.categoryTagConfig),
[data?.categoryTagConfig]
);
const activeCategoryTags = useMemo(
() => categoryTagConfig.tags,
[categoryTagConfig.tags]
);
const templates = useMemo(
() =>
allTemplates.filter((template) =>
matchesTemplateSelectedCategories(
template,
normalizedSelectedCategoryNames,
activeCategoryTags
)
),
[activeCategoryTags, allTemplates, normalizedSelectedCategoryNames]
);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [selectedLayerId, setSelectedLayerId] = useState<string>("");
@ -647,6 +770,28 @@ export function AgentPsdTemplateEditorDialog({
templates.find((template) => template.id === selectedTemplateId) || null,
[selectedTemplateId, templates]
);
const effectiveTemplateBrandName = useMemo(
() => normalizedBrandName || String(selectedTemplate?.brandName || "").trim(),
[normalizedBrandName, selectedTemplate?.brandName]
);
const textColorConfig = useMemo(
() =>
resolveAgentPsdTextColorConfigForBrand(
rawTextColorConfig,
effectiveTemplateBrandName
),
[effectiveTemplateBrandName, rawTextColorConfig]
);
const shouldShowTemplateBrand = useMemo(
() =>
!normalizedBrandName &&
templates.some((template) => Boolean(normalizeManagedBrandName(template.brandName))),
[normalizedBrandName, templates]
);
const selectedCategorySummaryText = useMemo(
() => normalizedSelectedCategoryNames.join(" / "),
[normalizedSelectedCategoryNames]
);
const shouldGroupTemplatesBySecondaryCategory = useMemo(
() =>
secondaryCategoryConfig.enabled &&
@ -1428,9 +1573,13 @@ export function AgentPsdTemplateEditorDialog({
nextMode === "sku" ? skuModeTemplates : mainModeTemplates;
if (nextTemplates.length === 0) {
toast.error(
nextMode === "sku"
? "当前品牌下暂无 SKU 图模式模板"
: "当前品牌下暂无主图模式模板"
selectedCategorySummaryText
? nextMode === "sku"
? `当前品类“${selectedCategorySummaryText}”下暂无 SKU 图模式模板`
: `当前品类“${selectedCategorySummaryText}”下暂无主图模式模板`
: nextMode === "sku"
? "当前品牌下暂无 SKU 图模式模板"
: "当前品牌下暂无主图模式模板"
);
return;
}
@ -1475,6 +1624,11 @@ export function AgentPsdTemplateEditorDialog({
<Label>
{selectedTemplate ? "选择模板" : "主图模式模板"}
</Label>
{selectedCategorySummaryText ? (
<div className="text-white/45 text-xs">
当前按品类“{selectedCategorySummaryText}”筛选模板,同时保留“适合所有”的模板。
</div>
) : null}
{selectedTemplate && hasTemplateOptions ? (
<div className="space-y-2.5">
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.03] p-1">
@ -1516,6 +1670,7 @@ export function AgentPsdTemplateEditorDialog({
{renderTemplateSelectContent(selectedTemplate, {
showSecondaryCategory:
shouldGroupTemplatesBySecondaryCategory,
showBrand: shouldShowTemplateBrand,
})}
</div>
) : (
@ -1550,6 +1705,7 @@ export function AgentPsdTemplateEditorDialog({
{renderTemplateSelectContent(template, {
showSecondaryCategory:
shouldGroupTemplatesBySecondaryCategory,
showBrand: shouldShowTemplateBrand,
compact: true,
})}
</SelectItem>
@ -1563,6 +1719,7 @@ export function AgentPsdTemplateEditorDialog({
value={template.id}
>
{renderTemplateSelectContent(template, {
showBrand: shouldShowTemplateBrand,
compact: true,
})}
</SelectItem>
@ -1598,15 +1755,18 @@ export function AgentPsdTemplateEditorDialog({
{renderTemplatePickerCardContent(template, {
showSecondaryCategory:
shouldGroupTemplatesBySecondaryCategory,
showBrand: shouldShowTemplateBrand,
emphasize: true,
})}
</button>
))
) : (
<div className="rounded-xl border border-white/15 border-dashed bg-white/[0.02] px-3 py-3 text-muted-foreground text-sm">
{normalizedBrandName
? `当前品牌“${normalizedBrandName}”下暂无主图模式模板`
: "请先在工作流中选择品牌后再打开 PSD 模板。"}
{selectedCategorySummaryText
? `当前品类“${selectedCategorySummaryText}”下暂无主图模式模板`
: normalizedBrandName
? `当前品牌“${normalizedBrandName}”下暂无主图模式模板`
: "当前暂无可用的主图模式模板,请先去后台配置。"}
</div>
)}
</div>
@ -1650,6 +1810,10 @@ export function AgentPsdTemplateEditorDialog({
? "正在加载可用模板..."
: hasTemplateOptions
? "请从左侧主图模式模板或右侧 SKU 图模式模板中选择一个模板开始编辑。"
: selectedCategorySummaryText && normalizedBrandName
? `当前品牌“${normalizedBrandName}”下没有命中品类“${selectedCategorySummaryText}”的 PSD 模板,请先去后台配置。`
: selectedCategorySummaryText
? `当前品类“${selectedCategorySummaryText}”下还没有可用 PSD 模板,请先去后台配置。`
: normalizedBrandName
? `当前品牌“${normalizedBrandName}”下还没有可用 PSD 模板,请先去后台配置。`
: "当前还没有启用的 PSD 模板,请先去后台配置。"}
@ -2071,7 +2235,11 @@ export function AgentPsdTemplateEditorDialog({
<div className="mt-1 text-white/45 text-xs">
{normalizedTemplateMode === "sku"
? "当前结果属于 SKU 图模式,可直接从这里选择并进入编辑。"
: "这里集中展示当前品牌下的 SKU 图模式模板。"}
: selectedCategorySummaryText
? `当前按品类“${selectedCategorySummaryText}”筛选,同时展示“适合所有”的 SKU 图模式模板。`
: normalizedBrandName
? "这里集中展示当前品牌下的 SKU 图模式模板。"
: "当前未选择品牌,这里展示全部品牌下的 SKU 图模式模板。"}
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-white/65 text-xs">
@ -2106,6 +2274,7 @@ export function AgentPsdTemplateEditorDialog({
{renderTemplatePickerCardContent(template, {
showSecondaryCategory:
shouldGroupTemplatesBySecondaryCategory,
showBrand: shouldShowTemplateBrand,
emphasize: true,
})}
</button>
@ -2113,9 +2282,11 @@ export function AgentPsdTemplateEditorDialog({
</div>
) : (
<div className="flex h-full w-full items-center justify-center px-6 text-center text-muted-foreground text-sm">
{normalizedBrandName
? `当前品牌“${normalizedBrandName}”下暂无 SKU 图模式模板`
: "请先返回工作流区域选择品牌,再打开 PSD 模板。"}
{selectedCategorySummaryText
? `当前品类“${selectedCategorySummaryText}”下暂无 SKU 图模式模板`
: normalizedBrandName
? `当前品牌“${normalizedBrandName}”下暂无 SKU 图模式模板`
: "当前暂无可用的 SKU 图模式模板,请先去后台配置。"}
</div>
)}
</div>

View File

@ -0,0 +1,89 @@
export interface AgentPsdTemplateCategoryTagConfig {
tags: string[];
}
export const AGENT_PSD_TEMPLATE_CATEGORY_TAG_CONFIG_KEY =
"agent_psd_template_category_tag_config";
export const AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG = "适合所有";
const MAX_CATEGORY_TAG_LENGTH = 64;
const MAX_CATEGORY_TAG_CONFIG_ITEMS = 200;
const MAX_TEMPLATE_CATEGORY_TAG_ITEMS = 30;
function normalizeCategoryTagText(value: unknown) {
return String(value || "").trim().slice(0, MAX_CATEGORY_TAG_LENGTH);
}
export function normalizeAgentPsdTemplateCategoryTags(value: unknown): string[] {
const source = Array.isArray(value)
? value
: typeof value === "string"
? value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
: [];
const tags: string[] = [];
const seen = new Set<string>();
for (const item of source) {
const normalized = normalizeCategoryTagText(item);
if (!normalized) {
continue;
}
const finalValue =
normalized === AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG
? AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG
: normalized;
const dedupeKey = finalValue.toLowerCase();
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
tags.push(finalValue);
if (tags.length >= MAX_TEMPLATE_CATEGORY_TAG_ITEMS) {
break;
}
}
return tags;
}
export const defaultAgentPsdTemplateCategoryTagConfig: AgentPsdTemplateCategoryTagConfig = {
tags: [],
};
export function normalizeAgentPsdTemplateCategoryTagConfig(
value: unknown
): AgentPsdTemplateCategoryTagConfig {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
tags: normalizeAgentPsdTemplateCategoryTags(raw.tags)
.filter((tag) => tag !== AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG)
.slice(0, MAX_CATEGORY_TAG_CONFIG_ITEMS),
};
}
export function getAgentPsdTemplateCategoryTagOptions(
config?: AgentPsdTemplateCategoryTagConfig | null,
extraTags?: unknown
) {
return [
AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG,
...normalizeAgentPsdTemplateCategoryTagConfig(config).tags,
...normalizeAgentPsdTemplateCategoryTags(extraTags).filter(
(tag) => tag !== AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG
),
].filter((tag, index, array) => {
const normalized = tag.toLowerCase();
return array.findIndex((candidate) => candidate.toLowerCase() === normalized) === index;
});
}

View File

@ -1,5 +1,7 @@
import type { AgentPsdTextColorConfig } from "@/types/agent";
import type { AgentPsdTemplateSecondaryCategoryConfig } from "@/lib/agents/psd-template-secondary-category-config";
import type { AgentPsdTemplateCategoryTagConfig } from "@/lib/agents/psd-template-category-tag-config";
import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config";
export const AGENT_PSD_TEMPLATES_KEY = "agent_psd_templates";
export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 6;
@ -86,6 +88,7 @@ export interface AgentPsdTemplate {
brandName: string;
mode: AgentPsdTemplateMode;
secondaryCategory?: string;
categoryTags?: string[];
description?: string;
status: AgentPsdTemplateStatus;
parseStatus: AgentPsdTemplateParseStatus;
@ -114,6 +117,7 @@ export interface AgentPsdTemplateListResponse {
templates: AgentPsdTemplate[];
textColorConfig?: AgentPsdTextColorConfig;
secondaryCategoryConfig?: AgentPsdTemplateSecondaryCategoryConfig;
categoryTagConfig?: AgentPsdTemplateCategoryTagConfig;
error?: string;
}
@ -444,12 +448,17 @@ export function parseAgentPsdTemplates(value: unknown) {
const parseStatus = normalizeAgentPsdTemplateParseStatus(record.parseStatus);
const rawParseProgress = normalizePositiveInteger(record.parseProgress);
const categoryTags = normalizeAgentPsdTemplateCategoryTags(
record.categoryTags ?? record.productCategoryTags
);
templates.push({
id,
name,
brandName,
mode: normalizeAgentPsdTemplateMode(record.mode),
secondaryCategory: normalizeText(record.secondaryCategory) || undefined,
categoryTags: categoryTags.length > 0 ? categoryTags : undefined,
description: normalizeText(record.description) || undefined,
status: normalizeAgentPsdTemplateStatus(record.status),
parseStatus,