feat: organize psd assets by brand and harden template editor
This commit is contained in:
parent
f4d488d104
commit
11ab9a8e03
@ -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>
|
||||
|
||||
@ -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 }> }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 =
|
||||
|
||||
106
lib/agents/psd-template-image-slot-selection.ts
Normal file
106
lib/agents/psd-template-image-slot-selection.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user