feat: add in-canvas PSD template editing
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 23:05:45 +08:00
parent 449a631db4
commit 2b2b2bd228
6 changed files with 499 additions and 236 deletions

View File

@ -147,6 +147,8 @@ function buildInitialTemplateLayers(params: {
fontId: matchedFont?.font.id,
fontFamily: matchedFont?.font.family,
fontLabel: matchedFont?.font.name,
fontFileUrl: matchedFont?.font.fileUrl,
fontContentType: matchedFont?.font.contentType,
fontSize: Math.max(
10,
Math.round(

View File

@ -103,6 +103,8 @@ async function repairTemplateLayerPreviewsIfNeeded(template: NonNullable<Awaited
fontId: matchedFont.font.id,
fontFamily: matchedFont.font.family,
fontLabel: matchedFont.font.name,
fontFileUrl: matchedFont.font.fileUrl,
fontContentType: matchedFont.font.contentType,
};
});
const hasFontRepair = nextLayers.some(
@ -259,6 +261,8 @@ export async function PUT(
layer.fontId = matchedFont.font.id;
layer.fontFamily = matchedFont.font.family;
layer.fontLabel = matchedFont.font.name;
layer.fontFileUrl = matchedFont.font.fileUrl;
layer.fontContentType = matchedFont.font.contentType;
}
}
@ -270,6 +274,8 @@ export async function PUT(
layer.fontFamily = matchedFont.family;
layer.fontLabel = matchedFont.name;
layer.fontFileUrl = matchedFont.fileUrl;
layer.fontContentType = matchedFont.contentType;
}
if (!String(layer.fontFamily || "").trim()) {

View File

@ -126,6 +126,8 @@ async function repairTemplateLayerPreviewsIfNeeded(params: {
fontId: matchedFont.font.id,
fontFamily: matchedFont.font.family,
fontLabel: matchedFont.font.name,
fontFileUrl: matchedFont.font.fileUrl,
fontContentType: matchedFont.font.contentType,
};
});
const hasFontRepair = nextLayers.some(

View File

@ -2,9 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
getEditableAgentPsdTemplateLayers,
isAgentPsdTextLayerSlot,
isAgentPsdTemplateReady,
type AgentPsdTemplateListResponse,
} from "@/lib/agents/psd-template-config";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
export const runtime = "nodejs";
@ -27,6 +29,8 @@ export async function GET(request: NextRequest) {
try {
const requestedBrand = String(request.nextUrl.searchParams.get("brand") || "").trim();
const fonts = await readAgentPsdFonts().catch(() => []);
const fontMap = new Map(fonts.map((font) => [font.id, font]));
const templates = (await readAgentPsdTemplates())
.filter((template) => template.status === "active")
.filter(isAgentPsdTemplateReady)
@ -35,7 +39,22 @@ export async function GET(request: NextRequest) {
)
.map((template) => ({
...template,
layers: getEditableAgentPsdTemplateLayers(template),
layers: getEditableAgentPsdTemplateLayers(template).map((layer) => {
if (!isAgentPsdTextLayerSlot(layer) || !layer.fontId) {
return layer;
}
const font = fontMap.get(layer.fontId);
if (!font) {
return layer;
}
return {
...layer,
fontFileUrl: font.fileUrl,
fontContentType: font.contentType,
};
}),
}))
.filter((template) => template.layers.length > 0);

View File

@ -1,12 +1,13 @@
"use client";
import { useEffect, useMemo, useState, type ChangeEvent } from "react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import {
Download,
Image as ImageIcon,
Loader2,
MousePointerClick,
Sparkles,
Type,
Upload,
@ -30,8 +31,11 @@ import {
isAgentPsdImageLayerSlot,
isAgentPsdTextLayerSlot,
type AgentPsdTemplate,
type AgentPsdTemplateImageLayerSlot,
type AgentPsdTemplateLayerSlot,
type AgentPsdTemplateListResponse,
type AgentPsdTemplateRenderResponse,
type AgentPsdTemplateTextLayerSlot,
} from "@/lib/agents/psd-template-config";
const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json());
@ -56,6 +60,51 @@ function createInitialState(template: AgentPsdTemplate | null, sourceImageUrl: s
return { imageAssignments, textAssignments };
}
function getLayerLabel(layer: AgentPsdTemplateLayerSlot) {
return isAgentPsdImageLayerSlot(layer) ? "可替换图片" : "可编辑文字";
}
function getLayerOutlineColor(layer: AgentPsdTemplateLayerSlot) {
return isAgentPsdImageLayerSlot(layer)
? "rgba(249,115,22,0.95)"
: "rgba(56,189,248,0.95)";
}
function buildPreviewFontFaces(template: AgentPsdTemplate | null) {
if (!template) return "";
const rules = new Map<string, string>();
for (const layer of template.layers) {
if (!isAgentPsdTextLayerSlot(layer) || !layer.fontFamily || !layer.fontFileUrl) {
continue;
}
if (rules.has(layer.fontFamily)) {
continue;
}
rules.set(
layer.fontFamily,
`@font-face { font-family: "${layer.fontFamily}"; src: url("${layer.fontFileUrl}") format("${layer.fontContentType?.includes("woff2") ? "woff2" : layer.fontContentType?.includes("woff") ? "woff" : layer.fontContentType?.includes("otf") ? "opentype" : "truetype"}"); font-display: swap; }`
);
}
return Array.from(rules.values()).join("\n");
}
function getPreferredSelectedLayerId(template: AgentPsdTemplate | null) {
if (!template || template.layers.length === 0) {
return "";
}
const preferredImage = template.layers.find(
(layer) => isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource
);
if (preferredImage) {
return preferredImage.id;
}
return template.layers[0]?.id || "";
}
export function AgentPsdTemplateEditorDialog({
open,
onOpenChange,
@ -77,11 +126,16 @@ export function AgentPsdTemplateEditorDialog({
const templates = useMemo(() => (data?.success ? data.templates : []), [data]);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [selectedLayerId, setSelectedLayerId] = useState<string>("");
const [imageAssignments, setImageAssignments] = useState<Record<string, string>>({});
const [textAssignments, setTextAssignments] = useState<Record<string, string>>({});
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false);
const [renderedResultUrl, setRenderedResultUrl] = useState<string>("");
const [stageScale, setStageScale] = useState(1);
const previewViewportRef = useRef<HTMLDivElement | null>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const selectedTemplate = useMemo(
() => templates.find((template) => template.id === selectedTemplateId) || null,
@ -107,6 +161,50 @@ export function AgentPsdTemplateEditorDialog({
return Math.max(0, selectedTemplate.layerCount - selectedTemplate.layers.length);
}, [selectedTemplate]);
const effectiveImageAssignments = useMemo(() => {
const nextAssignments: Record<string, string> = {};
if (!selectedTemplate) {
return nextAssignments;
}
for (const layer of editableImageLayers) {
const explicit = String(imageAssignments[layer.id] || "").trim();
if (explicit) {
nextAssignments[layer.id] = explicit;
continue;
}
if (layer.isDefaultResultSource && sourceImageUrl?.trim()) {
nextAssignments[layer.id] = sourceImageUrl.trim();
}
}
return nextAssignments;
}, [editableImageLayers, imageAssignments, selectedTemplate, sourceImageUrl]);
const effectiveTextAssignments = useMemo(() => {
const nextAssignments: Record<string, string> = {};
if (!selectedTemplate) {
return nextAssignments;
}
for (const layer of editableTextLayers) {
const explicit = textAssignments[layer.id];
nextAssignments[layer.id] =
typeof explicit === "string" ? explicit : String(layer.defaultText || "");
}
return nextAssignments;
}, [editableTextLayers, selectedTemplate, textAssignments]);
const selectedLayer = useMemo(
() => selectedTemplate?.layers.find((layer) => layer.id === selectedLayerId) || null,
[selectedLayerId, selectedTemplate]
);
const selectedImageLayer = selectedLayer && isAgentPsdImageLayerSlot(selectedLayer) ? selectedLayer : null;
const selectedTextLayer = selectedLayer && isAgentPsdTextLayerSlot(selectedLayer) ? selectedLayer : null;
const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]);
useEffect(() => {
if (!open) return;
@ -121,8 +219,38 @@ export function AgentPsdTemplateEditorDialog({
const initialState = createInitialState(selectedTemplate, sourceImageUrl || "");
setImageAssignments(initialState.imageAssignments);
setTextAssignments(initialState.textAssignments);
setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate));
}, [open, selectedTemplate, sourceImageUrl]);
useEffect(() => {
if (!selectedTemplate) {
setStageScale(1);
return;
}
const element = previewViewportRef.current;
if (!element) return;
const updateScale = () => {
const rect = element.getBoundingClientRect();
const availableWidth = Math.max(240, rect.width - 32);
const availableHeight = Math.max(240, rect.height - 32);
const nextScale = Math.min(
availableWidth / Math.max(1, selectedTemplate.width),
availableHeight / Math.max(1, selectedTemplate.height),
1
);
setStageScale(Number.isFinite(nextScale) && nextScale > 0 ? nextScale : 1);
};
updateScale();
const observer = new ResizeObserver(updateScale);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [selectedTemplate]);
async function handleSlotUpload(slotId: string, event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
@ -151,6 +279,14 @@ export function AgentPsdTemplateEditorDialog({
}
}
function openImageUpload(slotId: string) {
setSelectedLayerId(slotId);
if (uploadInputRef.current) {
uploadInputRef.current.value = "";
uploadInputRef.current.click();
}
}
async function handleRender() {
if (!selectedTemplate) {
toast.error("请先选择 PSD 模板");
@ -167,8 +303,8 @@ export function AgentPsdTemplateEditorDialog({
body: JSON.stringify({
templateId: selectedTemplate.id,
sourceImageUrl: sourceImageUrl || "",
imageAssignments,
textAssignments,
imageAssignments: effectiveImageAssignments,
textAssignments: effectiveTextAssignments,
}),
});
@ -195,7 +331,7 @@ export function AgentPsdTemplateEditorDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
overlayClassName="bg-black/85 backdrop-blur-sm"
className="flex h-[92vh] w-[96vw] max-w-[1600px] flex-col overflow-hidden border border-white/10 bg-[#09090b] p-0 text-white shadow-[0_30px_120px_rgba(0,0,0,0.65)]"
className="flex h-[92vh] w-[96vw] max-w-[1700px] flex-col overflow-hidden border border-white/10 bg-[#09090b] p-0 text-white shadow-[0_30px_120px_rgba(0,0,0,0.65)]"
>
<DialogHeader className="border-b border-white/10 bg-[#09090b] px-6 py-5">
<DialogTitle className="flex items-center gap-2">
@ -203,12 +339,14 @@ export function AgentPsdTemplateEditorDialog({
套用 PSD 模板
</DialogTitle>
<DialogDescription>
这里是直接把当前图片和输入文字替换进 PSD 模板,不会再次调用 AI 生图模型。
这里是直接在模板里替换图片和修改文字,不会再次调用 AI 生图模型。
</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[340px_minmax(0,1fr)] xl:grid-cols-[340px_minmax(0,1fr)_360px]">
<div className="min-h-0 border-b border-white/10 bg-[#09090b] p-5 lg:border-b-0 lg:border-r">
{fontFaceCss ? <style>{fontFaceCss}</style> : null}
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden xl:grid-cols-[300px_minmax(0,1fr)_380px]">
<div className="min-h-0 border-b border-white/10 bg-[#09090b] p-5 xl:border-b-0 xl:border-r">
<ScrollArea className="h-full pr-4">
<div className="space-y-4">
<div className="space-y-2">
@ -229,7 +367,7 @@ export function AgentPsdTemplateEditorDialog({
{selectedTemplate ? (
<>
<div className="mx-auto w-full max-w-[240px] overflow-hidden rounded-2xl border border-white/10 bg-white/5">
<div className="mx-auto w-full max-w-[220px] overflow-hidden rounded-2xl border border-white/10 bg-white/5">
<div
style={{
aspectRatio: `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`,
@ -255,15 +393,9 @@ export function AgentPsdTemplateEditorDialog({
) : null}
</div>
{selectedTemplate.description ? (
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-sm text-muted-foreground">
{selectedTemplate.description}
</div>
) : null}
<div className="rounded-xl border border-dashed border-white/15 bg-white/[0.02] p-3 text-xs leading-6 text-muted-foreground">
用户侧这里只会显示后台已经开放的槽位:
图片图层显示为“可替换图片”,文字图层显示为“可编辑文字”,其余固定图层不会出现在这里。
操作方式更接近 PS:
直接点击模板里的图片区域去替换图片,点击模板里的文字区域直接改现有文字。
</div>
<Button
@ -294,246 +426,344 @@ export function AgentPsdTemplateEditorDialog({
<div className="min-h-0 border-b border-white/10 bg-[#09090b] p-5 xl:border-b-0 xl:border-r">
<div className="mb-4 flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">槽位内容</div>
<div className="text-sm font-medium">模板画布编辑区</div>
{selectedTemplate ? (
<>
<Badge variant="outline">{editableImageLayers.length} 个可替换图片</Badge>
<Badge variant="outline">{editableTextLayers.length} 个可编辑文字</Badge>
<Badge variant="outline">{editableImageLayers.length} 个图片替换区域</Badge>
<Badge variant="outline">{editableTextLayers.length} 个文字编辑区域</Badge>
</>
) : null}
</div>
<div className="mb-4 text-xs text-muted-foreground">
图片槽位直接替换图片,文字槽位直接修改文案,不会再次调用 AI 生图。
点模板上的橙色框替换图片,点蓝色框直接修改文字。
</div>
<ScrollArea className="h-full min-h-0 pr-4">
<div
ref={previewViewportRef}
className="relative flex h-full min-h-[520px] items-start justify-center overflow-auto rounded-2xl border border-white/10 bg-[#111114]"
>
{selectedTemplate ? (
<div className="space-y-6">
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">可替换图片</div>
<Badge variant="secondary">{editableImageLayers.length}</Badge>
</div>
{editableImageLayers.length > 0 ? (
editableImageLayers.map((layer) => {
const assignedUrl = imageAssignments[layer.id] || "";
const slotUploadInputId = `psd-slot-upload-${layer.id}`;
return (
<div key={layer.id} className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{layer.layerName}</div>
<Badge variant="default">可替换图片</Badge>
{layer.isRequired ? <Badge variant="destructive">必传</Badge> : null}
{layer.isDefaultResultSource ? (
<Badge variant="secondary">默认使用当前图片</Badge>
) : null}
<Badge variant="outline">
{layer.width} x {layer.height}
</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">{layer.layerPath}</div>
{assignedUrl ? (
<div className="mt-3 overflow-hidden rounded-xl border border-white/10 bg-white/5">
<div className="aspect-[4/3]">
<img
src={assignedUrl}
alt={layer.layerName}
className="h-full w-full object-contain"
/>
</div>
</div>
) : (
<div className="mt-3 flex aspect-[4/3] items-center justify-center rounded-xl border border-dashed border-white/15 bg-white/[0.02] text-xs text-muted-foreground">
暂未替换图片
</div>
)}
<div className="mt-3 flex flex-wrap gap-2">
{sourceImageUrl ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setImageAssignments((current) => ({
...current,
[layer.id]: sourceImageUrl,
}))
}
>
使用当前图片
</Button>
) : null}
{layer.allowUserUploadOverride ? (
<>
<Input
id={slotUploadInputId}
className="hidden"
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => handleSlotUpload(layer.id, event)}
/>
<Label htmlFor={slotUploadInputId} className="inline-flex">
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingSlotId === layer.id}
onClick={(event) => {
if (uploadingSlotId === layer.id) {
event.preventDefault();
}
}}
>
{uploadingSlotId === layer.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传替换图片
</Button>
</Label>
</>
) : null}
{assignedUrl ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setImageAssignments((current) => {
const nextAssignments = { ...current };
delete nextAssignments[layer.id];
return nextAssignments;
})
}
>
清空
</Button>
) : null}
</div>
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed border-white/15 p-5 text-sm text-muted-foreground">
这个模板当前没有开放可替换图片槽位。
</div>
)}
</section>
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">可编辑文字</div>
<Badge variant="secondary">{editableTextLayers.length}</Badge>
</div>
{editableTextLayers.length > 0 ? (
editableTextLayers.map((layer) => {
const textValue = textAssignments[layer.id] || "";
return (
<div key={layer.id} className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{layer.layerName}</div>
<Badge variant="default">可编辑文字</Badge>
{layer.isRequired ? <Badge variant="destructive">必填</Badge> : null}
<Badge variant="outline">
{layer.width} x {layer.height}
</Badge>
{layer.fontLabel || layer.fontFamily ? (
<Badge variant="secondary">{layer.fontLabel || layer.fontFamily}</Badge>
) : null}
</div>
<div className="mt-1 text-xs text-muted-foreground">{layer.layerPath}</div>
<div className="mt-3 grid gap-3">
<Textarea
rows={4}
value={textValue}
placeholder={layer.placeholder || "请输入文字"}
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[layer.id]: event.target.value,
}))
}
/>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>字体:{layer.fontLabel || layer.fontFamily || "系统字体"}</span>
<span>字号:{layer.fontSize}px</span>
<span>行高:{layer.lineHeight}</span>
<span>对齐:{layer.textAlign}</span>
</div>
</div>
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed border-white/15 p-5 text-sm text-muted-foreground">
这个模板当前没有开放可编辑文字槽位。
</div>
)}
</section>
</div>
) : (
<div className="rounded-2xl border border-dashed border-white/15 p-5 text-sm text-muted-foreground">
{normalizedBrandName ? "请先选择一个模板。" : "请先在工作流中选择品牌后再使用 PSD 模板。"}
</div>
)}
</ScrollArea>
</div>
<div className="min-h-0 bg-[#09090b] p-5">
<div className="mb-4">
<div className="text-sm font-medium">导出结果</div>
<div className="mt-1 text-xs text-muted-foreground">
直接完成模板替换后,这里会显示最终导出的成品图。
</div>
</div>
{renderedResultUrl ? (
<div className="flex h-full min-h-0 flex-col justify-between gap-4">
<div className="mx-auto w-full max-w-[280px] overflow-hidden rounded-2xl border border-white/10 bg-white/5">
<div
className="relative my-4"
style={{
width: selectedTemplate.width * stageScale,
height: selectedTemplate.height * stageScale,
}}
>
<div
className="absolute left-0 top-0 overflow-hidden rounded-[20px] shadow-2xl"
style={{
aspectRatio: selectedTemplate
? `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`
: "1 / 1",
width: selectedTemplate.width,
height: selectedTemplate.height,
transform: `scale(${stageScale})`,
transformOrigin: "top left",
}}
>
<img
src={renderedResultUrl}
alt="PSD 模板导出结果"
className="h-full w-full object-contain"
src={selectedTemplate.renderBaseUrl || selectedTemplate.previewUrl}
alt={`${selectedTemplate.name} 编辑底图`}
className="absolute inset-0 h-full w-full object-cover"
/>
{editableImageLayers.map((layer) => {
const assignedUrl = effectiveImageAssignments[layer.id];
const isSelected = selectedLayerId === layer.id;
return (
<button
key={layer.id}
type="button"
onClick={() => setSelectedLayerId(layer.id)}
className="absolute overflow-hidden rounded-lg transition-all"
style={{
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
border: `2px solid ${getLayerOutlineColor(layer)}`,
boxShadow: isSelected
? `0 0 0 3px rgba(255,255,255,0.16), 0 0 0 6px ${getLayerOutlineColor(layer)}55`
: `0 0 0 1px ${getLayerOutlineColor(layer)}66 inset`,
background: assignedUrl ? "rgba(255,255,255,0.02)" : "rgba(249,115,22,0.12)",
}}
>
{assignedUrl ? (
<img
src={assignedUrl}
alt={layer.layerName}
className="h-full w-full"
style={{
objectFit:
layer.replaceMode === "contain"
? "contain"
: layer.replaceMode === "stretch"
? "fill"
: "cover",
WebkitMaskImage: layer.maskUrl ? `url(${layer.maskUrl})` : undefined,
maskImage: layer.maskUrl ? `url(${layer.maskUrl})` : undefined,
WebkitMaskSize: layer.maskUrl ? "100% 100%" : undefined,
maskSize: layer.maskUrl ? "100% 100%" : undefined,
WebkitMaskRepeat: layer.maskUrl ? "no-repeat" : undefined,
maskRepeat: layer.maskUrl ? "no-repeat" : undefined,
}}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-orange-500/10 text-[13px] font-medium text-orange-100">
点击替换图片
</div>
)}
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
{layer.layerName}
</div>
</button>
);
})}
{editableTextLayers.map((layer) => {
const isSelected = selectedLayerId === layer.id;
const value = effectiveTextAssignments[layer.id] || "";
const sharedStyle = {
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
fontFamily: layer.fontFamily || "sans-serif",
fontSize: layer.fontSize,
lineHeight: layer.lineHeight,
letterSpacing: `${layer.letterSpacing}px`,
color: layer.textColor,
fontWeight: layer.fontWeight,
textAlign: layer.textAlign,
paddingLeft: layer.paddingX,
paddingRight: layer.paddingX,
paddingTop: layer.paddingY,
paddingBottom: layer.paddingY,
} as const;
return (
<div
key={layer.id}
className="absolute overflow-hidden rounded-lg"
style={{
...sharedStyle,
border: `2px solid ${getLayerOutlineColor(layer)}`,
boxShadow: isSelected
? `0 0 0 3px rgba(255,255,255,0.16), 0 0 0 6px ${getLayerOutlineColor(layer)}55`
: `0 0 0 1px ${getLayerOutlineColor(layer)}66 inset`,
background: isSelected ? "rgba(56,189,248,0.09)" : "rgba(56,189,248,0.03)",
}}
onClick={() => setSelectedLayerId(layer.id)}
>
{isSelected ? (
<textarea
value={value}
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[layer.id]: event.target.value,
}))
}
className="h-full w-full resize-none border-none bg-transparent outline-none"
style={sharedStyle}
/>
) : (
<div
className="pointer-events-none h-full w-full whitespace-pre-wrap break-words"
style={sharedStyle}
>
{value || layer.placeholder || layer.layerName}
</div>
)}
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
{layer.layerName}
</div>
</div>
);
})}
</div>
</div>
<Button onClick={handleDownload} className="w-full">
<Download className="mr-2 h-4 w-4" />
下载结果图
</Button>
</div>
) : (
<div className="flex h-full min-h-0 flex-col items-center justify-center rounded-2xl border border-dashed border-white/15 bg-white/[0.02] px-6 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
{editableTextLayers.length > 0 ? (
<Type className="h-8 w-8 text-muted-foreground" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground" />
)}
</div>
<div className="mt-4 text-sm font-medium">还未导出成品图</div>
<div className="mt-2 text-xs leading-6 text-muted-foreground">
) : (
<div className="flex h-full w-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
{normalizedBrandName
? "确认替换图片和文字后,点击“应用模板并导出”,这里会显示最终图片。"
? "请选择一个模板后开始直接在模板中编辑。"
: "请先返回工作流区域选择品牌,再打开 PSD 模板。"}
</div>
)}
</div>
</div>
<div className="min-h-0 bg-[#09090b] p-5">
<div className="mb-4 text-sm font-medium">当前选中图层</div>
<Input
ref={uploadInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => {
if (!selectedImageLayer) return;
void handleSlotUpload(selectedImageLayer.id, event);
}}
/>
<div className="flex h-full min-h-0 flex-col gap-5">
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-4">
{selectedImageLayer ? (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="default">可替换图片</Badge>
<div className="text-sm font-medium">{selectedImageLayer.layerName}</div>
<Badge variant="outline">
{selectedImageLayer.width} x {selectedImageLayer.height}
</Badge>
</div>
<div className="text-xs text-muted-foreground">{selectedImageLayer.layerPath}</div>
<div className="overflow-hidden rounded-xl border border-white/10 bg-white/5">
<div className="aspect-[4/3]">
{effectiveImageAssignments[selectedImageLayer.id] ? (
<img
src={effectiveImageAssignments[selectedImageLayer.id]}
alt={selectedImageLayer.layerName}
className="h-full w-full object-contain"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
暂未替换图片
</div>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{sourceImageUrl ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setImageAssignments((current) => ({
...current,
[selectedImageLayer.id]: sourceImageUrl,
}))
}
>
使用当前图片
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingSlotId === selectedImageLayer.id}
onClick={() => openImageUpload(selectedImageLayer.id)}
>
{uploadingSlotId === selectedImageLayer.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传替换图片
</Button>
{effectiveImageAssignments[selectedImageLayer.id] ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setImageAssignments((current) => {
const nextAssignments = { ...current };
delete nextAssignments[selectedImageLayer.id];
return nextAssignments;
})
}
>
清空
</Button>
) : null}
</div>
</div>
) : selectedTextLayer ? (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="default">可编辑文字</Badge>
<div className="text-sm font-medium">{selectedTextLayer.layerName}</div>
<Badge variant="outline">
{selectedTextLayer.width} x {selectedTextLayer.height}
</Badge>
</div>
<div className="text-xs text-muted-foreground">{selectedTextLayer.layerPath}</div>
<Textarea
rows={5}
value={effectiveTextAssignments[selectedTextLayer.id] || ""}
placeholder={selectedTextLayer.placeholder || "请输入文字"}
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[selectedTextLayer.id]: event.target.value,
}))
}
/>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>字体:{selectedTextLayer.fontLabel || selectedTextLayer.fontFamily || "系统字体"}</span>
<span>字号:{selectedTextLayer.fontSize}px</span>
<span>行高:{selectedTextLayer.lineHeight}</span>
<span>字距:{selectedTextLayer.letterSpacing}px</span>
</div>
</div>
) : (
<div className="flex min-h-[200px] flex-col items-center justify-center text-center">
<MousePointerClick className="h-8 w-8 text-white/35" />
<div className="mt-3 text-sm font-medium">点模板中的图层开始编辑</div>
<div className="mt-2 text-xs leading-6 text-muted-foreground">
图片区域可直接替换图片,文字区域可直接改现有文字。
</div>
</div>
)}
</div>
)}
<div className="min-h-0 flex-1 rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<div className="mb-3 text-sm font-medium">导出结果</div>
{renderedResultUrl ? (
<div className="flex h-full min-h-0 flex-col justify-between gap-4">
<div className="mx-auto w-full max-w-[280px] overflow-hidden rounded-2xl border border-white/10 bg-white/5">
<div
style={{
aspectRatio: selectedTemplate
? `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`
: "1 / 1",
}}
>
<img
src={renderedResultUrl}
alt="PSD 模板导出结果"
className="h-full w-full object-contain"
/>
</div>
</div>
<Button onClick={handleDownload} className="w-full">
<Download className="mr-2 h-4 w-4" />
下载结果图
</Button>
</div>
) : (
<div className="flex h-full min-h-[240px] flex-col items-center justify-center rounded-2xl border border-dashed border-white/15 bg-white/[0.02] px-6 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
{selectedTextLayer ? (
<Type className="h-8 w-8 text-muted-foreground" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground" />
)}
</div>
<div className="mt-4 text-sm font-medium">还未导出成品图</div>
<div className="mt-2 text-xs leading-6 text-muted-foreground">
在模板画布里完成替换后,点击“应用模板并导出”,这里会显示最终图片。
</div>
</div>
)}
</div>
</div>
</div>
</div>
</DialogContent>

View File

@ -46,6 +46,8 @@ export interface AgentPsdTemplateTextLayerSlot
fontId?: string;
fontFamily?: string;
fontLabel?: string;
fontFileUrl?: string;
fontContentType?: string;
fontSize: number;
lineHeight: number;
letterSpacing: number;
@ -264,6 +266,8 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
fontId: normalizeText(record.fontId) || undefined,
fontFamily: normalizeText(record.fontFamily) || undefined,
fontLabel: normalizeText(record.fontLabel) || undefined,
fontFileUrl: normalizeOptionalUrl(record.fontFileUrl),
fontContentType: normalizeText(record.fontContentType) || undefined,
fontSize: normalizePositiveNumber(record.fontSize, estimatedFontSize),
lineHeight: normalizePositiveNumber(record.lineHeight, 1.2),
letterSpacing: Number.isFinite(Number(record.letterSpacing))