feat: add in-canvas PSD template editing
This commit is contained in:
parent
449a631db4
commit
2b2b2bd228
@ -147,6 +147,8 @@ function buildInitialTemplateLayers(params: {
|
|||||||
fontId: matchedFont?.font.id,
|
fontId: matchedFont?.font.id,
|
||||||
fontFamily: matchedFont?.font.family,
|
fontFamily: matchedFont?.font.family,
|
||||||
fontLabel: matchedFont?.font.name,
|
fontLabel: matchedFont?.font.name,
|
||||||
|
fontFileUrl: matchedFont?.font.fileUrl,
|
||||||
|
fontContentType: matchedFont?.font.contentType,
|
||||||
fontSize: Math.max(
|
fontSize: Math.max(
|
||||||
10,
|
10,
|
||||||
Math.round(
|
Math.round(
|
||||||
|
|||||||
@ -103,6 +103,8 @@ async function repairTemplateLayerPreviewsIfNeeded(template: NonNullable<Awaited
|
|||||||
fontId: matchedFont.font.id,
|
fontId: matchedFont.font.id,
|
||||||
fontFamily: matchedFont.font.family,
|
fontFamily: matchedFont.font.family,
|
||||||
fontLabel: matchedFont.font.name,
|
fontLabel: matchedFont.font.name,
|
||||||
|
fontFileUrl: matchedFont.font.fileUrl,
|
||||||
|
fontContentType: matchedFont.font.contentType,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const hasFontRepair = nextLayers.some(
|
const hasFontRepair = nextLayers.some(
|
||||||
@ -259,6 +261,8 @@ export async function PUT(
|
|||||||
layer.fontId = matchedFont.font.id;
|
layer.fontId = matchedFont.font.id;
|
||||||
layer.fontFamily = matchedFont.font.family;
|
layer.fontFamily = matchedFont.font.family;
|
||||||
layer.fontLabel = matchedFont.font.name;
|
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.fontFamily = matchedFont.family;
|
||||||
layer.fontLabel = matchedFont.name;
|
layer.fontLabel = matchedFont.name;
|
||||||
|
layer.fontFileUrl = matchedFont.fileUrl;
|
||||||
|
layer.fontContentType = matchedFont.contentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!String(layer.fontFamily || "").trim()) {
|
if (!String(layer.fontFamily || "").trim()) {
|
||||||
|
|||||||
@ -126,6 +126,8 @@ async function repairTemplateLayerPreviewsIfNeeded(params: {
|
|||||||
fontId: matchedFont.font.id,
|
fontId: matchedFont.font.id,
|
||||||
fontFamily: matchedFont.font.family,
|
fontFamily: matchedFont.font.family,
|
||||||
fontLabel: matchedFont.font.name,
|
fontLabel: matchedFont.font.name,
|
||||||
|
fontFileUrl: matchedFont.font.fileUrl,
|
||||||
|
fontContentType: matchedFont.font.contentType,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const hasFontRepair = nextLayers.some(
|
const hasFontRepair = nextLayers.some(
|
||||||
|
|||||||
@ -2,9 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import {
|
import {
|
||||||
getEditableAgentPsdTemplateLayers,
|
getEditableAgentPsdTemplateLayers,
|
||||||
|
isAgentPsdTextLayerSlot,
|
||||||
isAgentPsdTemplateReady,
|
isAgentPsdTemplateReady,
|
||||||
type AgentPsdTemplateListResponse,
|
type AgentPsdTemplateListResponse,
|
||||||
} from "@/lib/agents/psd-template-config";
|
} from "@/lib/agents/psd-template-config";
|
||||||
|
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
|
||||||
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
|
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@ -27,6 +29,8 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const requestedBrand = String(request.nextUrl.searchParams.get("brand") || "").trim();
|
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())
|
const templates = (await readAgentPsdTemplates())
|
||||||
.filter((template) => template.status === "active")
|
.filter((template) => template.status === "active")
|
||||||
.filter(isAgentPsdTemplateReady)
|
.filter(isAgentPsdTemplateReady)
|
||||||
@ -35,7 +39,22 @@ export async function GET(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
.map((template) => ({
|
.map((template) => ({
|
||||||
...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);
|
.filter((template) => template.layers.length > 0);
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type ChangeEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
MousePointerClick,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Type,
|
Type,
|
||||||
Upload,
|
Upload,
|
||||||
@ -30,8 +31,11 @@ import {
|
|||||||
isAgentPsdImageLayerSlot,
|
isAgentPsdImageLayerSlot,
|
||||||
isAgentPsdTextLayerSlot,
|
isAgentPsdTextLayerSlot,
|
||||||
type AgentPsdTemplate,
|
type AgentPsdTemplate,
|
||||||
|
type AgentPsdTemplateImageLayerSlot,
|
||||||
|
type AgentPsdTemplateLayerSlot,
|
||||||
type AgentPsdTemplateListResponse,
|
type AgentPsdTemplateListResponse,
|
||||||
type AgentPsdTemplateRenderResponse,
|
type AgentPsdTemplateRenderResponse,
|
||||||
|
type AgentPsdTemplateTextLayerSlot,
|
||||||
} from "@/lib/agents/psd-template-config";
|
} from "@/lib/agents/psd-template-config";
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json());
|
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 };
|
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({
|
export function AgentPsdTemplateEditorDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@ -77,11 +126,16 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
const templates = useMemo(() => (data?.success ? data.templates : []), [data]);
|
const templates = useMemo(() => (data?.success ? data.templates : []), [data]);
|
||||||
|
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||||||
|
const [selectedLayerId, setSelectedLayerId] = useState<string>("");
|
||||||
const [imageAssignments, setImageAssignments] = useState<Record<string, string>>({});
|
const [imageAssignments, setImageAssignments] = useState<Record<string, string>>({});
|
||||||
const [textAssignments, setTextAssignments] = useState<Record<string, string>>({});
|
const [textAssignments, setTextAssignments] = useState<Record<string, string>>({});
|
||||||
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
|
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
|
||||||
const [isRendering, setIsRendering] = useState(false);
|
const [isRendering, setIsRendering] = useState(false);
|
||||||
const [renderedResultUrl, setRenderedResultUrl] = useState<string>("");
|
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(
|
const selectedTemplate = useMemo(
|
||||||
() => templates.find((template) => template.id === selectedTemplateId) || null,
|
() => templates.find((template) => template.id === selectedTemplateId) || null,
|
||||||
@ -107,6 +161,50 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
return Math.max(0, selectedTemplate.layerCount - selectedTemplate.layers.length);
|
return Math.max(0, selectedTemplate.layerCount - selectedTemplate.layers.length);
|
||||||
}, [selectedTemplate]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
@ -121,8 +219,38 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
const initialState = createInitialState(selectedTemplate, sourceImageUrl || "");
|
const initialState = createInitialState(selectedTemplate, sourceImageUrl || "");
|
||||||
setImageAssignments(initialState.imageAssignments);
|
setImageAssignments(initialState.imageAssignments);
|
||||||
setTextAssignments(initialState.textAssignments);
|
setTextAssignments(initialState.textAssignments);
|
||||||
|
setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate));
|
||||||
}, [open, selectedTemplate, sourceImageUrl]);
|
}, [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>) {
|
async function handleSlotUpload(slotId: string, event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
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() {
|
async function handleRender() {
|
||||||
if (!selectedTemplate) {
|
if (!selectedTemplate) {
|
||||||
toast.error("请先选择 PSD 模板");
|
toast.error("请先选择 PSD 模板");
|
||||||
@ -167,8 +303,8 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
templateId: selectedTemplate.id,
|
templateId: selectedTemplate.id,
|
||||||
sourceImageUrl: sourceImageUrl || "",
|
sourceImageUrl: sourceImageUrl || "",
|
||||||
imageAssignments,
|
imageAssignments: effectiveImageAssignments,
|
||||||
textAssignments,
|
textAssignments: effectiveTextAssignments,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -195,7 +331,7 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
overlayClassName="bg-black/85 backdrop-blur-sm"
|
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">
|
<DialogHeader className="border-b border-white/10 bg-[#09090b] px-6 py-5">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
@ -203,12 +339,14 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
套用 PSD 模板
|
套用 PSD 模板
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
这里是直接把当前图片和输入文字替换进 PSD 模板,不会再次调用 AI 生图模型。
|
这里是直接在模板里替换图片和修改文字,不会再次调用 AI 生图模型。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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]">
|
{fontFaceCss ? <style>{fontFaceCss}</style> : null}
|
||||||
<div className="min-h-0 border-b border-white/10 bg-[#09090b] p-5 lg:border-b-0 lg:border-r">
|
|
||||||
|
<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">
|
<ScrollArea className="h-full pr-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -229,7 +367,7 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
|
|
||||||
{selectedTemplate ? (
|
{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
|
<div
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`,
|
aspectRatio: `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`,
|
||||||
@ -255,15 +393,9 @@ export function AgentPsdTemplateEditorDialog({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<Button
|
<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="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="mb-4 flex flex-wrap items-center gap-2">
|
||||||
<div className="text-sm font-medium">槽位内容</div>
|
<div className="text-sm font-medium">模板画布编辑区</div>
|
||||||
{selectedTemplate ? (
|
{selectedTemplate ? (
|
||||||
<>
|
<>
|
||||||
<Badge variant="outline">{editableImageLayers.length} 个可替换图片</Badge>
|
<Badge variant="outline">{editableImageLayers.length} 个图片替换区域</Badge>
|
||||||
<Badge variant="outline">{editableTextLayers.length} 个可编辑文字</Badge>
|
<Badge variant="outline">{editableTextLayers.length} 个文字编辑区域</Badge>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4 text-xs text-muted-foreground">
|
<div className="mb-4 text-xs text-muted-foreground">
|
||||||
图片槽位直接替换图片,文字槽位直接修改文案,不会再次调用 AI 生图。
|
点模板上的橙色框替换图片,点蓝色框直接修改文字。
|
||||||
</div>
|
</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 ? (
|
{selectedTemplate ? (
|
||||||
<div className="space-y-6">
|
<div
|
||||||
<section className="space-y-3">
|
className="relative my-4"
|
||||||
<div className="flex items-center justify-between gap-3">
|
style={{
|
||||||
<div className="text-sm font-medium">可替换图片</div>
|
width: selectedTemplate.width * stageScale,
|
||||||
<Badge variant="secondary">{editableImageLayers.length}</Badge>
|
height: selectedTemplate.height * stageScale,
|
||||||
</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
|
<div
|
||||||
|
className="absolute left-0 top-0 overflow-hidden rounded-[20px] shadow-2xl"
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: selectedTemplate
|
width: selectedTemplate.width,
|
||||||
? `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`
|
height: selectedTemplate.height,
|
||||||
: "1 / 1",
|
transform: `scale(${stageScale})`,
|
||||||
|
transformOrigin: "top left",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={renderedResultUrl}
|
src={selectedTemplate.renderBaseUrl || selectedTemplate.previewUrl}
|
||||||
alt="PSD 模板导出结果"
|
alt={`${selectedTemplate.name} 编辑底图`}
|
||||||
className="h-full w-full object-contain"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<Button onClick={handleDownload} className="w-full">
|
<div className="flex h-full w-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||||
<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">
|
|
||||||
{normalizedBrandName
|
{normalizedBrandName
|
||||||
? "确认替换图片和文字后,点击“应用模板并导出”,这里会显示最终图片。"
|
? "请选择一个模板后开始直接在模板中编辑。"
|
||||||
: "请先返回工作流区域选择品牌,再打开 PSD 模板。"}
|
: "请先返回工作流区域选择品牌,再打开 PSD 模板。"}
|
||||||
</div>
|
</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>
|
||||||
)}
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -46,6 +46,8 @@ export interface AgentPsdTemplateTextLayerSlot
|
|||||||
fontId?: string;
|
fontId?: string;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
fontLabel?: string;
|
fontLabel?: string;
|
||||||
|
fontFileUrl?: string;
|
||||||
|
fontContentType?: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
lineHeight: number;
|
lineHeight: number;
|
||||||
letterSpacing: number;
|
letterSpacing: number;
|
||||||
@ -264,6 +266,8 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
|
|||||||
fontId: normalizeText(record.fontId) || undefined,
|
fontId: normalizeText(record.fontId) || undefined,
|
||||||
fontFamily: normalizeText(record.fontFamily) || undefined,
|
fontFamily: normalizeText(record.fontFamily) || undefined,
|
||||||
fontLabel: normalizeText(record.fontLabel) || undefined,
|
fontLabel: normalizeText(record.fontLabel) || undefined,
|
||||||
|
fontFileUrl: normalizeOptionalUrl(record.fontFileUrl),
|
||||||
|
fontContentType: normalizeText(record.fontContentType) || undefined,
|
||||||
fontSize: normalizePositiveNumber(record.fontSize, estimatedFontSize),
|
fontSize: normalizePositiveNumber(record.fontSize, estimatedFontSize),
|
||||||
lineHeight: normalizePositiveNumber(record.lineHeight, 1.2),
|
lineHeight: normalizePositiveNumber(record.lineHeight, 1.2),
|
||||||
letterSpacing: Number.isFinite(Number(record.letterSpacing))
|
letterSpacing: Number.isFinite(Number(record.letterSpacing))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user