feat: improve PSD template previews and deletion
This commit is contained in:
parent
305e1e5161
commit
e2698a7a49
@ -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>
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 模板渲染结果"
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user