feat: organize psd assets by brand and harden template editor
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-02 18:29:41 +08:00
parent f4d488d104
commit 11ab9a8e03
10 changed files with 797 additions and 215 deletions

View File

@ -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<string | null>(null);
const [draftTemplate, setDraftTemplate] = useState<AgentPsdTemplate | null>(null);
@ -201,6 +222,7 @@ export function PsdTemplatesSettingsClient({
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [fontUploadName, setFontUploadName] = useState("");
const [fontFamily, setFontFamily] = useState("");
const [fontUploadBrandName, setFontUploadBrandName] = useState("");
const [fontUploadFile, setFontUploadFile] = useState<File | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [isCreatingAndParsing, setIsCreatingAndParsing] = useState(false);
@ -211,6 +233,11 @@ export function PsdTemplatesSettingsClient({
const [deletingTemplateId, setDeletingTemplateId] = useState<string | null>(null);
const [isUploadingFont, setIsUploadingFont] = useState(false);
const [deletingFontId, setDeletingFontId] = useState<string | null>(null);
const [savingTemplateBrandId, setSavingTemplateBrandId] = useState<string | null>(null);
const [savingFontBrandId, setSavingFontBrandId] = useState<string | null>(null);
const [isFontListExpanded, setIsFontListExpanded] = useState(false);
const [templateBrandDrafts, setTemplateBrandDrafts] = useState<Record<string, string>>({});
const [fontBrandDrafts, setFontBrandDrafts] = useState<Record<string, string>>({});
const [parsingTemplateIds, setParsingTemplateIds] = useState<string[]>([]);
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<string, AgentPsdTemplate[]>();
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<string, AgentPsdFont[]>();
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({
</div>
<div className="space-y-2">
<Label>所属品牌</Label>
{brandOptions.length > 0 ? (
{managedBrandOptions.length > 0 ? (
<Select value={uploadBrandName} onValueChange={setUploadBrandName}>
<SelectTrigger>
<SelectValue placeholder="请选择品牌" />
</SelectTrigger>
<SelectContent>
{brandOptions.map((brand) => (
{managedBrandOptions.map((brand) => (
<SelectItem key={brand} value={brand}>
{brand}
</SelectItem>
@ -991,6 +1151,35 @@ export function PsdTemplatesSettingsClient({
placeholder="例如:SourceHanSansCN-Medium"
/>
</div>
<div className="space-y-2">
<Label>所属品牌</Label>
{managedBrandOptions.length > 0 ? (
<Select
value={fontUploadBrandName.trim() || EMPTY_BRAND_SELECT_VALUE}
onValueChange={(value) =>
setFontUploadBrandName(value === EMPTY_BRAND_SELECT_VALUE ? "" : value)
}
>
<SelectTrigger>
<SelectValue placeholder="选择品牌分类(可选)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_BRAND_SELECT_VALUE}>暂不分类</SelectItem>
{managedBrandOptions.map((brand) => (
<SelectItem key={brand} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={fontUploadBrandName}
onChange={(event) => setFontUploadBrandName(event.target.value)}
placeholder="输入品牌分类(可选)"
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="psdFontFile">字体文件</Label>
<Input
@ -1016,44 +1205,146 @@ export function PsdTemplatesSettingsClient({
<Separator />
<div className="space-y-3">
{fonts.length === 0 ? (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
还没有上传字体,文字槽位目前只能使用系统基础字体。
</div>
) : (
fonts.map((font) => (
<div key={font.id} className="rounded-2xl border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{font.name}</div>
<div className="mt-1 text-xs text-muted-foreground">{font.family}</div>
<div className="mt-1 text-[11px] text-muted-foreground">{font.fileName}</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteFont(font)}
disabled={deletingFontId === font.id}
>
{deletingFontId === font.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
<Collapsible open={isFontListExpanded} onOpenChange={setIsFontListExpanded}>
<div className="rounded-2xl border border-dashed p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">已上传字体列表</div>
<div className="mt-1 text-xs text-muted-foreground">
默认收起,按品牌分类查看字体归属。
</div>
</div>
))
)}
</div>
<CollapsibleTrigger asChild>
<Button type="button" variant="outline" size="sm">
{isFontListExpanded ? (
<>
收起列表
<ChevronUp className="ml-2 h-4 w-4" />
</>
) : (
<>
展开列表({fonts.length})
<ChevronDown className="ml-2 h-4 w-4" />
</>
)}
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="mt-4 space-y-4">
{fonts.length === 0 ? (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
还没有上传字体,文字槽位目前只能使用系统基础字体。
</div>
) : (
groupedFonts.map((group) => (
<div key={group.label} className="space-y-3 rounded-2xl border bg-background/60 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">{group.label}</div>
<div className="text-xs text-muted-foreground">{group.items.length} 个字体</div>
</div>
<div className="space-y-3">
{group.items.map((font) => {
const fontBrandDraft =
fontBrandDrafts[font.id] ?? normalizeManagedBrandName(font.brandName);
const isUnassignedFont = !normalizeManagedBrandName(font.brandName);
return (
<div key={font.id} className="rounded-2xl border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{font.name}</div>
<div className="mt-1 text-xs text-muted-foreground">{font.family}</div>
<div className="mt-1 text-[11px] text-muted-foreground">{font.fileName}</div>
<div className="mt-2 text-[11px] text-muted-foreground">
品牌:{normalizeManagedBrandName(font.brandName) || UNASSIGNED_BRAND_LABEL}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteFont(font)}
disabled={deletingFontId === font.id}
>
{deletingFontId === font.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
{isUnassignedFont || fontBrandDrafts[font.id] ? (
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
<div className="min-w-0 flex-1">
{managedBrandOptions.length > 0 ? (
<Select
value={fontBrandDraft || EMPTY_BRAND_SELECT_VALUE}
onValueChange={(value) =>
setFontBrandDrafts((current) => ({
...current,
[font.id]:
value === EMPTY_BRAND_SELECT_VALUE ? "" : value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="选择品牌分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_BRAND_SELECT_VALUE}>暂不分类</SelectItem>
{managedBrandOptions.map((brand) => (
<SelectItem key={`${font.id}-${brand}`} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={fontBrandDraft}
onChange={(event) =>
setFontBrandDrafts((current) => ({
...current,
[font.id]: event.target.value,
}))
}
placeholder="输入品牌分类"
/>
)}
</div>
<Button
type="button"
variant="outline"
onClick={() =>
void handleQuickUpdateFontBrand(font, fontBrandDraft)
}
disabled={savingFontBrandId === font.id}
>
{savingFontBrandId === font.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
保存分类
</Button>
</div>
) : null}
</div>
);
})}
</div>
</div>
))
)}
</CollapsibleContent>
</div>
</Collapsible>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">模板列表</CardTitle>
<CardDescription>选择一个模板继续配置槽位。</CardDescription>
<CardDescription>按品牌查看模板,选择一个模板继续配置槽位。</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{templates.length === 0 ? (
@ -1061,121 +1352,203 @@ export function PsdTemplatesSettingsClient({
还没有 PSD 模板,先上传一个模板开始配置。
</div>
) : (
templates.map((template) => {
const isActive = template.id === selectedTemplateId;
const isParsing =
template.parseStatus === "processing" ||
parsingTemplateIds.includes(template.id);
return (
<div
key={template.id}
role="button"
tabIndex={0}
onClick={() => 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"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{template.name}</div>
<div className="mt-1 text-xs text-muted-foreground">
{template.parseStatus === "completed"
? `${template.width} x ${template.height} · ${template.layerCount} 图层`
: "待解析完成后生成尺寸和图层"}
</div>
<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}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<Badge variant={template.status === "active" ? "default" : "outline"}>
{template.status === "active" ? "启用" : "停用"}
</Badge>
<Badge variant={getTemplateParseBadgeVariant(template)}>
{getTemplateParseLabel(template)}
</Badge>
<Button
type="button"
variant="outline"
size="sm"
onClick={(event) => {
event.stopPropagation();
void handleParseTemplate(template);
}}
disabled={isParsing}
>
{isParsing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{getTemplateParseActionLabel(template)}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-rose-500 hover:text-rose-600"
onClick={(event) => {
event.stopPropagation();
void handleDeleteTemplate(template);
}}
disabled={isDeleting}
>
{deletingTemplateId === template.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</div>
{template.description ? (
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground">
{template.description}
</div>
) : null}
{template.parseStatus === "processing" ? (
<div className="mt-3 space-y-2 rounded-xl border border-dashed p-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{template.parseStage || "正在解析 PSD..."}</span>
<span>{getTemplateParseProgress(template)}%</span>
</div>
<Progress value={getTemplateParseProgress(template)} className="h-2" />
</div>
) : null}
{template.parseStatus !== "processing" &&
template.parseStage &&
(template.parseStatus !== "completed" || Boolean(template.parseError)) ? (
<div className="mt-2 text-xs text-muted-foreground">
{template.parseStage}
</div>
) : null}
{template.parseError ? (
<div className="mt-2 rounded-xl border border-amber-300/40 bg-amber-50 px-3 py-2 text-xs text-amber-800">
{template.parseError}
</div>
) : null}
groupedTemplates.map((group) => (
<div key={group.label} className="space-y-3 rounded-2xl border bg-background/50 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">{group.label}</div>
<div className="text-xs text-muted-foreground">{group.items.length} 个模板</div>
</div>
);
})
<div className="space-y-3">
{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 (
<div
key={template.id}
role="button"
tabIndex={0}
onClick={() => 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"
}`}
>
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="flex min-w-0 gap-4">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-2xl border bg-muted/20">
<img
src={template.previewUrl}
alt={template.name}
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{template.name}</div>
<div className="mt-1 text-xs text-muted-foreground">
{template.parseStatus === "completed"
? `${template.width} x ${template.height} · ${template.layerCount} 图层`
: "待解析完成后生成尺寸和图层"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
品牌:{normalizeManagedBrandName(template.brandName) || UNASSIGNED_BRAND_LABEL}
</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}
</div>
) : null}
{template.description ? (
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground">
{template.description}
</div>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
<Badge variant={template.status === "active" ? "default" : "outline"}>
{template.status === "active" ? "启用" : "停用"}
</Badge>
<Badge variant={getTemplateParseBadgeVariant(template)}>
{getTemplateParseLabel(template)}
</Badge>
<Button
type="button"
variant="outline"
size="sm"
onClick={(event) => {
event.stopPropagation();
void handleParseTemplate(template);
}}
disabled={isParsing}
>
{isParsing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{getTemplateParseActionLabel(template)}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-rose-500 hover:text-rose-600"
onClick={(event) => {
event.stopPropagation();
void handleDeleteTemplate(template);
}}
disabled={isDeleting}
>
{deletingTemplateId === template.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</div>
{isUnassignedTemplate ? (
<div
className="mt-3 flex flex-col gap-2 sm:flex-row"
onClick={(event) => event.stopPropagation()}
>
<div className="min-w-0 flex-1">
{managedBrandOptions.length > 0 ? (
<Select
value={templateBrandDraft || EMPTY_BRAND_SELECT_VALUE}
onValueChange={(value) =>
setTemplateBrandDrafts((current) => ({
...current,
[template.id]:
value === EMPTY_BRAND_SELECT_VALUE ? "" : value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="给未分类模板设置品牌" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_BRAND_SELECT_VALUE}>请选择品牌</SelectItem>
{managedBrandOptions.map((brand) => (
<SelectItem key={`${template.id}-${brand}`} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={templateBrandDraft}
onChange={(event) =>
setTemplateBrandDrafts((current) => ({
...current,
[template.id]: event.target.value,
}))
}
placeholder="输入品牌名称"
/>
)}
</div>
<Button
type="button"
variant="outline"
onClick={() =>
void handleQuickUpdateTemplateBrand(template, templateBrandDraft)
}
disabled={savingTemplateBrandId === template.id}
>
{savingTemplateBrandId === template.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
保存品牌分类
</Button>
</div>
) : null}
{template.parseStatus === "processing" ? (
<div className="mt-3 space-y-2 rounded-xl border border-dashed p-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{template.parseStage || "正在解析 PSD..."}</span>
<span>{getTemplateParseProgress(template)}%</span>
</div>
<Progress value={getTemplateParseProgress(template)} className="h-2" />
</div>
) : null}
{template.parseStatus !== "processing" &&
template.parseStage &&
(template.parseStatus !== "completed" || Boolean(template.parseError)) ? (
<div className="mt-2 text-xs text-muted-foreground">
{template.parseStage}
</div>
) : null}
{template.parseError ? (
<div className="mt-2 rounded-xl border border-amber-300/40 bg-amber-50 px-3 py-2 text-xs text-amber-800">
{template.parseError}
</div>
) : null}
</div>
);
})}
</div>
</div>
))
)}
</CardContent>
</Card>
@ -1300,12 +1673,21 @@ export function PsdTemplatesSettingsClient({
</div>
<div className="space-y-2">
<Label>所属品牌</Label>
{brandOptions.length > 0 ? (
{selectableDraftBrandOptions.length > 0 ? (
<Select
value={draftTemplate.brandName}
value={
normalizeManagedBrandName(draftTemplate.brandName) ||
EMPTY_BRAND_SELECT_VALUE
}
onValueChange={(value) =>
setDraftTemplate((current) =>
current ? { ...current, brandName: value } : current
current
? {
...current,
brandName:
value === EMPTY_BRAND_SELECT_VALUE ? "未分配品牌" : value,
}
: current
)
}
>
@ -1313,7 +1695,8 @@ export function PsdTemplatesSettingsClient({
<SelectValue />
</SelectTrigger>
<SelectContent>
{brandOptions.map((brand) => (
<SelectItem value={EMPTY_BRAND_SELECT_VALUE}>未分类</SelectItem>
{selectableDraftBrandOptions.map((brand) => (
<SelectItem key={brand} value={brand}>
{brand}
</SelectItem>

View File

@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { upsertSystemSetting } from "@/lib/db/queries";
import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin";
import {
AGENT_PSD_FONTS_KEY,
upsertAgentPsdFont,
type AgentPsdFontMutationResponse,
} from "@/lib/agents/psd-font-config";
import { isAgentPsdTextLayerSlot } from "@/lib/agents/psd-template-config";
@ -14,6 +16,12 @@ import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
export const runtime = "nodejs";
const updateFontSchema = z.object({
name: z.string().trim().min(1).max(80),
family: z.string().trim().min(1).max(120),
brandName: z.string().trim().max(64).optional().nullable(),
});
function jsonError(status: number, error: string) {
const payload: AgentPsdFontMutationResponse = {
success: false,
@ -22,6 +30,55 @@ function jsonError(status: number, error: string) {
return NextResponse.json(payload, { status });
}
export async function PUT(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
const session = await requireAgentWorkflowAdmin().catch(() => null);
if (!session?.user?.id) {
return jsonError(401, "未登录");
}
let body: z.infer<typeof updateFontSchema>;
try {
body = updateFontSchema.parse(await request.json());
} catch {
return jsonError(400, "字体参数不合法");
}
try {
const { id } = await context.params;
const fonts = await readAgentPsdFonts();
const currentFont = fonts.find((entry) => entry.id === id);
if (!currentFont) {
return jsonError(404, "字体不存在");
}
const updatedFont = {
...currentFont,
name: body.name.trim(),
family: body.family.trim(),
brandName: String(body.brandName || "").trim() || undefined,
updatedAt: new Date().toISOString(),
};
await upsertSystemSetting({
key: AGENT_PSD_FONTS_KEY,
value: upsertAgentPsdFont(fonts, updatedFont),
updatedBy: session.user.id,
});
return NextResponse.json({
success: true,
font: updatedFont,
} satisfies AgentPsdFontMutationResponse);
} catch (error) {
console.error("[PSD Fonts][Admin] update failed:", error);
return jsonError(502, "字体更新失败");
}
}
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ id: string }> }

View File

@ -78,11 +78,16 @@ export async function POST(request: NextRequest) {
String(formData.get("name") || "").trim() ||
(file instanceof File ? getDefaultFontName(file.name) : "");
const family = String(formData.get("family") || "").trim() || name;
const brandName = String(formData.get("brandName") || "").trim();
if (!(file instanceof File)) {
return jsonMutationError(400, "缺少字体文件");
}
if (brandName.length > 64) {
return jsonMutationError(400, "字体品牌不能超过 64 个字符");
}
const extension = getFileExtension(file.name);
if (!ALLOWED_FONT_EXTENSIONS.has(extension)) {
return jsonMutationError(400, "字体文件仅支持 ttf / otf / woff / woff2");
@ -104,6 +109,7 @@ export async function POST(request: NextRequest) {
id: randomUUID(),
name,
family,
brandName: brandName || undefined,
fileName: file.name,
fileUrl: uploaded.url,
filePath: uploaded.pathname,

View File

@ -15,6 +15,7 @@ import {
parsePsdTemplateFile,
preparePsdTemplateRenderAssets,
} from "@/lib/agents/psd-template-parser";
import { pickPrimaryPsdImageLayerId } from "@/lib/agents/psd-template-image-slot-selection";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { uploadFile } from "@/lib/storage/service";
@ -42,62 +43,17 @@ function getErrorMessage(error: unknown) {
return "PSD 解析失败";
}
function getCanvasOverlapArea(params: {
x: number;
y: number;
width: number;
height: number;
canvasWidth: number;
canvasHeight: number;
}) {
const left = Math.max(0, params.x);
const top = Math.max(0, params.y);
const right = Math.min(params.canvasWidth, params.x + params.width);
const bottom = Math.min(params.canvasHeight, params.y + params.height);
return Math.max(0, right - left) * Math.max(0, bottom - top);
}
function pickPrimaryImageLayerId(params: {
parsedLayers: Awaited<ReturnType<typeof parsePsdTemplateFile>>["layers"];
canvasWidth: number;
canvasHeight: number;
}) {
const { parsedLayers, canvasWidth, canvasHeight } = params;
const imageLayers = parsedLayers.filter((layer) => layer.slotType === "image");
if (imageLayers.length === 0) {
return null;
}
const backgroundPattern = /(背景|底图|bg|background)/i;
const scored = imageLayers.map((layer) => {
const overlapArea = getCanvasOverlapArea({
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
canvasWidth,
canvasHeight,
});
const layerArea = Math.max(1, layer.width * layer.height);
const coversWholeCanvas =
overlapArea >= canvasWidth * canvasHeight * 0.96 &&
layer.x <= 0 &&
layer.y <= 0 &&
layer.width >= canvasWidth * 0.96 &&
layer.height >= canvasHeight * 0.96;
const backgroundPenalty =
backgroundPattern.test(layer.layerName) || backgroundPattern.test(layer.layerPath) || coversWholeCanvas
? 0.45
: 1;
return {
layer,
score: Math.max(overlapArea, layerArea * 0.5) * backgroundPenalty,
};
return pickPrimaryPsdImageLayerId({
imageLayers: parsedLayers.filter((layer) => layer.slotType === "image"),
canvasWidth,
canvasHeight,
});
scored.sort((left, right) => right.score - left.score);
return scored[0]?.layer.id || null;
}
function shouldAutoEditableTextLayer(

View File

@ -415,6 +415,7 @@ function getPlacedAssignedImageStyle(params: {
height: baseFrame.height,
transform: `translate(-50%, -50%) scale(${normalizedPlacement.scale})`,
transformOrigin: "center center",
objectFit: getImageFit(layer.replaceMode),
willChange: "transform",
};
}
@ -688,13 +689,13 @@ export function AgentPsdTemplateEditorDialog({
const selectedImageAdjustmentPanel =
selectedImageLayer ? (
<div className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.03] p-3.5">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2">
{sourceImageUrl ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-10 justify-center"
className="h-11 w-full min-w-0 justify-center gap-1.5 px-3 text-[13px]"
onClick={() => {
setImageAssignments((current) => ({
...current,
@ -711,23 +712,23 @@ export function AgentPsdTemplateEditorDialog({
});
}}
>
使用当前图片
<span className="min-w-0 truncate">使用当前图片</span>
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
className="h-10 justify-center"
className="h-11 w-full min-w-0 justify-center gap-1.5 px-3 text-[13px]"
disabled={uploadingSlotId === selectedImageLayer.id}
onClick={() => openImageUpload(selectedImageLayer.id)}
>
{uploadingSlotId === selectedImageLayer.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
<Upload className="h-4 w-4 shrink-0" />
)}
上传替换图片
<span className="min-w-0 truncate">上传替换图片</span>
</Button>
</div>
@ -1255,16 +1256,19 @@ export function AgentPsdTemplateEditorDialog({
<SelectValue placeholder={isLoading ? "加载模板中..." : "请选择模板"} />
)}
</SelectTrigger>
<SelectContent>
<SelectContent
className="border-white/10 bg-[#101114] text-white shadow-2xl"
style={{ backgroundColor: "#101114", color: "#ffffff" }}
>
{shouldGroupTemplatesBySecondaryCategory
? groupedTemplates.map((group) => (
<SelectGroup key={`psd-template-group-${group.label}`}>
<SelectLabel>{group.label}</SelectLabel>
<SelectLabel className="text-white/50">{group.label}</SelectLabel>
{group.templates.map((template) => (
<SelectItem
key={template.id}
value={template.id}
className="py-2.5 pr-3"
className="py-2.5 pr-3 text-white focus:bg-white/10 focus:text-white data-[state=checked]:bg-white/10 data-[state=checked]:text-white"
>
{renderTemplateSelectContent(template, {
showSecondaryCategory: shouldGroupTemplatesBySecondaryCategory,
@ -1278,7 +1282,7 @@ export function AgentPsdTemplateEditorDialog({
<SelectItem
key={template.id}
value={template.id}
className="py-2.5 pr-3"
className="py-2.5 pr-3 text-white focus:bg-white/10 focus:text-white data-[state=checked]:bg-white/10 data-[state=checked]:text-white"
>
{renderTemplateSelectContent(template, { compact: true })}
</SelectItem>

View File

@ -4,6 +4,7 @@ export interface AgentPsdFont {
id: string;
name: string;
family: string;
brandName?: string;
fileName: string;
fileUrl: string;
filePath?: string;
@ -70,6 +71,7 @@ export function parseAgentPsdFonts(value: unknown) {
id,
name,
family,
brandName: normalizeText(record.brandName) || undefined,
fileName,
fileUrl,
filePath: normalizeText(record.filePath) || undefined,

View File

@ -2,7 +2,7 @@ import type { AgentPsdTextColorConfig } from "@/types/agent";
import type { AgentPsdTemplateSecondaryCategoryConfig } from "@/lib/agents/psd-template-secondary-category-config";
export const AGENT_PSD_TEMPLATES_KEY = "agent_psd_templates";
export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 4;
export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 5;
export type AgentPsdTemplateStatus = "active" | "disabled";
export type AgentPsdTemplateParseStatus =

View File

@ -0,0 +1,106 @@
import type { AgentPsdTemplateImageContentBounds } from "@/lib/agents/psd-template-config";
type PsdImageLayerCandidate = {
id: string;
layerName: string;
layerPath: string;
x: number;
y: number;
width: number;
height: number;
contentBounds?: AgentPsdTemplateImageContentBounds;
};
const BACKGROUND_LAYER_PATTERN = /(背景|底图|bg|background)/i;
const ADJUSTMENT_LAYER_PATTERN =
/(色相\s*\/?\s*饱和度|曲线|色阶|亮度\s*\/?\s*对比度|可选颜色|色彩平衡|自然饱和度|曝光|黑白|阈值|反相|海报化|通道混合器|渐变映射|纯色|渐变填充|图案填充|hue\s*\/?\s*saturation|curves?|levels?|brightness\s*\/?\s*contrast|selective color|color balance|vibrance|exposure|black\s*&?\s*white|threshold|invert|posterize|channel mixer|gradient map|solid color|gradient fill|pattern fill)/i;
const PRIMARY_IMAGE_HINT_PATTERN =
/(主图|产品|商品|主体|sku|result|photo|image|product|subject|scene|素材)/i;
function getCanvasOverlapArea(params: {
x: number;
y: number;
width: number;
height: number;
canvasWidth: number;
canvasHeight: number;
}) {
const left = Math.max(0, params.x);
const top = Math.max(0, params.y);
const right = Math.min(params.canvasWidth, params.x + params.width);
const bottom = Math.min(params.canvasHeight, params.y + params.height);
return Math.max(0, right - left) * Math.max(0, bottom - top);
}
export function isPsdAdjustmentLikeImageLayer(layer: {
layerName: string;
layerPath: string;
}) {
return (
ADJUSTMENT_LAYER_PATTERN.test(layer.layerName) ||
ADJUSTMENT_LAYER_PATTERN.test(layer.layerPath)
);
}
export function pickPrimaryPsdImageLayerId(params: {
imageLayers: PsdImageLayerCandidate[];
canvasWidth: number;
canvasHeight: number;
}) {
const { imageLayers, canvasWidth, canvasHeight } = params;
if (imageLayers.length === 0) {
return null;
}
const scored = imageLayers.map((layer) => {
const overlapArea = getCanvasOverlapArea({
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
canvasWidth,
canvasHeight,
});
const layerArea = Math.max(1, layer.width * layer.height);
const visibleArea =
layer.contentBounds && layer.contentBounds.width > 0 && layer.contentBounds.height > 0
? layer.contentBounds.width * layer.contentBounds.height
: overlapArea;
const coversWholeCanvas =
overlapArea >= canvasWidth * canvasHeight * 0.96 &&
layer.x <= 0 &&
layer.y <= 0 &&
layer.width >= canvasWidth * 0.96 &&
layer.height >= canvasHeight * 0.96;
const isAdjustmentLike = isPsdAdjustmentLikeImageLayer(layer);
const hasPrimaryImageHint =
PRIMARY_IMAGE_HINT_PATTERN.test(layer.layerName) ||
PRIMARY_IMAGE_HINT_PATTERN.test(layer.layerPath);
const backgroundPenalty =
BACKGROUND_LAYER_PATTERN.test(layer.layerName) ||
BACKGROUND_LAYER_PATTERN.test(layer.layerPath) ||
coversWholeCanvas
? 0.45
: 1;
const adjustmentPenalty = isAdjustmentLike ? 0.05 : 1;
const oversizePenalty =
layer.width > canvasWidth * 1.2 || layer.height > canvasHeight * 1.2 ? 0.9 : 1;
const primaryHintBonus = hasPrimaryImageHint ? 1.2 : 1;
return {
layer,
score:
(visibleArea * 1.15 + overlapArea + layerArea * 0.2) *
backgroundPenalty *
adjustmentPenalty *
oversizePenalty *
primaryHintBonus,
isAdjustmentLike,
};
});
const preferredCandidates = scored.filter((entry) => !entry.isAdjustmentLike);
const candidates = preferredCandidates.length > 0 ? preferredCandidates : scored;
candidates.sort((left, right) => right.score - left.score);
return candidates[0]?.layer.id || null;
}

View File

@ -475,7 +475,12 @@ export async function renderAgentPsdTemplate(params: {
.resize({
width: Math.max(1, Math.round(placementFrame.width)),
height: Math.max(1, Math.round(placementFrame.height)),
fit: "fill",
fit:
layer.replaceMode === "contain"
? "contain"
: layer.replaceMode === "stretch"
? "fill"
: "cover",
background: {
r: 0,
g: 0,

View File

@ -8,6 +8,10 @@ import {
isAgentPsdTextLayerSlot,
type AgentPsdTemplate,
} from "@/lib/agents/psd-template-config";
import {
isPsdAdjustmentLikeImageLayer,
pickPrimaryPsdImageLayerId,
} from "@/lib/agents/psd-template-image-slot-selection";
import {
parsePsdTemplateFileFromUrl,
preparePsdTemplateRenderAssets,
@ -44,6 +48,65 @@ function hasDedicatedRenderBase(template: AgentPsdTemplate) {
);
}
function repairEditableImageLayerSelection(template: AgentPsdTemplate) {
const imageLayers = template.layers.filter(isAgentPsdImageLayerSlot);
if (imageLayers.length === 0) {
return template.layers;
}
const editableImageLayers = imageLayers.filter((layer) => layer.isEditable);
const defaultImageLayer = imageLayers.find((layer) => layer.isEditable && layer.isDefaultResultSource);
const shouldAutoRepairSelection =
editableImageLayers.length === 0 ||
!defaultImageLayer ||
(editableImageLayers.length === 1 && isPsdAdjustmentLikeImageLayer(editableImageLayers[0]));
if (!shouldAutoRepairSelection) {
return template.layers;
}
const nextPrimaryLayerId = pickPrimaryPsdImageLayerId({
imageLayers,
canvasWidth: template.width,
canvasHeight: template.height,
});
if (!nextPrimaryLayerId) {
return template.layers;
}
const currentPrimaryLayerId = defaultImageLayer?.id || editableImageLayers[0]?.id || "";
if (currentPrimaryLayerId === nextPrimaryLayerId) {
return template.layers;
}
return template.layers.map((layer) => {
if (!isAgentPsdImageLayerSlot(layer)) {
return layer;
}
if (layer.id === nextPrimaryLayerId) {
return {
...layer,
isEditable: true,
isDefaultResultSource: true,
allowUserUploadOverride: true,
};
}
if (layer.id === currentPrimaryLayerId) {
return {
...layer,
isEditable: false,
isDefaultResultSource: false,
allowUserUploadOverride: false,
};
}
return layer;
});
}
function toArrayBuffer(buffer: Buffer) {
return buffer.buffer.slice(
buffer.byteOffset,
@ -65,7 +128,7 @@ export async function repairAgentPsdTemplateAssetsIfNeeded(
const fonts = await readAgentPsdFonts().catch(() => []);
const needsFontDetection = needsTextMetadataRepair(template);
let nextLayers = template.layers;
let nextLayers = repairEditableImageLayerSelection(template);
if (needsFontDetection) {
try {
const parsedTemplate = await parsePsdTemplateFileFromUrl(template.fileUrl);