ai-chatbot/components/agents/AgentPsdTemplateEditorDialog.tsx
Your Name d7d33ebc4a
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add explicit psd text color switch buttons
2026-04-03 12:30:58 +08:00

2312 lines
85 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 {
Image as ImageIcon,
Loader2,
Move,
RefreshCcw,
Sparkles,
Type,
Upload,
ZoomIn,
ZoomOut,
} from "lucide-react";
import {
type ChangeEvent,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { AgentPsdTemplateExportPreview } from "@/components/agents/agent-psd-template-export-preview";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import {
type AgentPsdImagePlacement,
type AgentPsdImageSize,
getDefaultAgentPsdImagePlacement,
normalizeAgentPsdImagePlacement,
resolveAgentPsdImageBaseFrame,
} from "@/lib/agents/psd-image-placement";
import {
type AgentPsdTemplate,
type AgentPsdTemplateImageLayerSlot,
type AgentPsdTemplateLayerSlot,
type AgentPsdTemplateListResponse,
type AgentPsdTemplateMode,
type AgentPsdTemplateRenderResponse,
type AgentPsdTemplateTextLayerSlot,
isAgentPsdImageLayerSlot,
isAgentPsdTextLayerSlot,
parseAgentPsdTemplates,
} from "@/lib/agents/psd-template-config";
import {
defaultAgentPsdTemplateSecondaryCategoryConfig,
normalizeAgentPsdTemplateSecondaryCategoryConfig,
} from "@/lib/agents/psd-template-secondary-category-config";
import {
defaultAgentPsdTextColorConfig,
normalizeAgentPsdTextColorConfig,
} from "@/lib/agents/psd-text-color-config";
import {
normalizeHexColor,
resolveTextLayout,
} from "@/lib/agents/psd-text-layout";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
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,
secondaryCategoryConfig: defaultAgentPsdTemplateSecondaryCategoryConfig,
error:
typeof payload?.error === "string"
? payload.error
: "PSD 模板加载失败",
};
}
return {
success: true,
templates: parseAgentPsdTemplates(payload.templates),
textColorConfig: normalizeAgentPsdTextColorConfig(
payload.textColorConfig
),
secondaryCategoryConfig: normalizeAgentPsdTemplateSecondaryCategoryConfig(
payload.secondaryCategoryConfig
),
};
} catch {
return {
success: false,
templates: [],
textColorConfig: defaultAgentPsdTextColorConfig,
secondaryCategoryConfig: defaultAgentPsdTemplateSecondaryCategoryConfig,
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 getTemplateMetaLabel(
template: AgentPsdTemplate,
showSecondaryCategory: boolean
) {
const metaParts = [
`${template.width} x ${template.height}`,
showSecondaryCategory
? String(template.secondaryCategory || "").trim()
: "",
].filter(Boolean);
return metaParts.join(" · ");
}
function renderTemplateSelectContent(
template: AgentPsdTemplate,
params?: {
showSecondaryCategory?: boolean;
compact?: boolean;
}
) {
const showSecondaryCategory = params?.showSecondaryCategory === true;
const compact = params?.compact === true;
const metaLabel = getTemplateMetaLabel(template, showSecondaryCategory);
return (
<div
className={
compact
? "grid min-w-0 flex-1 grid-cols-[48px_minmax(0,1fr)] items-center gap-3"
: "grid min-w-0 flex-1 grid-cols-[46px_minmax(0,1fr)] items-center gap-2.5"
}
>
<div
className={
compact
? "flex h-12 w-12 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/5"
: "flex h-[46px] w-[46px] shrink-0 overflow-hidden rounded-lg border border-white/10 bg-white/5"
}
>
<img
alt={template.name}
className="h-full w-full object-cover"
src={template.previewUrl}
/>
</div>
<div className="min-w-0 text-left">
<div
className={
compact
? "truncate font-medium text-sm leading-tight"
: "truncate font-semibold text-[15px] leading-tight"
}
>
{template.name}
</div>
{metaLabel ? (
<div
className={
compact
? "mt-1 truncate text-[11px] text-white/45"
: "mt-0.5 truncate text-[11px] text-white/45"
}
>
{metaLabel}
</div>
) : null}
</div>
</div>
);
}
function renderTemplatePickerCardContent(
template: AgentPsdTemplate,
params?: {
showSecondaryCategory?: boolean;
emphasize?: boolean;
}
) {
const showSecondaryCategory = params?.showSecondaryCategory === true;
const emphasize = params?.emphasize === true;
const metaLabel = getTemplateMetaLabel(template, showSecondaryCategory);
return (
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/5">
<img
alt={template.name}
className="h-full w-full object-cover"
src={template.previewUrl}
/>
</div>
<div className="min-w-0">
<div
className={
emphasize
? "truncate font-semibold text-sm text-white"
: "truncate font-medium text-sm text-white"
}
>
{template.name}
</div>
{metaLabel ? (
<div className="mt-1 truncate text-[11px] text-white/45">
{metaLabel}
</div>
) : null}
</div>
</div>
);
}
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 normalizedPlacement = normalizeAgentPsdImagePlacement(placement);
const baseFrame = resolveAgentPsdImageBaseFrame({
containerWidth: layer.width,
containerHeight: layer.height,
imageWidth: imageSize.width,
imageHeight: imageSize.height,
replaceMode: layer.replaceMode,
contentBounds: layer.contentBounds,
});
return {
position: "absolute",
left: baseFrame.centerX + normalizedPlacement.offsetX,
top: baseFrame.centerY + normalizedPlacement.offsetY,
width: baseFrame.width,
height: baseFrame.height,
transform: `translate(-50%, -50%) scale(${normalizedPlacement.scale})`,
transformOrigin: "center center",
objectFit: getImageFit(layer.replaceMode),
maxWidth: "none",
maxHeight: "none",
willChange: "transform",
};
}
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,
sizeInfoText,
templateMode,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceImageUrl: string | null;
brandName?: string | null;
sizeInfoText?: string | null;
templateMode?: AgentPsdTemplateMode | null;
}) {
const normalizedBrandName = String(brandName || "").trim();
const normalizedTemplateMode =
templateMode === "sku" ? "sku" : templateMode === "main" ? "main" : "";
const templateListRequestUrl = useMemo(() => {
if (!open || !normalizedBrandName) {
return null;
}
const searchParams = new URLSearchParams();
searchParams.set("brand", normalizedBrandName);
return `/api/psd-templates?${searchParams.toString()}`;
}, [open, normalizedBrandName]);
const { data, isLoading } = useSWR<AgentPsdTemplateListResponse>(
templateListRequestUrl,
fetcher
);
const templates = useMemo(
() => (data?.success ? data.templates : []),
[data]
);
const textColorConfig = useMemo(
() => normalizeAgentPsdTextColorConfig(data?.textColorConfig),
[data?.textColorConfig]
);
const secondaryCategoryConfig = useMemo(
() =>
normalizeAgentPsdTemplateSecondaryCategoryConfig(
data?.secondaryCategoryConfig
),
[data?.secondaryCategoryConfig]
);
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 [rightPanelView, setRightPanelView] = useState<"editor" | "export">(
"editor"
);
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 shouldGroupTemplatesBySecondaryCategory = useMemo(
() =>
secondaryCategoryConfig.enabled &&
templates.some((template) =>
Boolean(String(template.secondaryCategory || "").trim())
),
[secondaryCategoryConfig.enabled, templates]
);
const mainModeTemplates = useMemo(
() => templates.filter((template) => template.mode === "main"),
[templates]
);
const skuModeTemplates = useMemo(
() => templates.filter((template) => template.mode === "sku"),
[templates]
);
const selectedTemplateLayers = useMemo(() => {
if (!selectedTemplate || !Array.isArray(selectedTemplate.layers)) {
return [] as AgentPsdTemplateLayerSlot[];
}
return selectedTemplate.layers;
}, [selectedTemplate]);
const selectedTemplateMode: AgentPsdTemplateMode =
selectedTemplate?.mode === "sku" ? "sku" : "main";
const selectedModeTemplates = useMemo(
() =>
selectedTemplateMode === "sku" ? skuModeTemplates : mainModeTemplates,
[mainModeTemplates, selectedTemplateMode, skuModeTemplates]
);
const groupedSelectedModeTemplates = useMemo(() => {
if (!shouldGroupTemplatesBySecondaryCategory) {
return [] as Array<{ label: string; templates: AgentPsdTemplate[] }>;
}
const groups = new Map<string, AgentPsdTemplate[]>();
for (const template of selectedModeTemplates) {
const groupLabel =
String(template.secondaryCategory || "").trim() || "未分组";
const currentGroup = groups.get(groupLabel) || [];
currentGroup.push(template);
groups.set(groupLabel, currentGroup);
}
return Array.from(groups.entries()).map(([label, grouped]) => ({
label,
templates: grouped,
}));
}, [selectedModeTemplates, shouldGroupTemplatesBySecondaryCategory]);
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 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 selectedImageAssignmentUrl = selectedImageLayer
? String(effectiveImageAssignments[selectedImageLayer.id] || "").trim()
: "";
const selectedImagePlacement = selectedImageLayer
? normalizeAgentPsdImagePlacement(
effectiveImagePlacements[selectedImageLayer.id]
)
: getDefaultAgentPsdImagePlacement();
const selectedTextColor = selectedTextLayer
? effectiveTextColorAssignments[selectedTextLayer.id] ||
selectedTextLayer.textColor
: "#111111";
const selectedImageAdjustmentPanel = selectedImageLayer ? (
<div className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.03] p-3.5">
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2">
{sourceImageUrl ? (
<Button
className="h-11 w-full min-w-0 justify-center gap-1.5 px-3 text-[13px]"
onClick={() => {
setImageAssignments((current) => ({
...current,
[selectedImageLayer.id]: sourceImageUrl,
}));
setImagePlacements((current) => ({
...current,
[selectedImageLayer.id]: getDefaultAgentPsdImagePlacement(),
}));
setImageSizes((current) => {
const next = { ...current };
delete next[selectedImageLayer.id];
return next;
});
}}
size="sm"
type="button"
variant="outline"
>
<span className="min-w-0 truncate">使用当前图片</span>
</Button>
) : null}
<Button
className="h-11 w-full min-w-0 justify-center gap-1.5 px-3 text-[13px]"
disabled={uploadingSlotId === selectedImageLayer.id}
onClick={() => openImageUpload(selectedImageLayer.id)}
size="sm"
type="button"
variant="outline"
>
{uploadingSlotId === selectedImageLayer.id ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
) : (
<Upload className="h-4 w-4 shrink-0" />
)}
<span className="min-w-0 truncate">上传替换图片</span>
</Button>
</div>
{selectedImageAssignmentUrl ? (
<>
<div className="flex justify-end">
<Button
className="px-2 text-white/70 hover:text-white"
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;
});
}}
size="sm"
type="button"
variant="ghost"
>
清空当前替换图
</Button>
</div>
<div className="h-px bg-white/10" />
<div className="space-y-2">
<div className="flex items-center justify-between text-muted-foreground text-xs">
<div className="flex items-center gap-2">
<ZoomIn className="h-3.5 w-3.5" />
缩放
</div>
<span className="rounded-full border border-emerald-300/35 bg-emerald-500/15 px-2 py-0.5 font-medium text-emerald-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
aria-label="缩小图片"
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)}
title="缩小"
type="button"
>
<ZoomOut className="h-4 w-4" />
</button>
<Slider
className="py-1"
max={MAX_IMAGE_SCALE}
min={MIN_IMAGE_SCALE}
onValueChange={(value) => {
const newScale = clampImageScale(value[0] || 1);
updateImagePlacement(selectedImageLayer.id, (current) => ({
...current,
scale: newScale,
}));
}}
rangeClassName="bg-gradient-to-r from-emerald-300 via-green-400 to-teal-400 shadow-[0_0_12px_rgba(16,185,129,0.38)]"
step={0.01}
thumbClassName="h-5 w-5 border-2 border-emerald-100 bg-[#0a0a0d] shadow-[0_0_0_5px_rgba(16,185,129,0.2)]"
trackClassName="h-2.5 rounded-full border border-white/20 bg-[#0f1014]"
value={[selectedImagePlacement.scale]}
/>
<button
aria-label="放大图片"
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)}
title="放大"
type="button"
>
<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-emerald-200">100%</span>
<span>300%</span>
</div>
</div>
</div>
<div className="flex justify-end">
<Button
className="px-2 text-white/70 hover:text-white"
onClick={() => resetImagePlacement(selectedImageLayer.id)}
size="sm"
type="button"
variant="ghost"
>
重置位置与缩放
</Button>
</div>
</>
) : null}
</div>
) : null;
const fontFaceCss = useMemo(
() => buildPreviewFontFaces(selectedTemplate),
[selectedTemplate]
);
const selectedTemplateSelectValue = selectedTemplate?.id;
const hasTemplateOptions = templates.length > 0;
useEffect(() => {
if (open) {
return;
}
setSelectedTemplateId("");
setSelectedLayerId("");
}, [open]);
useEffect(() => {
if (!open) return;
if (templates.length === 0) {
if (selectedTemplateId) {
setSelectedTemplateId("");
}
setSelectedLayerId("");
return;
}
if (!templates.some((template) => template.id === selectedTemplateId)) {
setSelectedTemplateId("");
}
}, [open, selectedTemplateId, templates]);
useEffect(() => {
if (!open) return;
setRenderedResultUrl("");
setRightPanelView("editor");
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?.id]);
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);
setRightPanelView("export");
toast.success("模板替换结果已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "PSD 模板渲染失败");
} finally {
setIsRendering(false);
}
}
function handleSwitchSelectedTemplateMode(nextMode: AgentPsdTemplateMode) {
if (selectedTemplateMode === nextMode) {
return;
}
const nextTemplates =
nextMode === "sku" ? skuModeTemplates : mainModeTemplates;
if (nextTemplates.length === 0) {
toast.error(
nextMode === "sku"
? "当前品牌下暂无 SKU 图模式模板"
: "当前品牌下暂无主图模式模板"
);
return;
}
setSelectedTemplateId(nextTemplates[0].id);
}
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 onOpenChange={onOpenChange} open={open}>
<DialogContent
className="flex h-[96vh] 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)]"
overlayClassName="bg-black/85 backdrop-blur-sm"
>
{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-white/10 border-b bg-[#09090b] p-5 xl:border-r xl:border-b-0">
<ScrollArea className="h-full pr-4">
<div className="space-y-4">
<div className="space-y-2">
<Label>
{selectedTemplate ? "选择模板" : "主图模式模板"}
</Label>
{selectedTemplate && hasTemplateOptions ? (
<div className="space-y-2.5">
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.03] p-1">
<button
className={`rounded-full px-3 py-1.5 font-medium text-xs transition ${
selectedTemplateMode === "main"
? "bg-emerald-500/[0.16] text-emerald-100"
: "text-white/60 hover:text-white"
}`}
onClick={() =>
handleSwitchSelectedTemplateMode("main")
}
type="button"
>
主图模式
</button>
<button
className={`rounded-full px-3 py-1.5 font-medium text-xs transition ${
selectedTemplateMode === "sku"
? "bg-emerald-500/[0.16] text-emerald-100"
: "text-white/60 hover:text-white"
}`}
onClick={() =>
handleSwitchSelectedTemplateMode("sku")
}
type="button"
>
SKU图模式
</button>
</div>
<Select
onValueChange={setSelectedTemplateId}
value={selectedTemplateSelectValue}
>
<SelectTrigger className="h-auto min-h-[58px] items-center px-2.5 py-2 [&>span]:contents">
{selectedTemplate ? (
<div className="min-w-0 flex-1 pr-3">
{renderTemplateSelectContent(selectedTemplate, {
showSecondaryCategory:
shouldGroupTemplatesBySecondaryCategory,
})}
</div>
) : (
<SelectValue
placeholder={
isLoading ? "加载模板中..." : "请选择模板"
}
/>
)}
</SelectTrigger>
<SelectContent
className="border-white/10 bg-[#101114] text-white shadow-2xl"
style={{
backgroundColor: "#101114",
color: "#ffffff",
}}
>
{shouldGroupTemplatesBySecondaryCategory
? groupedSelectedModeTemplates.map((group) => (
<SelectGroup
key={`psd-template-group-${group.label}`}
>
<SelectLabel className="text-white/50">
{group.label}
</SelectLabel>
{group.templates.map((template) => (
<SelectItem
className="py-2.5 pr-3 text-white focus:bg-white/10 focus:text-white data-[state=checked]:bg-white/10 data-[state=checked]:text-white"
key={template.id}
value={template.id}
>
{renderTemplateSelectContent(template, {
showSecondaryCategory:
shouldGroupTemplatesBySecondaryCategory,
compact: true,
})}
</SelectItem>
))}
</SelectGroup>
))
: selectedModeTemplates.map((template) => (
<SelectItem
className="py-2.5 pr-3 text-white focus:bg-white/10 focus:text-white data-[state=checked]:bg-white/10 data-[state=checked]:text-white"
key={template.id}
value={template.id}
>
{renderTemplateSelectContent(template, {
compact: true,
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-2">
{isLoading ? (
<>
{Array.from({ length: 2 }).map((_, index) => (
<div
className="rounded-2xl border border-white/15 border-dashed bg-white/[0.02] px-4 py-4 text-muted-foreground text-sm"
key={`main-template-loading-${index}`}
>
正在加载主图模式模板...
</div>
))}
</>
) : mainModeTemplates.length > 0 ? (
mainModeTemplates.map((template) => (
<button
className={`w-full rounded-2xl border p-3 text-left transition ${
selectedTemplateId === template.id
? "border-emerald-400/45 bg-emerald-500/10"
: "border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]"
}`}
key={template.id}
onClick={() => setSelectedTemplateId(template.id)}
type="button"
>
{renderTemplatePickerCardContent(template, {
showSecondaryCategory:
shouldGroupTemplatesBySecondaryCategory,
emphasize: true,
})}
</button>
))
) : (
<div className="rounded-xl border border-white/15 border-dashed bg-white/[0.02] px-3 py-3 text-muted-foreground text-sm">
{normalizedBrandName
? `当前品牌“${normalizedBrandName}”下暂无主图模式模板`
: "请先在工作流中选择品牌后再打开 PSD 模板。"}
</div>
)}
</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
alt={selectedTemplate.name}
className="h-full w-full object-contain"
src={selectedTemplate.previewUrl}
/>
</div>
</div>
<Button
className="w-full bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
disabled={isRendering}
onClick={handleRender}
>
{isRendering ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
应用模板并导出
</Button>
{selectedImageAdjustmentPanel}
</>
) : (
<div className="rounded-2xl border border-white/15 border-dashed p-5 text-muted-foreground text-sm">
{isLoading
? "正在加载可用模板..."
: hasTemplateOptions
? "请从左侧主图模式模板或右侧 SKU 图模式模板中选择一个模板开始编辑。"
: normalizedBrandName
? `当前品牌“${normalizedBrandName}”下还没有可用 PSD 模板,请先去后台配置。`
: "当前还没有启用的 PSD 模板,请先去后台配置。"}
</div>
)}
</div>
</ScrollArea>
</div>
<div className="flex min-h-0 flex-col border-white/10 border-b bg-[#09090b] p-5 xl:border-r xl:border-b-0">
<div
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"
ref={previewViewportRef}
>
{selectedTemplate ? (
<div
className="relative shrink-0"
style={{
width: selectedTemplate.width * stageScale,
height: selectedTemplate.height * stageScale,
}}
>
<div
className="absolute top-0 left-0 overflow-hidden rounded-[20px] shadow-2xl"
style={{
width: selectedTemplate.width,
height: selectedTemplate.height,
transform: `scale(${stageScale})`,
transformOrigin: "top left",
}}
>
{shouldShowCompositeFallback ? (
<img
alt={`${selectedTemplate.name} 编辑底图`}
className="absolute inset-0 h-full w-full object-cover"
src={
selectedTemplate.renderBaseUrl ||
selectedTemplate.previewUrl
}
/>
) : 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;
}
const assignedImageContainerStyle = assignedUrl
? {
...layerBoxStyle,
...getAssignedImageMaskStyle(layer),
}
: layerBoxStyle;
return (
<div
className="absolute overflow-hidden"
key={layer.id}
style={assignedImageContainerStyle}
>
<img
alt={layer.layerName}
className={assignedUrl ? "" : "h-full w-full"}
src={layerImageUrl}
style={
assignedUrl && imageSizes[layer.id]
? getPlacedAssignedImageStyle({
layer,
imageSize: imageSizes[layer.id],
placement:
effectiveImagePlacements[layer.id],
})
: assignedUrl
? {
width: "100%",
height: "100%",
objectFit: getImageFit(
layer.replaceMode
),
}
: {
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
className="absolute overflow-hidden"
key={layer.id}
style={layerBoxStyle}
>
<img
alt={layer.layerName}
className="h-full w-full object-fill"
src={layer.sourceUrl}
/>
</div>
);
}
if (!value && !layer.placeholder) {
return null;
}
const textFrame = getTextLayerRenderFrame(
layer,
value || layer.placeholder || layer.layerName
);
return (
<div
className="absolute overflow-visible"
key={layer.id}
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
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
className="absolute overflow-hidden rounded-lg transition-all"
key={layer.id}
onClick={() => setSelectedLayerId(layer.id)}
onPointerDown={(event) =>
handleImageDragStart(layer, event)
}
role="button"
style={{
...frameStyle,
zIndex: 20,
background: "rgba(249,115,22,0.08)",
cursor:
imageDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: "none",
}}
tabIndex={0}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
>
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
{layer.layerName}
</div>
<div className="pointer-events-none absolute right-2 bottom-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
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
className="absolute overflow-hidden rounded-lg transition-all"
key={layer.id}
onClick={() => setSelectedLayerId(layer.id)}
style={{
...frameStyle,
zIndex: isSelected ? 20 : 10,
background: isSelected
? "rgba(249,115,22,0.08)"
: "transparent",
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
type="button"
>
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white">
{layer.layerName}
</div>
{hasAssignedImage ? null : (
<div className="pointer-events-none absolute right-2 bottom-2 rounded-full bg-orange-500/85 px-2 py-1 text-[11px] text-white">
点击替换图片
</div>
)}
</button>
);
})}
{orderedEditableImageLayers.map((layer) => {
const isSelected = selectedLayerId === layer.id;
const hasAssignedImage = Boolean(
String(
effectiveImageAssignments[layer.id] || ""
).trim()
);
if (!isSelected || !hasAssignedImage) {
return null;
}
return (
<div
className="absolute flex items-center gap-1 rounded-full bg-black/72 p-1 text-white shadow-lg"
key={`${layer.id}-controls`}
style={{
left: layer.x + layer.width - 112,
top: Math.max(8, layer.y - 40),
zIndex: 25,
}}
>
<button
aria-label="缩小图片"
className="rounded-full p-1.5 transition hover:bg-white/10"
onClick={() => nudgeImageScale(layer.id, -0.1)}
title="缩小"
type="button"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<button
aria-label="放大图片"
className="rounded-full p-1.5 transition hover:bg-white/10"
onClick={() => nudgeImageScale(layer.id, 0.1)}
title="放大"
type="button"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
aria-label="重置图片位置和缩放"
className="rounded-full p-1.5 transition hover:bg-white/10"
onClick={() => resetImagePlacement(layer.id)}
title="重置"
type="button"
>
<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
className="absolute overflow-visible rounded-lg"
key={layer.id}
style={{
...frameStyle,
zIndex: 40,
background: preserveSourceAppearance
? "transparent"
: "rgba(56,189,248,0.08)",
}}
>
{preserveSourceAppearance && layer.sourceUrl ? (
<img
alt={layer.layerName}
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
src={layer.sourceUrl}
/>
) : null}
<div
className="absolute"
style={
preserveSourceAppearance
? { inset: 0 }
: {
left: editedTextFrame.offsetX,
top: editedTextFrame.offsetY,
width: editedTextFrame.width,
height: editedTextFrame.height,
}
}
>
<textarea
className="resize-none border-none bg-transparent outline-none"
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[layer.id]: event.target.value,
}))
}
ref={inlineTextEditorRef}
style={getInlineTextEditorStyle(
layer,
preserveSourceAppearance,
currentValue,
resolvedTextColor
)}
value={currentValue}
wrap="off"
/>
</div>
<div
className={getTextLayerNameBadgeClassName()}
>
{layer.layerName}
</div>
</div>
);
}
return (
<button
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
className="absolute overflow-visible rounded-lg transition-all"
key={layer.id}
onClick={() => setSelectedLayerId(layer.id)}
onDoubleClick={() => setSelectedLayerId(layer.id)}
style={{
...frameStyle,
zIndex: isSelected ? 40 : 30,
background: isSelected
? "rgba(56,189,248,0.05)"
: "transparent",
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
type="button"
>
<div className={getTextLayerNameBadgeClassName()}>
{layer.layerName}
</div>
<div className={getTextLayerHintBadgeClassName()}>
点击编辑文字
</div>
</button>
);
})}
</div>
</div>
</div>
) : (
<div className="flex h-full w-full flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-sm text-white">
SKU图模式模板
</div>
<div className="mt-1 text-white/45 text-xs">
{normalizedTemplateMode === "sku"
? "当前结果属于 SKU 图模式,可直接从这里选择并进入编辑。"
: "这里集中展示当前品牌下的 SKU 图模式模板。"}
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-white/65 text-xs">
{skuModeTemplates.length} 个模板
</div>
</div>
{isLoading ? (
<div className="grid gap-3 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div
className="rounded-2xl border border-white/15 border-dashed bg-white/[0.02] px-4 py-5 text-muted-foreground text-sm"
key={`sku-template-loading-${index}`}
>
正在加载 SKU 图模式模板...
</div>
))}
</div>
) : skuModeTemplates.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{skuModeTemplates.map((template) => (
<button
className={`rounded-2xl border p-3 text-left transition ${
selectedTemplateId === template.id
? "border-emerald-400/45 bg-emerald-500/10"
: "border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]"
}`}
key={template.id}
onClick={() => setSelectedTemplateId(template.id)}
type="button"
>
{renderTemplatePickerCardContent(template, {
showSecondaryCategory:
shouldGroupTemplatesBySecondaryCategory,
emphasize: true,
})}
</button>
))}
</div>
) : (
<div className="flex h-full w-full items-center justify-center px-6 text-center text-muted-foreground text-sm">
{normalizedBrandName
? `当前品牌“${normalizedBrandName}”下暂无 SKU 图模式模板`
: "请先返回工作流区域选择品牌,再打开 PSD 模板。"}
</div>
)}
</div>
)}
</div>
</div>
<div className="flex min-h-0 flex-col bg-[#09090b] p-4 xl:p-5">
{renderedResultUrl ? (
<div className="mb-3 flex items-center justify-end gap-3">
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.03] p-1">
<button
className={`rounded-full px-3 py-1.5 font-medium text-xs transition ${
rightPanelView === "editor"
? "bg-emerald-500/[0.16] text-emerald-100"
: "text-white/60 hover:text-white"
}`}
onClick={() => setRightPanelView("editor")}
type="button"
>
编辑面板
</button>
<button
className={`rounded-full px-3 py-1.5 font-medium text-xs transition ${
rightPanelView === "export"
? "bg-emerald-500/[0.16] text-emerald-100"
: "text-white/60 hover:text-white"
}`}
onClick={() => setRightPanelView("export")}
type="button"
>
导出结果
</button>
</div>
</div>
) : null}
<Input
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => {
if (!selectedImageLayer) return;
void handleSlotUpload(selectedImageLayer.id, event);
}}
ref={uploadInputRef}
type="file"
/>
{renderedResultUrl && rightPanelView === "export" ? (
<AgentPsdTemplateExportPreview
downloadName={`${selectedTemplate?.name || "PSD模板"}-${selectedTemplateMode === "sku" ? "SKU图" : "主图"}.png`}
enableDimensionAnnotations={selectedTemplateMode === "sku"}
imageAlt="PSD 模板导出结果"
imageUrl={renderedResultUrl}
key={`${selectedTemplate?.id || "template"}-${renderedResultUrl}`}
sizeInfoText={sizeInfoText}
/>
) : selectedTextLayer ? (
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-3.5 xl:p-4">
<div className="flex h-full flex-col gap-3">
<Textarea
className="min-h-[180px] flex-1 resize-none xl:min-h-[200px]"
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[selectedTextLayer.id]: event.target.value,
}))
}
placeholder={
selectedTextLayer.placeholder || "请输入文字"
}
rows={4}
value={
effectiveTextAssignments[selectedTextLayer.id] || ""
}
/>
<div className="flex flex-wrap gap-2 text-muted-foreground text-xs">
<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="font-medium text-sm">文字颜色</div>
<div className="text-muted-foreground text-xs">
选中文字后,可直接点击下面按钮切换品牌预设文字颜色。
</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
className={`h-11 justify-start gap-2.5 rounded-xl border px-3 text-left ${
isActive
? "border-emerald-400/60 bg-emerald-500/[0.14] text-white hover:bg-emerald-500/[0.18]"
: "border-white/10 bg-white/[0.02] text-white/80 hover:bg-white/[0.06]"
}`}
key={`text-color-button-${index + 1}`}
onClick={() =>
setTextColorAssignments((current) => ({
...current,
[selectedTextLayer.id]: color,
}))
}
type="button"
variant="ghost"
>
<span
className="h-5 w-5 shrink-0 rounded-full border border-white/20"
style={{ backgroundColor: color }}
/>
<span className="min-w-0 flex-1 truncate font-medium text-sm">{`切换为颜色 ${index + 1}`}</span>
</Button>
);
})}
</div>
<div className="grid grid-cols-2 gap-2">
{textColorConfig.colors.map((color, index) => {
const isActive =
getResolvedTextLayerColor(
selectedTextLayer,
color
) ===
getResolvedTextLayerColor(
selectedTextLayer,
selectedTextColor
);
return (
<button
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]"
}`}
key={`text-color-option-${index + 1}`}
onClick={() =>
setTextColorAssignments((current) => ({
...current,
[selectedTextLayer.id]: color,
}))
}
type="button"
>
<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="font-medium text-xs">{`颜色 ${index + 1}`}</span>
<span className="font-mono text-[11px] text-white/55 uppercase">
{color}
</span>
</span>
</button>
);
})}
</div>
</div>
) : null}
</div>
</div>
) : (
<div className="flex-1" />
)}
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}