feat: improve PSD template previews and deletion
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-03-27 19:17:35 +08:00
parent 305e1e5161
commit e2698a7a49
6 changed files with 218 additions and 79 deletions

View File

@ -109,6 +109,7 @@ export function PsdTemplatesSettingsClient({
const [isCreating, setIsCreating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deletingTemplateId, setDeletingTemplateId] = useState<string | null>(null);
const [isUploadingFont, setIsUploadingFont] = useState(false);
const [deletingFontId, setDeletingFontId] = useState<string | null>(null);
@ -331,15 +332,16 @@ export function PsdTemplatesSettingsClient({
}
}
async function handleDeleteTemplate() {
if (!selectedTemplate) return;
const confirmed = window.confirm(`确认删除模板“${selectedTemplate.name}”吗?`);
async function handleDeleteTemplate(template = selectedTemplate) {
if (!template) return;
const confirmed = window.confirm(`确认删除模板“${template.name}”吗?`);
if (!confirmed) return;
try {
setIsDeleting(true);
setDeletingTemplateId(template.id);
const response = await fetch(
`/api/admin/psd-templates/${encodeURIComponent(selectedTemplate.id)}`,
`/api/admin/psd-templates/${encodeURIComponent(template.id)}`,
{
method: "DELETE",
}
@ -350,12 +352,15 @@ export function PsdTemplatesSettingsClient({
}
toast.success("PSD 模板已删除");
setSelectedTemplateId(null);
if (selectedTemplateId === template.id) {
setSelectedTemplateId(null);
}
await mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : "PSD 模板删除失败");
} finally {
setIsDeleting(false);
setDeletingTemplateId(null);
}
}
@ -604,10 +609,17 @@ export function PsdTemplatesSettingsClient({
templates.map((template) => {
const isActive = template.id === selectedTemplateId;
return (
<button
<div
key={template.id}
type="button"
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"
@ -616,24 +628,43 @@ export function PsdTemplatesSettingsClient({
>
<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.width} x {template.height} · {template.layerCount} 图层
<div className="truncate text-sm font-medium">{template.name}</div>
<div className="mt-1 text-xs text-muted-foreground">
{template.width} x {template.height} · {template.layerCount} 图层
</div>
<div className="mt-1 text-xs text-muted-foreground">
品牌:{template.brandName}
</div>
</div>
<div className="mt-1 text-xs text-muted-foreground">
品牌:{template.brandName}
<div className="flex items-center gap-2">
<Badge variant={template.status === "active" ? "default" : "outline"}>
{template.status === "active" ? "启用" : "停用"}
</Badge>
<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>
<Badge variant={template.status === "active" ? "default" : "outline"}>
{template.status === "active" ? "启用" : "停用"}
</Badge>
</div>
{template.description ? (
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground">
{template.description}
</div>
) : null}
</button>
</div>
);
})
)}
@ -649,10 +680,15 @@ export function PsdTemplatesSettingsClient({
<CardTitle className="text-base">模板基础信息</CardTitle>
<CardDescription>启用前请至少配置一个可编辑槽位,文字槽位建议明确字体和字号。</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[320px,minmax(0,1fr)]">
<CardContent className="grid gap-6 lg:grid-cols-[260px,minmax(0,1fr)]">
<div className="space-y-3">
<div className="overflow-hidden rounded-2xl border bg-muted/20">
<div className="aspect-[4/5] w-full bg-black/5">
<div className="mx-auto w-full max-w-[220px] overflow-hidden rounded-2xl border bg-muted/20">
<div
className="w-full bg-black/5"
style={{
aspectRatio: `${Math.max(1, draftTemplate.width)} / ${Math.max(1, draftTemplate.height)}`,
}}
>
<img
src={draftTemplate.previewUrl}
alt={draftTemplate.name}
@ -775,7 +811,9 @@ export function PsdTemplatesSettingsClient({
<Button
variant="destructive"
size="sm"
onClick={handleDeleteTemplate}
onClick={() => {
void handleDeleteTemplate();
}}
disabled={isDeleting}
>
{isDeleting ? (
@ -819,23 +857,36 @@ export function PsdTemplatesSettingsClient({
filteredLayers.map((layer, index) => (
<div key={layer.id} className="rounded-2xl border p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">#{index + 1}</Badge>
<span className="text-sm font-medium">{layer.layerName}</span>
<Badge variant="secondary">
{layer.width} x {layer.height}
</Badge>
<Badge variant={layer.slotType === "text" ? "default" : "outline"}>
{layer.slotType === "text" ? "文字槽位" : "图片槽位"}
</Badge>
{layer.slotType === "image" && layer.maskUrl ? (
<Badge variant="outline">已生成蒙版</Badge>
) : null}
<div className="flex min-w-0 items-start gap-3">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted/20">
{layer.previewUrl ? (
<img
src={layer.previewUrl}
alt={`${layer.layerName} 预览`}
className="h-full w-full object-contain"
/>
) : (
<div className="text-[10px] text-muted-foreground">无预览</div>
)}
</div>
<div className="text-xs text-muted-foreground">{layer.layerPath}</div>
<div className="text-[11px] text-muted-foreground">
坐标:({layer.x}, {layer.y}) · layerId: {layer.layerId}
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">#{index + 1}</Badge>
<span className="text-sm font-medium">{layer.layerName}</span>
<Badge variant="secondary">
{layer.width} x {layer.height}
</Badge>
<Badge variant={layer.slotType === "text" ? "default" : "outline"}>
{layer.slotType === "text" ? "文字槽位" : "图片槽位"}
</Badge>
{layer.slotType === "image" && layer.maskUrl ? (
<Badge variant="outline">已生成蒙版</Badge>
) : null}
</div>
<div className="text-xs text-muted-foreground">{layer.layerPath}</div>
<div className="text-[11px] text-muted-foreground">
坐标:({layer.x}, {layer.y}) · layerId: {layer.layerId}
</div>
</div>
</div>

View File

@ -12,7 +12,10 @@ import {
type AgentPsdTemplateListResponse,
type AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config";
import { parsePsdTemplateFile } from "@/lib/agents/psd-template-parser";
import {
parsePsdTemplateFile,
preparePsdTemplateRenderAssets,
} from "@/lib/agents/psd-template-parser";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
export const runtime = "nodejs";
@ -136,9 +139,54 @@ export async function POST(request: NextRequest) {
),
]);
const templateId = randomUUID();
const initialLayers = parsedTemplate.layers.map((layer): AgentPsdTemplateLayerSlot => {
if (layer.slotType === "text") {
return {
...layer,
slotType: "text",
isEditable: false,
isRequired: false,
placeholder: layer.defaultText || layer.layerName,
fontSize: Math.max(16, Math.min(layer.height, Math.round(layer.height * 0.78))),
lineHeight: 1.2,
letterSpacing: 0,
fontWeight: 500,
textColor: "#111111",
textAlign: "left",
paddingX: 0,
paddingY: 0,
maxLines: 4,
};
}
return {
...layer,
slotType: "image",
isEditable: false,
isRequired: false,
isDefaultResultSource: false,
allowUserUploadOverride: true,
replaceMode: "cover",
};
});
const preparedAssets = await preparePsdTemplateRenderAssets({
templateId,
fileUrl: uploadedFile.url,
layers: initialLayers,
});
const uploadedRenderBase = await uploadFile(
`agent-psd-base-${templateId}-${Date.now()}.png`,
preparedAssets.renderBaseBuffer.buffer.slice(
preparedAssets.renderBaseBuffer.byteOffset,
preparedAssets.renderBaseBuffer.byteOffset + preparedAssets.renderBaseBuffer.byteLength
) as ArrayBuffer
);
const now = new Date().toISOString();
const nextTemplate: AgentPsdTemplate = {
id: randomUUID(),
id: templateId,
name,
brandName,
description: description || undefined,
@ -148,41 +196,12 @@ export async function POST(request: NextRequest) {
filePath: uploadedFile.pathname,
previewUrl: uploadedPreview.url,
previewPath: uploadedPreview.pathname,
renderBaseUrl: uploadedPreview.url,
renderBasePath: uploadedPreview.pathname,
renderBaseUrl: uploadedRenderBase.url,
renderBasePath: uploadedRenderBase.pathname,
width: parsedTemplate.width,
height: parsedTemplate.height,
layerCount: parsedTemplate.layers.length,
layers: parsedTemplate.layers.map((layer): AgentPsdTemplateLayerSlot => {
if (layer.slotType === "text") {
return {
...layer,
slotType: "text",
isEditable: false,
isRequired: false,
placeholder: layer.defaultText || layer.layerName,
fontSize: Math.max(16, Math.min(layer.height, Math.round(layer.height * 0.78))),
lineHeight: 1.2,
letterSpacing: 0,
fontWeight: 500,
textColor: "#111111",
textAlign: "left",
paddingX: 0,
paddingY: 0,
maxLines: 4,
};
}
return {
...layer,
slotType: "image",
isEditable: false,
isRequired: false,
isDefaultResultSource: false,
allowUserUploadOverride: true,
replaceMode: "cover",
};
}),
layers: preparedAssets.layers,
createdAt: now,
updatedAt: now,
uploadedById: session.user.id,

View File

@ -206,8 +206,12 @@ export function AgentPsdTemplateEditorDialog({
{selectedTemplate ? (
<>
<div className="overflow-hidden rounded-2xl border bg-muted/20">
<div className="aspect-[4/5]">
<div className="mx-auto w-full max-w-[240px] overflow-hidden rounded-2xl border bg-muted/20">
<div
style={{
aspectRatio: `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`,
}}
>
<img
src={selectedTemplate.previewUrl}
alt={selectedTemplate.name}
@ -436,8 +440,14 @@ export function AgentPsdTemplateEditorDialog({
{renderedResultUrl ? (
<div className="space-y-4">
<div className="overflow-hidden rounded-2xl border bg-muted/20">
<div className="aspect-[4/5]">
<div className="mx-auto w-full max-w-[280px] overflow-hidden rounded-2xl border bg-muted/20">
<div
style={{
aspectRatio: selectedTemplate
? `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`
: "1 / 1",
}}
>
<img
src={renderedResultUrl}
alt="PSD 模板渲染结果"

View File

@ -17,6 +17,8 @@ export interface AgentPsdTemplateBaseLayerSlot {
height: number;
isEditable: boolean;
isRequired: boolean;
previewUrl?: string;
previewPath?: string;
}
export interface AgentPsdTemplateImageLayerSlot
@ -207,6 +209,8 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
height,
isEditable,
isRequired: isEditable && record.isRequired === true,
previewUrl: normalizeOptionalUrl(record.previewUrl),
previewPath: normalizeText(record.previewPath) || undefined,
} satisfies AgentPsdTemplateBaseLayerSlot;
if (slotType === "text") {

View File

@ -92,6 +92,36 @@ async function rgbaToPngBuffer(rgba: Uint8ClampedArray, width: number, height: n
.toBuffer();
}
async function createLayerThumbnailBuffer(params: {
rgba: Uint8ClampedArray;
width: number;
height: number;
maxSize?: number;
}) {
const { rgba, width, height, maxSize = 160 } = params;
const sharp = await getSharp();
return sharp(Buffer.from(rgba), {
raw: {
width,
height,
channels: 4,
},
})
.resize({
width: maxSize,
height: maxSize,
fit: "contain",
background: {
r: 0,
g: 0,
b: 0,
alpha: 0,
},
})
.png()
.toBuffer();
}
function extractLayerId(node: PsdLayerLike, fallback: string) {
const additional = node.additionalProperties;
const layerIdRecord =
@ -243,10 +273,6 @@ export async function preparePsdTemplateRenderAssets(params: {
const nextLayers = [...params.layers];
for (const targetLayer of nextLayers) {
if (!isAgentPsdImageLayerSlot(targetLayer) || !targetLayer.isEditable || targetLayer.maskUrl) {
continue;
}
const entry = nodeMap.get(targetLayer.layerId);
if (!entry?.node || typeof entry.node.composite !== "function") {
continue;
@ -254,6 +280,34 @@ export async function preparePsdTemplateRenderAssets(params: {
try {
const rgba = await entry.node.composite(false, true);
if (!targetLayer.previewUrl) {
const thumbnailBuffer = await createLayerThumbnailBuffer({
rgba,
width: entry.layer.width,
height: entry.layer.height,
});
const uploadedPreview = await uploadFile(
`agent-psd-layer-preview-${params.templateId}-${sanitizeFileSegment(targetLayer.layerId, targetLayer.id)}.png`,
thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer
);
const layerIndex = nextLayers.findIndex((layer) => layer.id === targetLayer.id);
if (layerIndex >= 0) {
nextLayers[layerIndex] = {
...nextLayers[layerIndex],
previewUrl: uploadedPreview.url,
previewPath: uploadedPreview.pathname,
};
}
}
if (!isAgentPsdImageLayerSlot(targetLayer) || !targetLayer.isEditable || targetLayer.maskUrl) {
continue;
}
const pixelCount = entry.layer.width * entry.layer.height;
const maskRgba = new Uint8ClampedArray(pixelCount * 4);
let hasVisiblePixel = false;

View File

@ -20,6 +20,7 @@ export async function tryDeleteAgentPsdTemplateAssets(template: AgentPsdTemplate
template.filePath,
template.previewPath,
template.renderBasePath,
...template.layers.map((layer) => layer.previewPath),
...template.layers
.filter(isAgentPsdImageLayerSlot)
.map((layer) => layer.maskPath),