From 11ab9a8e03edb45bea0d7debfcd39967ee1b1c62 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 2 Apr 2026 18:29:41 +0800 Subject: [PATCH] feat: organize psd assets by brand and harden template editor --- .../psd-templates-settings-client.tsx | 687 ++++++++++++++---- app/api/admin/psd-fonts/[id]/route.ts | 57 ++ app/api/admin/psd-fonts/route.ts | 6 + .../admin/psd-templates/[id]/parse/route.ts | 54 +- .../agents/AgentPsdTemplateEditorDialog.tsx | 26 +- lib/agents/psd-font-config.ts | 2 + lib/agents/psd-template-config.ts | 2 +- .../psd-template-image-slot-selection.ts | 106 +++ lib/agents/psd-template-renderer.ts | 7 +- lib/agents/psd-template-repair.ts | 65 +- 10 files changed, 797 insertions(+), 215 deletions(-) create mode 100644 lib/agents/psd-template-image-slot-selection.ts diff --git a/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx b/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx index 2d810780c..89c91a714 100644 --- a/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx @@ -7,6 +7,8 @@ import { toast } from "sonner"; import { AlertTriangle, ArrowLeft, + ChevronDown, + ChevronUp, Clock3, Image as ImageIcon, Layers, @@ -21,6 +23,7 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Progress } from "@/components/ui/progress"; @@ -64,6 +67,8 @@ const SYSTEM_FONT_OPTIONS = [ ]; const EMPTY_CATEGORY_SELECT_VALUE = "__none__"; +const EMPTY_BRAND_SELECT_VALUE = "__unassigned__"; +const UNASSIGNED_BRAND_LABEL = "未分类"; const PSD_TEMPLATE_MODE_OPTIONS: Array<{ value: AgentPsdTemplateMode; label: string; @@ -85,6 +90,14 @@ function getPsdTemplateModeLabel(mode?: AgentPsdTemplateMode | null) { return mode === "sku" ? "SKU图模式" : "主图模式"; } +function normalizeManagedBrandName(value?: string | null) { + const normalized = String(value || "").trim(); + if (!normalized || normalized === "未分配品牌") { + return ""; + } + return normalized; +} + function cloneTemplate(template: AgentPsdTemplate | null) { return template ? (JSON.parse(JSON.stringify(template)) as AgentPsdTemplate) : null; } @@ -174,6 +187,7 @@ export function PsdTemplatesSettingsClient({ ); const templates = useMemo(() => (data?.success ? data.templates : []), [data]); + const fonts = useMemo(() => (fontsData?.success ? fontsData.fonts : []), [fontsData]); const brandOptions = useMemo( () => Array.from( @@ -181,14 +195,21 @@ export function PsdTemplatesSettingsClient({ [ ...(siteSettingsData?.agentWorkflowUiConfig?.inputOptions?.brands || []), ...templates.map((template) => template.brandName), + ...fonts.map((font) => font.brandName || ""), ] .map((item) => String(item || "").trim()) .filter(Boolean) ) ), - [siteSettingsData, templates] + [siteSettingsData, templates, fonts] + ); + const managedBrandOptions = useMemo( + () => + Array.from( + new Set(brandOptions.map((brand) => normalizeManagedBrandName(brand)).filter(Boolean)) + ).sort((left, right) => left.localeCompare(right, "zh-CN")), + [brandOptions] ); - const fonts = useMemo(() => (fontsData?.success ? fontsData.fonts : []), [fontsData]); const [selectedTemplateId, setSelectedTemplateId] = useState(null); const [draftTemplate, setDraftTemplate] = useState(null); @@ -201,6 +222,7 @@ export function PsdTemplatesSettingsClient({ const [uploadFile, setUploadFile] = useState(null); const [fontUploadName, setFontUploadName] = useState(""); const [fontFamily, setFontFamily] = useState(""); + const [fontUploadBrandName, setFontUploadBrandName] = useState(""); const [fontUploadFile, setFontUploadFile] = useState(null); const [isCreating, setIsCreating] = useState(false); const [isCreatingAndParsing, setIsCreatingAndParsing] = useState(false); @@ -211,6 +233,11 @@ export function PsdTemplatesSettingsClient({ const [deletingTemplateId, setDeletingTemplateId] = useState(null); const [isUploadingFont, setIsUploadingFont] = useState(false); const [deletingFontId, setDeletingFontId] = useState(null); + const [savingTemplateBrandId, setSavingTemplateBrandId] = useState(null); + const [savingFontBrandId, setSavingFontBrandId] = useState(null); + const [isFontListExpanded, setIsFontListExpanded] = useState(false); + const [templateBrandDrafts, setTemplateBrandDrafts] = useState>({}); + const [fontBrandDrafts, setFontBrandDrafts] = useState>({}); const [parsingTemplateIds, setParsingTemplateIds] = useState([]); const selectedTemplate = useMemo( @@ -267,6 +294,50 @@ export function PsdTemplatesSettingsClient({ } return options; }, [workflowConfig, draftTemplate?.brandName, draftTemplate?.secondaryCategory]); + const selectableDraftBrandOptions = useMemo(() => { + const currentBrand = normalizeManagedBrandName(draftTemplate?.brandName); + return Array.from(new Set([...managedBrandOptions, ...(currentBrand ? [currentBrand] : [])])); + }, [draftTemplate?.brandName, managedBrandOptions]); + const groupedTemplates = useMemo(() => { + const grouped = new Map(); + templates.forEach((template) => { + const label = normalizeManagedBrandName(template.brandName) || UNASSIGNED_BRAND_LABEL; + const current = grouped.get(label) || []; + current.push(template); + grouped.set(label, current); + }); + + return Array.from(grouped.entries()) + .sort(([leftLabel], [rightLabel]) => { + if (leftLabel === UNASSIGNED_BRAND_LABEL) return -1; + if (rightLabel === UNASSIGNED_BRAND_LABEL) return 1; + return leftLabel.localeCompare(rightLabel, "zh-CN"); + }) + .map(([label, items]) => ({ + label, + items, + })); + }, [templates]); + const groupedFonts = useMemo(() => { + const grouped = new Map(); + fonts.forEach((font) => { + const label = normalizeManagedBrandName(font.brandName) || UNASSIGNED_BRAND_LABEL; + const current = grouped.get(label) || []; + current.push(font); + grouped.set(label, current); + }); + + return Array.from(grouped.entries()) + .sort(([leftLabel], [rightLabel]) => { + if (leftLabel === UNASSIGNED_BRAND_LABEL) return -1; + if (rightLabel === UNASSIGNED_BRAND_LABEL) return 1; + return leftLabel.localeCompare(rightLabel, "zh-CN"); + }) + .map(([label, items]) => ({ + label, + items, + })); + }, [fonts]); const fontOptions = useMemo( () => [ @@ -496,6 +567,7 @@ export function PsdTemplatesSettingsClient({ const formData = new FormData(); formData.set("name", fontUploadName.trim() || fontUploadFile.name.replace(/\.[^.]+$/, "")); formData.set("family", fontFamily.trim() || fontUploadName.trim() || "CustomFont"); + formData.set("brandName", fontUploadBrandName.trim()); formData.set("file", fontUploadFile); try { @@ -512,6 +584,7 @@ export function PsdTemplatesSettingsClient({ toast.success("字体已上传到字体库"); setFontUploadName(""); setFontFamily(""); + setFontUploadBrandName(""); setFontUploadFile(null); await mutateFonts(); } catch (error) { @@ -544,6 +617,93 @@ export function PsdTemplatesSettingsClient({ } } + async function handleQuickUpdateFontBrand(font: AgentPsdFont, nextBrandName: string) { + const normalizedBrandName = normalizeManagedBrandName(nextBrandName); + + try { + setSavingFontBrandId(font.id); + const response = await fetch(`/api/admin/psd-fonts/${encodeURIComponent(font.id)}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: font.name, + family: font.family, + brandName: normalizedBrandName, + }), + }); + const payload = (await response.json()) as AgentPsdFontMutationResponse; + if (!response.ok || !payload.success || !payload.font) { + throw new Error(payload.error || "字体品牌更新失败"); + } + + setFontBrandDrafts((current) => { + const next = { ...current }; + delete next[font.id]; + return next; + }); + toast.success(normalizedBrandName ? "字体品牌已更新" : "字体已移到未分类"); + await mutateFonts(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "字体品牌更新失败"); + } finally { + setSavingFontBrandId(null); + } + } + + async function handleQuickUpdateTemplateBrand( + template: AgentPsdTemplate, + nextBrandName: string + ) { + const normalizedBrandName = normalizeManagedBrandName(nextBrandName); + if (!normalizedBrandName) { + toast.error("请选择品牌"); + return; + } + + try { + setSavingTemplateBrandId(template.id); + const response = await fetch( + `/api/admin/psd-templates/${encodeURIComponent(template.id)}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: template.name, + brandName: normalizedBrandName, + mode: template.mode, + secondaryCategory: template.secondaryCategory || "", + description: template.description || "", + status: template.status, + layers: template.layers, + }), + } + ); + const payload = (await response.json()) as AgentPsdTemplateMutationResponse; + if (!response.ok || !payload.success || !payload.template) { + throw new Error(payload.error || "模板品牌更新失败"); + } + + setTemplateBrandDrafts((current) => { + const next = { ...current }; + delete next[template.id]; + return next; + }); + if (selectedTemplateId === template.id) { + setDraftTemplate(cloneTemplate(payload.template)); + } + toast.success("模板品牌已更新"); + await mutate(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "模板品牌更新失败"); + } finally { + setSavingTemplateBrandId(null); + } + } + async function handleSaveTemplate() { if (!draftTemplate) return; @@ -799,13 +959,13 @@ export function PsdTemplatesSettingsClient({
- {brandOptions.length > 0 ? ( + {managedBrandOptions.length > 0 ? ( + setFontUploadBrandName(value === EMPTY_BRAND_SELECT_VALUE ? "" : value) + } + > + + + + + 暂不分类 + {managedBrandOptions.map((brand) => ( + + {brand} + + ))} + + + ) : ( + setFontUploadBrandName(event.target.value)} + placeholder="输入品牌分类(可选)" + /> + )} +
-
- {fonts.length === 0 ? ( -
- 还没有上传字体,文字槽位目前只能使用系统基础字体。 -
- ) : ( - fonts.map((font) => ( -
-
-
-
{font.name}
-
{font.family}
-
{font.fileName}
-
- + +
+
+
+
已上传字体列表
+
+ 默认收起,按品牌分类查看字体归属。
- )) - )} -
+ + + +
+ + + {fonts.length === 0 ? ( +
+ 还没有上传字体,文字槽位目前只能使用系统基础字体。 +
+ ) : ( + groupedFonts.map((group) => ( +
+
+
{group.label}
+
{group.items.length} 个字体
+
+
+ {group.items.map((font) => { + const fontBrandDraft = + fontBrandDrafts[font.id] ?? normalizeManagedBrandName(font.brandName); + const isUnassignedFont = !normalizeManagedBrandName(font.brandName); + + return ( +
+
+
+
{font.name}
+
{font.family}
+
{font.fileName}
+
+ 品牌:{normalizeManagedBrandName(font.brandName) || UNASSIGNED_BRAND_LABEL} +
+
+ +
+ + {isUnassignedFont || fontBrandDrafts[font.id] ? ( +
+
+ {managedBrandOptions.length > 0 ? ( + + ) : ( + + setFontBrandDrafts((current) => ({ + ...current, + [font.id]: event.target.value, + })) + } + placeholder="输入品牌分类" + /> + )} +
+ +
+ ) : null} +
+ ); + })} +
+
+ )) + )} +
+
+ 模板列表 - 选择一个模板继续配置槽位。 + 按品牌查看模板,选择一个模板继续配置槽位。 {templates.length === 0 ? ( @@ -1061,121 +1352,203 @@ export function PsdTemplatesSettingsClient({ 还没有 PSD 模板,先上传一个模板开始配置。
) : ( - templates.map((template) => { - const isActive = template.id === selectedTemplateId; - const isParsing = - template.parseStatus === "processing" || - parsingTemplateIds.includes(template.id); - return ( -
setSelectedTemplateId(template.id)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - setSelectedTemplateId(template.id); - } - }} - className={`w-full rounded-2xl border p-4 text-left transition ${ - isActive - ? "border-orange-400/50 bg-orange-500/10" - : "border-border bg-background hover:border-orange-300/40 hover:bg-muted/30" - }`} - > -
-
-
{template.name}
-
- {template.parseStatus === "completed" - ? `${template.width} x ${template.height} · ${template.layerCount} 图层` - : "待解析完成后生成尺寸和图层"} -
-
- 品牌:{template.brandName} -
-
- 模式:{getPsdTemplateModeLabel(template.mode)} -
- {template.secondaryCategory ? ( -
- 二级分类:{template.secondaryCategory} -
- ) : null} -
-
- - {template.status === "active" ? "启用" : "停用"} - - - {getTemplateParseLabel(template)} - - - -
-
- {template.description ? ( -
- {template.description} -
- ) : null} - {template.parseStatus === "processing" ? ( -
-
- {template.parseStage || "正在解析 PSD..."} - {getTemplateParseProgress(template)}% -
- -
- ) : null} - {template.parseStatus !== "processing" && - template.parseStage && - (template.parseStatus !== "completed" || Boolean(template.parseError)) ? ( -
- {template.parseStage} -
- ) : null} - {template.parseError ? ( -
- {template.parseError} -
- ) : null} + groupedTemplates.map((group) => ( +
+
+
{group.label}
+
{group.items.length} 个模板
- ); - }) +
+ {group.items.map((template) => { + const isActive = template.id === selectedTemplateId; + const isParsing = + template.parseStatus === "processing" || + parsingTemplateIds.includes(template.id); + const isUnassignedTemplate = !normalizeManagedBrandName(template.brandName); + const templateBrandDraft = + templateBrandDrafts[template.id] ?? normalizeManagedBrandName(template.brandName); + + return ( +
setSelectedTemplateId(template.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setSelectedTemplateId(template.id); + } + }} + className={`w-full rounded-2xl border p-4 text-left transition ${ + isActive + ? "border-orange-400/50 bg-orange-500/10" + : "border-border bg-background hover:border-orange-300/40 hover:bg-muted/30" + }`} + > +
+
+
+ {template.name} +
+
+
{template.name}
+
+ {template.parseStatus === "completed" + ? `${template.width} x ${template.height} · ${template.layerCount} 图层` + : "待解析完成后生成尺寸和图层"} +
+
+ 品牌:{normalizeManagedBrandName(template.brandName) || UNASSIGNED_BRAND_LABEL} +
+
+ 模式:{getPsdTemplateModeLabel(template.mode)} +
+ {template.secondaryCategory ? ( +
+ 二级分类:{template.secondaryCategory} +
+ ) : null} + {template.description ? ( +
+ {template.description} +
+ ) : null} +
+
+
+ + {template.status === "active" ? "启用" : "停用"} + + + {getTemplateParseLabel(template)} + + + +
+
+ + {isUnassignedTemplate ? ( +
event.stopPropagation()} + > +
+ {managedBrandOptions.length > 0 ? ( + + ) : ( + + setTemplateBrandDrafts((current) => ({ + ...current, + [template.id]: event.target.value, + })) + } + placeholder="输入品牌名称" + /> + )} +
+ +
+ ) : null} + + {template.parseStatus === "processing" ? ( +
+
+ {template.parseStage || "正在解析 PSD..."} + {getTemplateParseProgress(template)}% +
+ +
+ ) : null} + {template.parseStatus !== "processing" && + template.parseStage && + (template.parseStatus !== "completed" || Boolean(template.parseError)) ? ( +
+ {template.parseStage} +
+ ) : null} + {template.parseError ? ( +
+ {template.parseError} +
+ ) : null} +
+ ); + })} +
+
+ )) )} @@ -1300,12 +1673,21 @@ export function PsdTemplatesSettingsClient({
- {brandOptions.length > 0 ? ( + {selectableDraftBrandOptions.length > 0 ? (