ai-chatbot/components/agents/AgentPsdTemplateEditorDialog.tsx
Your Name 01748fe501
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: harden psd text workflow compatibility
2026-04-02 00:08:53 +08:00

1770 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type PointerEvent as ReactPointerEvent } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import {
Download,
Image as ImageIcon,
Loader2,
Move,
MousePointerClick,
RefreshCcw,
Sparkles,
Type,
Upload,
ZoomIn,
ZoomOut,
} 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 { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import {
getDefaultAgentPsdImagePlacement,
normalizeAgentPsdImagePlacement,
resolveAgentPsdImagePlacementFrame,
type AgentPsdImagePlacement,
type AgentPsdImageSize,
} from "@/lib/agents/psd-image-placement";
import {
defaultAgentPsdTextColorConfig,
normalizeAgentPsdTextColorConfig,
} from "@/lib/agents/psd-text-color-config";
import {
isAgentPsdImageLayerSlot,
isAgentPsdTextLayerSlot,
parseAgentPsdTemplates,
type AgentPsdTemplate,
type AgentPsdTemplateImageLayerSlot,
type AgentPsdTemplateLayerSlot,
type AgentPsdTemplateListResponse,
type AgentPsdTemplateRenderResponse,
type AgentPsdTemplateTextLayerSlot,
} from "@/lib/agents/psd-template-config";
import { normalizeHexColor, resolveTextLayout } from "@/lib/agents/psd-text-layout";
const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
try {
const response = await fetch(url, { cache: "no-store" });
const payload = (await response.json().catch(() => null)) as
| Partial<AgentPsdTemplateListResponse>
| null;
if (!response.ok || !payload || payload.success !== true || !Array.isArray(payload.templates)) {
return {
success: false,
templates: [],
textColorConfig: defaultAgentPsdTextColorConfig,
error: typeof payload?.error === "string" ? payload.error : "PSD 模板加载失败",
};
}
return {
success: true,
templates: parseAgentPsdTemplates(payload.templates),
textColorConfig: normalizeAgentPsdTextColorConfig(payload.textColorConfig),
};
} catch {
return {
success: false,
templates: [],
textColorConfig: defaultAgentPsdTextColorConfig,
error: "PSD 模板加载失败",
};
}
};
const PREVIEW_VIEWPORT_PADDING = 56;
const PREVIEW_STAGE_MAX_SCALE = 0.82;
const PREVIEW_STAGE_TARGET_WIDTH = 760;
const PREVIEW_STAGE_TARGET_HEIGHT = 760;
const MIN_IMAGE_SCALE = 0.4;
const MAX_IMAGE_SCALE = 3;
function clampImageScale(value: number) {
if (!Number.isFinite(value)) {
return 1;
}
return Math.max(MIN_IMAGE_SCALE, Math.min(MAX_IMAGE_SCALE, value));
}
function createInitialState(template: AgentPsdTemplate | null, sourceImageUrl: string) {
const imageAssignments: Record<string, string> = {};
const imagePlacements: Record<string, AgentPsdImagePlacement> = {};
const textAssignments: Record<string, string> = {};
if (!template) {
return { imageAssignments, imagePlacements, textAssignments };
}
for (const layer of template.layers) {
if (isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource && sourceImageUrl) {
imageAssignments[layer.id] = sourceImageUrl;
imagePlacements[layer.id] = getDefaultAgentPsdImagePlacement();
}
if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) {
textAssignments[layer.id] = layer.defaultText;
}
}
return { imageAssignments, imagePlacements, 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;
}
const familyAliases = Array.from(
new Set(
[
layer.fontFamily,
...(layer.detectedFontNames || []),
layer.detectedFontName,
]
.map((family) => String(family || "").trim())
.filter(Boolean)
)
);
for (const familyAlias of familyAliases) {
if (rules.has(familyAlias)) {
continue;
}
rules.set(
familyAlias,
`@font-face { font-family: "${familyAlias}"; 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 buildTextLayerFontFamily(layer: AgentPsdTemplateTextLayerSlot) {
const families = Array.from(
new Set(
[
layer.fontFamily,
...(layer.detectedFontNames || []),
layer.detectedFontName,
"PingFang SC",
"Microsoft YaHei",
"Noto Sans SC",
"Source Han Sans SC",
"sans-serif",
]
.map((family) => String(family || "").trim())
.filter(Boolean)
)
);
return families
.map((family) =>
family === "sans-serif" ? family : `"${family.replace(/"/g, '\\"')}"`
)
.join(", ");
}
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 getResolvedTextPreview(layer: AgentPsdTemplateTextLayerSlot, value: string) {
return value || layer.placeholder || layer.layerName;
}
function getResolvedTextLayerColor(
layer: AgentPsdTemplateTextLayerSlot,
value?: string
) {
return normalizeHexColor(value || layer.textColor, layer.textColor);
}
function getTextLayerRenderFrame(
layer: AgentPsdTemplateTextLayerSlot,
value: string
) {
const resolvedLayout = resolveTextLayout({
text: getResolvedTextPreview(layer, value),
width: layer.width,
height: layer.height,
fontSize: layer.fontSize,
lineHeight: layer.lineHeight,
letterSpacing: layer.letterSpacing,
paddingX: layer.paddingX,
paddingY: layer.paddingY,
maxLines: layer.maxLines,
autoFit: false,
preferNoWrap: true,
});
const width = Math.max(layer.width, resolvedLayout.contentWidth);
const height = Math.max(layer.height, resolvedLayout.contentHeight);
const offsetX =
layer.textAlign === "right"
? layer.width - width
: layer.textAlign === "center"
? (layer.width - width) / 2
: 0;
return {
...resolvedLayout,
width,
height,
offsetX,
offsetY: -resolvedLayout.visualOffsetY,
};
}
function getTextLayerContentStyle(
layer: AgentPsdTemplateTextLayerSlot,
value: string,
color: string
): CSSProperties {
const resolvedLayout = getTextLayerRenderFrame(layer, value);
return {
width: resolvedLayout.width,
height: resolvedLayout.height,
fontFamily: buildTextLayerFontFamily(layer),
fontSize: resolvedLayout.fontSize,
lineHeight: layer.lineHeight,
letterSpacing: `${layer.letterSpacing}px`,
color,
fontWeight: layer.fontWeight,
textAlign: layer.textAlign,
paddingLeft: layer.paddingX,
paddingRight: layer.paddingX,
paddingTop: layer.paddingY,
paddingBottom: layer.paddingY,
whiteSpace: "pre",
wordBreak: "keep-all",
overflowWrap: "normal",
};
}
function getAssignedImageMaskStyle(layer: AgentPsdTemplateImageLayerSlot): CSSProperties {
const hasMask = Boolean(layer.maskUrl);
return {
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,
};
}
function getPlacedAssignedImageStyle(params: {
layer: AgentPsdTemplateImageLayerSlot;
imageSize: AgentPsdImageSize;
placement: AgentPsdImagePlacement;
}): CSSProperties {
const { layer, imageSize, placement } = params;
const frame = resolveAgentPsdImagePlacementFrame({
containerWidth: layer.width,
containerHeight: layer.height,
imageWidth: imageSize.width,
imageHeight: imageSize.height,
replaceMode: layer.replaceMode,
placement,
});
return {
position: "absolute",
left: frame.left,
top: frame.top,
width: frame.width,
height: frame.height,
...getAssignedImageMaskStyle(layer),
};
}
function hasEditedTextValue(layer: AgentPsdTemplateTextLayerSlot, value: string) {
return String(value || "").replace(/\r\n/g, "\n") !== String(layer.defaultText || "").replace(/\r\n/g, "\n");
}
function hasCustomizedTextLayerAppearance(
layer: AgentPsdTemplateTextLayerSlot,
value: string,
color: string
) {
return (
hasEditedTextValue(layer, value) ||
getResolvedTextLayerColor(layer, color) !== getResolvedTextLayerColor(layer)
);
}
function getInlineTextEditorStyle(
layer: AgentPsdTemplateTextLayerSlot,
preserveSourceAppearance: boolean,
value: string,
color: string
): CSSProperties {
const baseStyle = getTextLayerContentStyle(layer, value, color);
return {
...baseStyle,
background: "transparent",
boxSizing: "border-box",
overflow: "visible",
color: preserveSourceAppearance ? "transparent" : color,
caretColor: color,
WebkitTextFillColor: preserveSourceAppearance ? "transparent" : color,
};
}
function getTextLayerNameBadgeClassName() {
return "pointer-events-none absolute left-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-black/78 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg";
}
function getTextLayerHintBadgeClassName() {
return "pointer-events-none absolute right-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-sky-500/90 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg";
}
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 textColorConfig = useMemo(
() => normalizeAgentPsdTextColorConfig(data?.textColorConfig),
[data?.textColorConfig]
);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [selectedLayerId, setSelectedLayerId] = useState<string>("");
const [imageAssignments, setImageAssignments] = useState<Record<string, string>>({});
const [imagePlacements, setImagePlacements] = useState<Record<string, AgentPsdImagePlacement>>({});
const [imageSizes, setImageSizes] = useState<Record<string, AgentPsdImageSize>>({});
const [textAssignments, setTextAssignments] = useState<Record<string, string>>({});
const [textColorAssignments, setTextColorAssignments] = 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 inlineTextEditorRef = useRef<HTMLTextAreaElement | null>(null);
const imageDragStateRef = useRef<{
layerId: string;
pointerId: number;
startClientX: number;
startClientY: number;
startPlacement: AgentPsdImagePlacement;
} | null>(null);
const selectedTemplate = useMemo(
() => templates.find((template) => template.id === selectedTemplateId) || null,
[selectedTemplateId, templates]
);
const selectedTemplateLayers = useMemo(() => {
if (!selectedTemplate || !Array.isArray(selectedTemplate.layers)) {
return [] as AgentPsdTemplateLayerSlot[];
}
return selectedTemplate.layers;
}, [selectedTemplate]);
const editableImageLayers = useMemo(
() =>
selectedTemplateLayers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable && isAgentPsdImageLayerSlot(layer)
),
[selectedTemplateLayers]
);
const editableTextLayers = useMemo(
() =>
selectedTemplateLayers.filter(
(layer): layer is AgentPsdTemplateTextLayerSlot =>
layer.isEditable && isAgentPsdTextLayerSlot(layer)
),
[selectedTemplateLayers]
);
const fixedLayerCount = useMemo(() => {
if (!selectedTemplate) return 0;
return selectedTemplateLayers.filter((layer) => !layer.isEditable).length;
}, [selectedTemplate, selectedTemplateLayers]);
const orderedPreviewLayers = useMemo(
() => [...selectedTemplateLayers].reverse(),
[selectedTemplateLayers]
);
const orderedEditableLayers = useMemo(
() => orderedPreviewLayers.filter((layer) => layer.isEditable),
[orderedPreviewLayers]
);
const orderedEditableImageLayers = useMemo(
() =>
orderedEditableLayers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot => isAgentPsdImageLayerSlot(layer)
),
[orderedEditableLayers]
);
const orderedEditableTextLayers = useMemo(
() =>
orderedEditableLayers.filter(
(layer): layer is AgentPsdTemplateTextLayerSlot => isAgentPsdTextLayerSlot(layer)
),
[orderedEditableLayers]
);
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 effectiveTextColorAssignments = useMemo(() => {
const nextAssignments: Record<string, string> = {};
if (!selectedTemplate) {
return nextAssignments;
}
for (const layer of editableTextLayers) {
nextAssignments[layer.id] = getResolvedTextLayerColor(
layer,
textColorAssignments[layer.id]
);
}
return nextAssignments;
}, [editableTextLayers, selectedTemplate, textColorAssignments]);
const effectiveImagePlacements = useMemo(() => {
const nextPlacements: Record<string, AgentPsdImagePlacement> = {};
if (!selectedTemplate) {
return nextPlacements;
}
for (const layer of editableImageLayers) {
if (!String(effectiveImageAssignments[layer.id] || "").trim()) {
continue;
}
nextPlacements[layer.id] = normalizeAgentPsdImagePlacement(imagePlacements[layer.id]);
}
return nextPlacements;
}, [editableImageLayers, effectiveImageAssignments, imagePlacements, selectedTemplate]);
const selectedLayer = useMemo(
() => selectedTemplateLayers.find((layer) => layer.id === selectedLayerId) || null,
[selectedLayerId, selectedTemplateLayers]
);
const selectedImageLayer = selectedLayer && isAgentPsdImageLayerSlot(selectedLayer) ? selectedLayer : null;
const selectedTextLayer = selectedLayer && isAgentPsdTextLayerSlot(selectedLayer) ? selectedLayer : null;
const selectedImagePlacement = selectedImageLayer
? normalizeAgentPsdImagePlacement(effectiveImagePlacements[selectedImageLayer.id])
: getDefaultAgentPsdImagePlacement();
const selectedTextColor = selectedTextLayer
? effectiveTextColorAssignments[selectedTextLayer.id] || selectedTextLayer.textColor
: "#111111";
const fontFaceCss = useMemo(() => buildPreviewFontFaces(selectedTemplate), [selectedTemplate]);
const selectedTemplateSelectValue = selectedTemplate?.id;
const hasTemplateOptions = templates.length > 0;
useEffect(() => {
if (!open) return;
if (templates.length === 0) {
if (selectedTemplateId) {
setSelectedTemplateId("");
}
setSelectedLayerId("");
return;
}
if (!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);
setImagePlacements(initialState.imagePlacements);
setTextAssignments(initialState.textAssignments);
setTextColorAssignments({});
setSelectedLayerId(getPreferredSelectedLayerId(selectedTemplate));
if (!selectedTemplate || typeof window === "undefined") {
return;
}
// 自动适配缩放比例(延迟执行确保 DOM 已渲染)
const timeoutId = window.setTimeout(() => {
if (previewViewportRef.current) {
const viewport = previewViewportRef.current;
const padding = 32;
const availableWidth = viewport.clientWidth - padding;
const availableHeight = viewport.clientHeight - padding;
if (availableWidth > 0 && availableHeight > 0) {
const scaleX = availableWidth / selectedTemplate.width;
const scaleY = availableHeight / selectedTemplate.height;
const fitScale = Math.min(scaleX, scaleY, 1);
setStageScale(fitScale);
}
}
}, 100);
return () => {
window.clearTimeout(timeoutId);
};
}, [open, selectedTemplate, sourceImageUrl]);
useEffect(() => {
if (!selectedTemplate) {
setImageSizes((current) => {
if (Object.keys(current).length === 0) {
return current;
}
return {};
});
return;
}
if (typeof window === "undefined" || typeof window.Image !== "function") {
return;
}
let cancelled = false;
for (const layer of editableImageLayers) {
const assignedUrl = String(effectiveImageAssignments[layer.id] || "").trim();
if (!assignedUrl) {
continue;
}
const currentSize = imageSizes[layer.id];
if (currentSize && currentSize.width > 0 && currentSize.height > 0) {
continue;
}
const image = new window.Image();
image.onload = () => {
if (cancelled) return;
const nextWidth = Math.max(1, image.naturalWidth || layer.width);
const nextHeight = Math.max(1, image.naturalHeight || layer.height);
setImageSizes((current) => {
if (
current[layer.id]?.width === nextWidth &&
current[layer.id]?.height === nextHeight
) {
return current;
}
return {
...current,
[layer.id]: {
width: nextWidth,
height: nextHeight,
},
};
});
};
image.src = assignedUrl;
}
return () => {
cancelled = true;
};
}, [editableImageLayers, effectiveImageAssignments, imageSizes, selectedTemplate]);
useEffect(() => {
if (!selectedTemplate) {
setStageScale(1);
return;
}
const element = previewViewportRef.current;
if (!element) return;
const updateScale = () => {
const rect = element.getBoundingClientRect();
const availableWidth = Math.max(
240,
Math.min(PREVIEW_STAGE_TARGET_WIDTH, rect.width - PREVIEW_VIEWPORT_PADDING)
);
const availableHeight = Math.max(
240,
Math.min(PREVIEW_STAGE_TARGET_HEIGHT, rect.height - PREVIEW_VIEWPORT_PADDING)
);
const nextScale = Math.min(
availableWidth / Math.max(1, selectedTemplate.width),
availableHeight / Math.max(1, selectedTemplate.height),
PREVIEW_STAGE_MAX_SCALE
);
setStageScale(Number.isFinite(nextScale) && nextScale > 0 ? nextScale : 1);
};
updateScale();
let animationFrameId: number | null = null;
const scheduleScaleUpdate = () => {
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId);
}
animationFrameId = window.requestAnimationFrame(() => {
animationFrameId = null;
updateScale();
});
return;
}
updateScale();
};
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(scheduleScaleUpdate);
observer.observe(element);
return () => {
observer.disconnect();
if (animationFrameId !== null && typeof window !== "undefined") {
window.cancelAnimationFrame(animationFrameId);
}
};
}
if (typeof window !== "undefined") {
window.addEventListener("resize", scheduleScaleUpdate);
}
return () => {
if (typeof window !== "undefined") {
window.removeEventListener("resize", scheduleScaleUpdate);
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId);
}
}
};
}, [selectedTemplate]);
useEffect(() => {
if (!selectedTextLayer) {
return;
}
const textarea = inlineTextEditorRef.current;
if (!textarea) {
return;
}
const focusTextarea = () => {
textarea.focus();
const valueLength = textarea.value.length;
try {
textarea.setSelectionRange(valueLength, valueLength);
} catch (error) {
console.warn("[AgentPsdTemplateEditorDialog] setSelectionRange failed:", error);
}
};
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
const frameId = window.requestAnimationFrame(focusTextarea);
return () => {
window.cancelAnimationFrame(frameId);
};
}
const timeoutId = window.setTimeout(focusTextarea, 0);
return () => {
window.clearTimeout(timeoutId);
};
}, [selectedTextLayer?.id]);
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,
}));
setImagePlacements((current) => ({
...current,
[slotId]: getDefaultAgentPsdImagePlacement(),
}));
setImageSizes((current) => {
const next = { ...current };
delete next[slotId];
return next;
});
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();
}
}
function updateImagePlacement(
layerId: string,
updater: (current: AgentPsdImagePlacement) => AgentPsdImagePlacement
) {
setImagePlacements((current) => ({
...current,
[layerId]: normalizeAgentPsdImagePlacement(updater(normalizeAgentPsdImagePlacement(current[layerId]))),
}));
}
function nudgeImageScale(layerId: string, delta: number) {
updateImagePlacement(layerId, (current) => ({
...current,
scale: Number(clampImageScale(current.scale + delta).toFixed(2)),
}));
}
function resetImagePlacement(layerId: string) {
setImagePlacements((current) => ({
...current,
[layerId]: getDefaultAgentPsdImagePlacement(),
}));
}
function handleImageDragStart(
layer: AgentPsdTemplateImageLayerSlot,
event: ReactPointerEvent<HTMLDivElement>
) {
if (!String(effectiveImageAssignments[layer.id] || "").trim()) {
return;
}
imageDragStateRef.current = {
layerId: layer.id,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startPlacement: normalizeAgentPsdImagePlacement(effectiveImagePlacements[layer.id]),
};
const nextTarget = event.currentTarget;
try {
nextTarget.setPointerCapture?.(event.pointerId);
} catch (error) {
console.warn("[AgentPsdTemplateEditorDialog] setPointerCapture failed:", error);
}
const handlePointerMove = (moveEvent: PointerEvent) => {
const dragState = imageDragStateRef.current;
if (!dragState || dragState.layerId !== layer.id) {
return;
}
updateImagePlacement(layer.id, () => ({
...dragState.startPlacement,
offsetX: dragState.startPlacement.offsetX + (moveEvent.clientX - dragState.startClientX) / stageScale,
offsetY: dragState.startPlacement.offsetY + (moveEvent.clientY - dragState.startClientY) / stageScale,
}));
};
const handlePointerUp = (upEvent: PointerEvent) => {
if (imageDragStateRef.current?.pointerId === upEvent.pointerId) {
imageDragStateRef.current = null;
}
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}
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,
imagePlacements: effectiveImagePlacements,
textAssignments: effectiveTextAssignments,
textColorAssignments: effectiveTextColorAssignments,
}),
});
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 (
<>
<style dangerouslySetInnerHTML={{__html: `
.psd-preview-viewport::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.psd-preview-viewport::-webkit-scrollbar-track {
background: transparent;
}
.psd-preview-viewport::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.psd-preview-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
`}} />
<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>
{hasTemplateOptions ? (
<Select value={selectedTemplateSelectValue} onValueChange={setSelectedTemplateId}>
<SelectTrigger>
<SelectValue placeholder={isLoading ? "加载模板中..." : "请选择模板"} />
</SelectTrigger>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="rounded-xl border border-dashed border-white/15 bg-white/[0.02] px-3 py-2 text-sm text-muted-foreground">
{isLoading
? "正在加载可用模板..."
: normalizedBrandName
? `当前品牌“${normalizedBrandName}”下暂无可用模板`
: "请先在工作流中选择品牌后再打开 PSD 模板。"}
</div>
)}
</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>
<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="flex min-h-0 flex-col 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="psd-preview-viewport relative flex min-h-0 flex-1 items-center justify-center overflow-auto rounded-2xl border border-white/10 bg-[#111114] p-4"
>
{selectedTemplate ? (
<div
className="relative shrink-0"
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={assignedUrl ? "" : "h-full w-full"}
style={
assignedUrl && imageSizes[layer.id]
? getPlacedAssignedImageStyle({
layer,
imageSize: imageSizes[layer.id],
placement: effectiveImagePlacements[layer.id],
})
: assignedUrl
? {
width: "100%",
height: "100%",
objectFit: getImageFit(layer.replaceMode),
...getAssignedImageMaskStyle(layer),
}
: {
width: "100%",
height: "100%",
objectFit: "fill",
}
}
/>
</div>
);
}
const isSelected = selectedLayerId === layer.id;
if (isSelected) {
return null;
}
const value = effectiveTextAssignments[layer.id] || "";
const resolvedTextColor = effectiveTextColorAssignments[layer.id] || layer.textColor;
const shouldUseEditedPreview = hasCustomizedTextLayerAppearance(
layer,
value,
resolvedTextColor
);
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;
}
const textFrame = getTextLayerRenderFrame(
layer,
value || layer.placeholder || layer.layerName
);
return (
<div
key={layer.id}
className="absolute overflow-visible"
style={{
left: layer.x + textFrame.offsetX,
top: layer.y + textFrame.offsetY,
width: textFrame.width,
height: textFrame.height,
}}
>
<div
className="whitespace-pre"
style={getTextLayerContentStyle(
layer,
value || layer.placeholder || layer.layerName,
resolvedTextColor
)}
>
{value || layer.placeholder || layer.layerName}
</div>
</div>
);
})}
</div>
<div className="absolute inset-0">
{orderedEditableImageLayers.map((layer) => {
const isSelected = selectedLayerId === layer.id;
const frameStyle = getLayerFrameStyle(layer, isSelected);
const hasAssignedImage = Boolean(
String(effectiveImageAssignments[layer.id] || "").trim()
);
return isSelected && hasAssignedImage ? (
<div
key={layer.id}
role="button"
tabIndex={0}
onClick={() => setSelectedLayerId(layer.id)}
onPointerDown={(event) => handleImageDragStart(layer, event)}
className="absolute overflow-hidden rounded-lg transition-all"
style={{
...frameStyle,
zIndex: 20,
background: "rgba(249,115,22,0.08)",
cursor: imageDragStateRef.current?.layerId === layer.id ? "grabbing" : "grab",
touchAction: "none",
}}
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>
<div className="pointer-events-none absolute bottom-2 right-2 flex items-center gap-1 rounded-full bg-orange-500/85 px-2 py-1 text-[11px] text-white">
<Move className="h-3 w-3" />
拖动调整
</div>
</div>
) : (
<button
key={layer.id}
type="button"
onClick={() => setSelectedLayerId(layer.id)}
className="absolute overflow-hidden rounded-lg transition-all"
style={{
...frameStyle,
zIndex: isSelected ? 20 : 10,
background: isSelected ? "rgba(249,115,22,0.08)" : "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>
{!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}
</button>
);
})}
{orderedEditableImageLayers.map((layer) => {
const isSelected = selectedLayerId === layer.id;
const hasAssignedImage = Boolean(
String(effectiveImageAssignments[layer.id] || "").trim()
);
if (!isSelected || !hasAssignedImage) {
return null;
}
return (
<div
key={`${layer.id}-controls`}
className="absolute flex items-center gap-1 rounded-full bg-black/72 p-1 text-white shadow-lg"
style={{
left: layer.x + layer.width - 112,
top: Math.max(8, layer.y - 40),
zIndex: 25,
}}
>
<button
type="button"
className="rounded-full p-1.5 transition hover:bg-white/10"
onClick={() => nudgeImageScale(layer.id, -0.1)}
aria-label="缩小图片"
title="缩小"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded-full p-1.5 transition hover:bg-white/10"
onClick={() => nudgeImageScale(layer.id, 0.1)}
aria-label="放大图片"
title="放大"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded-full p-1.5 transition hover:bg-white/10"
onClick={() => resetImagePlacement(layer.id)}
aria-label="重置图片位置和缩放"
title="重置"
>
<RefreshCcw className="h-3.5 w-3.5" />
</button>
</div>
);
})}
{orderedEditableTextLayers.map((layer) => {
const isSelected = selectedLayerId === layer.id;
const frameStyle = getLayerFrameStyle(layer, isSelected);
const currentValue = effectiveTextAssignments[layer.id] || "";
const resolvedTextColor =
effectiveTextColorAssignments[layer.id] || layer.textColor;
const preserveSourceAppearance =
!!layer.sourceUrl &&
!hasCustomizedTextLayerAppearance(layer, currentValue, resolvedTextColor);
const editedTextFrame = getTextLayerRenderFrame(layer, currentValue);
if (isSelected) {
return (
<div
key={layer.id}
className="absolute overflow-visible rounded-lg"
style={{
...frameStyle,
zIndex: 40,
background: preserveSourceAppearance
? "transparent"
: "rgba(56,189,248,0.08)",
}}
>
{preserveSourceAppearance && layer.sourceUrl ? (
<img
src={layer.sourceUrl}
alt={layer.layerName}
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
/>
) : null}
<div
className="absolute"
style={
preserveSourceAppearance
? { inset: 0 }
: {
left: editedTextFrame.offsetX,
top: editedTextFrame.offsetY,
width: editedTextFrame.width,
height: editedTextFrame.height,
}
}
>
<textarea
ref={inlineTextEditorRef}
wrap="off"
value={currentValue}
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[layer.id]: event.target.value,
}))
}
className="resize-none border-none bg-transparent outline-none"
style={getInlineTextEditorStyle(
layer,
preserveSourceAppearance,
currentValue,
resolvedTextColor
)}
/>
</div>
<div className={getTextLayerNameBadgeClassName()}>
{layer.layerName}
</div>
</div>
);
}
return (
<button
key={layer.id}
type="button"
onClick={() => setSelectedLayerId(layer.id)}
onDoubleClick={() => setSelectedLayerId(layer.id)}
className="absolute overflow-visible rounded-lg transition-all"
style={{
...frameStyle,
zIndex: isSelected ? 40 : 30,
background: isSelected ? "rgba(56,189,248,0.05)" : "transparent",
}}
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
>
<div className={getTextLayerNameBadgeClassName()}>
{layer.layerName}
</div>
<div className={getTextLayerHintBadgeClassName()}>
点击编辑文字
</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);
}}
/>
<ScrollArea className="h-full pr-3">
<div className="flex min-h-full 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="h-[220px]">
{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>
{effectiveImageAssignments[selectedImageLayer.id] ? (
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Move className="h-3.5 w-3.5" />
选中画布中的图片后可直接拖动位置
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<ZoomIn className="h-3.5 w-3.5" />
缩放
</div>
<span className="rounded-full border border-orange-300/35 bg-orange-500/15 px-2 py-0.5 font-medium text-orange-100">
{Math.round(selectedImagePlacement.scale * 100)}%
</span>
</div>
<div className="rounded-xl border border-white/12 bg-black/45 p-2.5">
<div className="grid grid-cols-[34px_minmax(0,1fr)_34px] items-center gap-2">
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/[0.04] text-white transition-colors hover:bg-white/[0.12]"
onClick={() => nudgeImageScale(selectedImageLayer.id, -0.05)}
aria-label="缩小图片"
title="缩小"
>
<ZoomOut className="h-4 w-4" />
</button>
<Slider
min={MIN_IMAGE_SCALE}
max={MAX_IMAGE_SCALE}
step={0.01}
value={[selectedImagePlacement.scale]}
className="py-1"
trackClassName="h-2.5 rounded-full border border-white/20 bg-[#0f1014]"
rangeClassName="bg-gradient-to-r from-amber-300 via-orange-400 to-rose-400 shadow-[0_0_12px_rgba(251,146,60,0.45)]"
thumbClassName="h-5 w-5 border-2 border-orange-100 bg-[#0a0a0d] shadow-[0_0_0_5px_rgba(251,146,60,0.22)]"
onValueChange={(value) => {
const newScale = clampImageScale(value[0] || 1);
// offset 本身就是相对中心位移,缩放时不应按比例放大,否则会导致画面偏移看起来像“变形”。
updateImagePlacement(selectedImageLayer.id, (current) => ({
...current,
scale: newScale,
}));
}}
/>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/[0.04] text-white transition-colors hover:bg-white/[0.12]"
onClick={() => nudgeImageScale(selectedImageLayer.id, 0.05)}
aria-label="放大图片"
title="放大"
>
<ZoomIn className="h-4 w-4" />
</button>
</div>
<div className="mt-2 flex items-center justify-between px-0.5 text-[10px] text-white/45">
<span>40%</span>
<span className="text-orange-200">100%</span>
<span>300%</span>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => resetImagePlacement(selectedImageLayer.id)}
>
重置位置与缩放
</Button>
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
{sourceImageUrl ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
{
setImageAssignments((current) => ({
...current,
[selectedImageLayer.id]: sourceImageUrl,
}));
setImagePlacements((current) => ({
...current,
[selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(),
}));
setImageSizes((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
}
}
>
使用当前图片
</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;
});
setImagePlacements((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
setImageSizes((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
}}
>
清空
</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>
{textColorConfig.enabled ? (
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">文字颜色</div>
<div className="text-xs text-muted-foreground">
当前模板允许在两种系统预设颜色之间切换。
</div>
</div>
<div
className="h-8 w-8 rounded-full border border-white/15"
style={{ backgroundColor: selectedTextColor }}
/>
</div>
<div className="grid grid-cols-2 gap-2">
{textColorConfig.colors.map((color, index) => {
const isActive =
getResolvedTextLayerColor(selectedTextLayer, color) ===
getResolvedTextLayerColor(selectedTextLayer, selectedTextColor);
return (
<button
key={`text-color-option-${index + 1}`}
type="button"
onClick={() =>
setTextColorAssignments((current) => ({
...current,
[selectedTextLayer.id]: color,
}))
}
className={`flex items-center gap-3 rounded-xl border px-3 py-3 text-left transition ${
isActive
? "border-emerald-400/55 bg-emerald-500/[0.12] text-white"
: "border-white/10 bg-white/[0.02] text-white/80 hover:bg-white/[0.06]"
}`}
>
<span
className="h-6 w-6 rounded-full border border-white/20"
style={{ backgroundColor: color }}
/>
<span className="flex min-w-0 flex-col">
<span className="text-xs font-medium">{`颜色 ${index + 1}`}</span>
<span className="font-mono text-[11px] uppercase text-white/55">
{color}
</span>
</span>
</button>
);
})}
</div>
</div>
) : null}
</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-[240px] 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-[220px] 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>
</ScrollArea>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}