896 lines
36 KiB
TypeScript
896 lines
36 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent } from "react";
|
||
import useSWR from "swr";
|
||
import { toast } from "sonner";
|
||
import {
|
||
Download,
|
||
Image as ImageIcon,
|
||
Loader2,
|
||
MousePointerClick,
|
||
Sparkles,
|
||
Type,
|
||
Upload,
|
||
} from "lucide-react";
|
||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
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());
|
||
|
||
function createInitialState(template: AgentPsdTemplate | null, sourceImageUrl: string) {
|
||
const imageAssignments: Record<string, string> = {};
|
||
const textAssignments: Record<string, string> = {};
|
||
|
||
if (!template) {
|
||
return { imageAssignments, textAssignments };
|
||
}
|
||
|
||
for (const layer of template.layers) {
|
||
if (isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource && sourceImageUrl) {
|
||
imageAssignments[layer.id] = sourceImageUrl;
|
||
}
|
||
if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) {
|
||
textAssignments[layer.id] = layer.defaultText;
|
||
}
|
||
}
|
||
|
||
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 editableLayers = template.layers.filter((layer) => layer.isEditable);
|
||
if (editableLayers.length === 0) {
|
||
return "";
|
||
}
|
||
|
||
const preferredImage = editableLayers.find(
|
||
(layer) => isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource
|
||
);
|
||
if (preferredImage) {
|
||
return preferredImage.id;
|
||
}
|
||
|
||
return editableLayers[0]?.id || "";
|
||
}
|
||
|
||
function getImageFit(replaceMode: AgentPsdTemplateImageLayerSlot["replaceMode"]): CSSProperties["objectFit"] {
|
||
if (replaceMode === "contain") return "contain";
|
||
if (replaceMode === "stretch") return "fill";
|
||
return "cover";
|
||
}
|
||
|
||
function getLayerFrameStyle(
|
||
layer: AgentPsdTemplateLayerSlot,
|
||
isSelected: boolean
|
||
): CSSProperties {
|
||
return {
|
||
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`,
|
||
};
|
||
}
|
||
|
||
function getTextLayerContentStyle(layer: AgentPsdTemplateTextLayerSlot): CSSProperties {
|
||
return {
|
||
width: "100%",
|
||
height: "100%",
|
||
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,
|
||
};
|
||
}
|
||
|
||
function getAssignedImageStyle(layer: AgentPsdTemplateImageLayerSlot): CSSProperties {
|
||
const hasMask = Boolean(layer.maskUrl);
|
||
return {
|
||
width: "100%",
|
||
height: "100%",
|
||
objectFit: getImageFit(layer.replaceMode),
|
||
WebkitMaskImage: hasMask ? `url(${layer.maskUrl})` : undefined,
|
||
maskImage: hasMask ? `url(${layer.maskUrl})` : undefined,
|
||
WebkitMaskSize: hasMask ? "100% 100%" : undefined,
|
||
maskSize: hasMask ? "100% 100%" : undefined,
|
||
WebkitMaskRepeat: hasMask ? "no-repeat" : undefined,
|
||
maskRepeat: hasMask ? "no-repeat" : undefined,
|
||
WebkitMaskPosition: hasMask ? "center" : undefined,
|
||
maskPosition: hasMask ? "center" : undefined,
|
||
};
|
||
}
|
||
|
||
export function AgentPsdTemplateEditorDialog({
|
||
open,
|
||
onOpenChange,
|
||
sourceImageUrl,
|
||
brandName,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
sourceImageUrl: string | null;
|
||
brandName?: string | null;
|
||
}) {
|
||
const normalizedBrandName = String(brandName || "").trim();
|
||
const { data, isLoading } = useSWR<AgentPsdTemplateListResponse>(
|
||
open && normalizedBrandName
|
||
? `/api/psd-templates?brand=${encodeURIComponent(normalizedBrandName)}`
|
||
: null,
|
||
fetcher
|
||
);
|
||
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,
|
||
[selectedTemplateId, templates]
|
||
);
|
||
|
||
const editableImageLayers = useMemo(
|
||
() =>
|
||
(selectedTemplate?.layers || []).filter(
|
||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||
layer.isEditable && isAgentPsdImageLayerSlot(layer)
|
||
),
|
||
[selectedTemplate]
|
||
);
|
||
const editableTextLayers = useMemo(
|
||
() =>
|
||
(selectedTemplate?.layers || []).filter(
|
||
(layer): layer is AgentPsdTemplateTextLayerSlot =>
|
||
layer.isEditable && isAgentPsdTextLayerSlot(layer)
|
||
),
|
||
[selectedTemplate]
|
||
);
|
||
const fixedLayerCount = useMemo(() => {
|
||
if (!selectedTemplate) return 0;
|
||
return selectedTemplate.layers.filter((layer) => !layer.isEditable).length;
|
||
}, [selectedTemplate]);
|
||
const orderedPreviewLayers = useMemo(
|
||
() => (selectedTemplate ? [...selectedTemplate.layers].reverse() : []),
|
||
[selectedTemplate]
|
||
);
|
||
const orderedEditableLayers = useMemo(
|
||
() => orderedPreviewLayers.filter((layer) => layer.isEditable),
|
||
[orderedPreviewLayers]
|
||
);
|
||
const shouldShowCompositeFallback = useMemo(
|
||
() => orderedPreviewLayers.some((layer) => !String(layer.sourceUrl || "").trim()),
|
||
[orderedPreviewLayers]
|
||
);
|
||
|
||
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;
|
||
|
||
if (templates.length > 0 && !templates.some((template) => template.id === selectedTemplateId)) {
|
||
setSelectedTemplateId(templates[0].id);
|
||
}
|
||
}, [open, selectedTemplateId, templates]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setRenderedResultUrl("");
|
||
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;
|
||
|
||
try {
|
||
setUploadingSlotId(slotId);
|
||
const formData = new FormData();
|
||
formData.set("file", file);
|
||
const response = await fetchUploadWithRetry(formData);
|
||
const payload = await response.json();
|
||
|
||
if (!response.ok || !payload.url) {
|
||
throw new Error(payload.error || "图片上传失败");
|
||
}
|
||
|
||
setImageAssignments((current) => ({
|
||
...current,
|
||
[slotId]: payload.url,
|
||
}));
|
||
toast.success("替换图片已上传");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "素材上传失败");
|
||
} finally {
|
||
setUploadingSlotId(null);
|
||
event.target.value = "";
|
||
}
|
||
}
|
||
|
||
function openImageUpload(slotId: string) {
|
||
setSelectedLayerId(slotId);
|
||
if (uploadInputRef.current) {
|
||
uploadInputRef.current.value = "";
|
||
uploadInputRef.current.click();
|
||
}
|
||
}
|
||
|
||
async function handleRender() {
|
||
if (!selectedTemplate) {
|
||
toast.error("请先选择 PSD 模板");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setIsRendering(true);
|
||
const response = await fetch("/api/psd-templates/render", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
templateId: selectedTemplate.id,
|
||
sourceImageUrl: sourceImageUrl || "",
|
||
imageAssignments: effectiveImageAssignments,
|
||
textAssignments: effectiveTextAssignments,
|
||
}),
|
||
});
|
||
|
||
const payload = (await response.json()) as AgentPsdTemplateRenderResponse;
|
||
if (!response.ok || !payload.success || !payload.result?.url) {
|
||
throw new Error(payload.error || "PSD 模板渲染失败");
|
||
}
|
||
|
||
setRenderedResultUrl(payload.result.url);
|
||
toast.success("模板替换结果已生成");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "PSD 模板渲染失败");
|
||
} finally {
|
||
setIsRendering(false);
|
||
}
|
||
}
|
||
|
||
function handleDownload() {
|
||
if (!renderedResultUrl) return;
|
||
window.open(renderedResultUrl, "_blank", "noopener,noreferrer");
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent
|
||
overlayClassName="bg-black/85 backdrop-blur-sm"
|
||
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">
|
||
<Sparkles className="h-5 w-5 text-orange-500" />
|
||
套用 PSD 模板
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
这里是直接在模板里替换图片和修改文字,不会再次调用 AI 生图模型。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{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">
|
||
<Label>选择模板</Label>
|
||
<Select value={selectedTemplateId} onValueChange={setSelectedTemplateId}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder={isLoading ? "加载模板中..." : "请选择模板"} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{templates.map((template) => (
|
||
<SelectItem key={template.id} value={template.id}>
|
||
{template.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{selectedTemplate ? (
|
||
<>
|
||
<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)}`,
|
||
}}
|
||
>
|
||
<img
|
||
src={selectedTemplate.previewUrl}
|
||
alt={selectedTemplate.name}
|
||
className="h-full w-full object-contain"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge variant="secondary">{selectedTemplate.brandName}</Badge>
|
||
<Badge variant="secondary">
|
||
{selectedTemplate.width} x {selectedTemplate.height}
|
||
</Badge>
|
||
<Badge variant="outline">{editableImageLayers.length} 个可替换图片</Badge>
|
||
<Badge variant="outline">{editableTextLayers.length} 个可编辑文字</Badge>
|
||
{fixedLayerCount > 0 ? (
|
||
<Badge variant="outline">{fixedLayerCount} 个固定图层</Badge>
|
||
) : null}
|
||
</div>
|
||
|
||
<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
|
||
onClick={handleRender}
|
||
disabled={isRendering}
|
||
className="w-full bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
|
||
>
|
||
{isRendering ? (
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Sparkles className="mr-2 h-4 w-4" />
|
||
)}
|
||
应用模板并导出
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<div className="rounded-2xl border border-dashed border-white/15 p-5 text-sm text-muted-foreground">
|
||
{isLoading
|
||
? "正在加载可用模板..."
|
||
: normalizedBrandName
|
||
? `当前品牌“${normalizedBrandName}”下还没有可用 PSD 模板,请先去后台配置。`
|
||
: "当前还没有启用的 PSD 模板,请先去后台配置。"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
|
||
<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>
|
||
{selectedTemplate ? (
|
||
<>
|
||
<Badge variant="outline">{editableImageLayers.length} 个图片替换区域</Badge>
|
||
<Badge variant="outline">{editableTextLayers.length} 个文字编辑区域</Badge>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
<div className="mb-4 text-xs text-muted-foreground">
|
||
点模板上的橙色框替换图片,点蓝色框直接修改文字。
|
||
</div>
|
||
|
||
<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="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={{
|
||
width: selectedTemplate.width,
|
||
height: selectedTemplate.height,
|
||
transform: `scale(${stageScale})`,
|
||
transformOrigin: "top left",
|
||
}}
|
||
>
|
||
{shouldShowCompositeFallback ? (
|
||
<img
|
||
src={selectedTemplate.renderBaseUrl || selectedTemplate.previewUrl}
|
||
alt={`${selectedTemplate.name} 编辑底图`}
|
||
className="absolute inset-0 h-full w-full object-cover"
|
||
/>
|
||
) : null}
|
||
|
||
<div className="pointer-events-none absolute inset-0">
|
||
{orderedPreviewLayers.map((layer) => {
|
||
const layerBoxStyle = {
|
||
left: layer.x,
|
||
top: layer.y,
|
||
width: layer.width,
|
||
height: layer.height,
|
||
} satisfies CSSProperties;
|
||
|
||
if (isAgentPsdImageLayerSlot(layer)) {
|
||
const assignedUrl = effectiveImageAssignments[layer.id];
|
||
const layerImageUrl = assignedUrl || layer.sourceUrl;
|
||
if (!layerImageUrl) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
key={layer.id}
|
||
className="absolute overflow-hidden"
|
||
style={layerBoxStyle}
|
||
>
|
||
<img
|
||
src={layerImageUrl}
|
||
alt={layer.layerName}
|
||
className="h-full w-full"
|
||
style={
|
||
assignedUrl
|
||
? getAssignedImageStyle(layer)
|
||
: {
|
||
width: "100%",
|
||
height: "100%",
|
||
objectFit: "fill",
|
||
}
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isSelected = selectedLayerId === layer.id;
|
||
if (isSelected) {
|
||
return null;
|
||
}
|
||
|
||
const value = effectiveTextAssignments[layer.id] || "";
|
||
const defaultText = String(layer.defaultText || "");
|
||
const shouldUseEditedPreview =
|
||
value.trim().length > 0 && value.trim() !== defaultText.trim();
|
||
|
||
if (!shouldUseEditedPreview && layer.sourceUrl) {
|
||
return (
|
||
<div
|
||
key={layer.id}
|
||
className="absolute overflow-hidden"
|
||
style={layerBoxStyle}
|
||
>
|
||
<img
|
||
src={layer.sourceUrl}
|
||
alt={layer.layerName}
|
||
className="h-full w-full object-fill"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!value && !layer.placeholder) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
key={layer.id}
|
||
className="absolute overflow-hidden"
|
||
style={layerBoxStyle}
|
||
>
|
||
<div
|
||
className="h-full w-full whitespace-pre-wrap break-words"
|
||
style={getTextLayerContentStyle(layer)}
|
||
>
|
||
{value || layer.placeholder || layer.layerName}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="absolute inset-0">
|
||
{orderedEditableLayers.map((layer) => {
|
||
const isSelected = selectedLayerId === layer.id;
|
||
const frameStyle = getLayerFrameStyle(layer, isSelected);
|
||
|
||
if (isAgentPsdTextLayerSlot(layer) && isSelected) {
|
||
return (
|
||
<div
|
||
key={layer.id}
|
||
className="absolute overflow-hidden rounded-lg"
|
||
style={{
|
||
...frameStyle,
|
||
zIndex: 40,
|
||
background: "rgba(56,189,248,0.08)",
|
||
}}
|
||
>
|
||
<textarea
|
||
value={effectiveTextAssignments[layer.id] || ""}
|
||
onChange={(event) =>
|
||
setTextAssignments((current) => ({
|
||
...current,
|
||
[layer.id]: event.target.value,
|
||
}))
|
||
}
|
||
className="h-full w-full resize-none border-none bg-transparent outline-none"
|
||
style={getTextLayerContentStyle(layer)}
|
||
/>
|
||
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
|
||
{layer.layerName}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const hasAssignedImage =
|
||
isAgentPsdImageLayerSlot(layer) &&
|
||
Boolean(String(effectiveImageAssignments[layer.id] || "").trim());
|
||
|
||
return (
|
||
<button
|
||
key={layer.id}
|
||
type="button"
|
||
onClick={() => setSelectedLayerId(layer.id)}
|
||
className="absolute overflow-hidden rounded-lg transition-all"
|
||
style={{
|
||
...frameStyle,
|
||
zIndex: isSelected ? 35 : 30,
|
||
background: isSelected
|
||
? isAgentPsdImageLayerSlot(layer)
|
||
? "rgba(249,115,22,0.08)"
|
||
: "rgba(56,189,248,0.05)"
|
||
: "transparent",
|
||
}}
|
||
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
|
||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||
>
|
||
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
|
||
{layer.layerName}
|
||
</div>
|
||
|
||
{isAgentPsdImageLayerSlot(layer) ? (
|
||
!hasAssignedImage ? (
|
||
<div className="pointer-events-none absolute bottom-2 right-2 rounded-full bg-orange-500/85 px-2 py-1 text-[11px] text-white">
|
||
点击替换图片
|
||
</div>
|
||
) : null
|
||
) : (
|
||
<div className="pointer-events-none absolute bottom-2 right-2 rounded-full bg-sky-500/85 px-2 py-1 text-[11px] text-white">
|
||
点击编辑文字
|
||
</div>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<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>
|
||
</Dialog>
|
||
);
|
||
}
|