ai-chatbot/components/agents/AgentPsdTemplateEditorDialog.tsx

5434 lines
203 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 {
ArrowLeft,
ChevronDown,
ChevronUp,
CheckCircle2,
Eye,
EyeOff,
Expand,
Image as ImageIcon,
Images,
Loader2,
Move,
Pointer,
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, { preload } 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,
} 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 {
AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG,
defaultAgentPsdTemplateCategoryTagConfig,
normalizeAgentPsdTemplateCategoryTagConfig,
normalizeAgentPsdTemplateCategoryTags,
} from "@/lib/agents/psd-template-category-tag-config";
import {
type AgentPsdTemplate,
type AgentPsdTemplateImageLayerSlot,
type AgentPsdTemplateLayerSlot,
type AgentPsdTemplateListResponse,
type AgentPsdTemplateMode,
type AgentPsdTemplateMutationResponse,
type AgentPsdTemplateRenderResponse,
type AgentPsdTemplateTextLayerSlot,
isAgentPsdImageLayerSlot,
isAgentPsdLayerSuperLocked,
isAgentPsdTextLayerSlot,
parseAgentPsdTemplates,
resolveAgentPsdTextLayerEffectiveAlign,
} from "@/lib/agents/psd-template-config";
import {
defaultAgentPsdTemplateSecondaryCategoryConfig,
normalizeAgentPsdTemplateSecondaryCategoryConfig,
} from "@/lib/agents/psd-template-secondary-category-config";
import {
defaultAgentPsdTextColorConfig,
normalizeAgentPsdTextColorConfig,
resolveAgentPsdTextColorConfigForBrand,
} from "@/lib/agents/psd-text-color-config";
import {
normalizeHexColor,
resolveTextLayout,
} from "@/lib/agents/psd-text-layout";
import { requestLongTaskResult } from "@/lib/client/long-task";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import type { CanvasPsdTemplatePendingImage } from "@/lib/canvas/psd-template-launch";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import {
getFriendlyRateLimitMessage,
getUserFacingRequestErrorMessage,
} from "@/lib/request-error";
function buildTemplateListRequestUrl(params?: {
brandName?: string | null;
summaryOnly?: boolean;
}) {
const searchParams = new URLSearchParams();
if (params?.summaryOnly !== false) {
searchParams.set("summary", "1");
}
const normalizedBrandName = String(params?.brandName || "").trim();
if (normalizedBrandName) {
searchParams.set("brand", normalizedBrandName);
}
const queryString = searchParams.toString();
return queryString
? `/api/psd-templates?${queryString}`
: "/api/psd-templates";
}
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,
categoryTagConfig: defaultAgentPsdTemplateCategoryTagConfig,
error:
typeof payload?.error === "string"
? payload.error
: "PSD 模板加载失败",
};
}
return {
success: true,
templates: parseAgentPsdTemplates(payload.templates),
textColorConfig: normalizeAgentPsdTextColorConfig(
payload.textColorConfig
),
secondaryCategoryConfig: normalizeAgentPsdTemplateSecondaryCategoryConfig(
payload.secondaryCategoryConfig
),
categoryTagConfig: normalizeAgentPsdTemplateCategoryTagConfig(
payload.categoryTagConfig
),
};
} catch {
return {
success: false,
templates: [],
textColorConfig: defaultAgentPsdTextColorConfig,
secondaryCategoryConfig: defaultAgentPsdTemplateSecondaryCategoryConfig,
categoryTagConfig: defaultAgentPsdTemplateCategoryTagConfig,
error: "PSD 模板加载失败",
};
}
};
const templateDetailFetcher = async (
url: string
): Promise<AgentPsdTemplateMutationResponse> => {
try {
const response = await fetch(url, { cache: "default" });
const payload = (await response
.json()
.catch(() => null)) as Partial<AgentPsdTemplateMutationResponse> | null;
if (
!response.ok ||
!payload ||
payload.success !== true ||
!payload.template
) {
return {
success: false,
error:
typeof payload?.error === "string"
? payload.error
: "PSD 模板详情加载失败",
};
}
return {
success: true,
template: parseAgentPsdTemplates([payload.template])[0],
};
} catch {
return {
success: false,
error: "PSD 模板详情加载失败",
};
}
};
const PSD_RENDER_INPUT_ARCHIVE_CONCURRENCY = 4;
async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
mapper: (item: T, index: number) => Promise<R>
) {
const results = new Array<R>(items.length);
let nextIndex = 0;
const workerCount = Math.max(
1,
Math.min(items.length, Math.round(Number(concurrency || 1)))
);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await mapper(items[currentIndex]!, currentIndex);
}
})
);
return results;
}
async function parseActionJsonResponse<T>(
response: Response,
fallbackMessage: string,
rateLimitMessage?: string
): Promise<T> {
const payload = await response
.clone()
.json()
.catch(() => null);
if (!response.ok) {
let responseText = "";
if (payload == null) {
responseText = await response
.text()
.then((value) => value.trim())
.catch(() => "");
}
const requestError = new Error(
getUserFacingRequestErrorMessage(
{
status: response.status,
data: payload,
message: responseText,
},
fallbackMessage,
{
rateLimitMessage,
}
)
) as Error & {
status?: number;
data?: unknown;
};
requestError.status = response.status;
requestError.data =
payload ??
(responseText
? {
message: responseText,
}
: null);
throw requestError;
}
return payload as T;
}
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;
const PSD_LAYER_Z_INDEX_STEP = 10;
const PSD_RENDER_BASE_Z_INDEX_OFFSET = 5;
const SELECTED_LAYER_INTERACTION_Z_BOOST = 1;
const SELECTED_LAYER_CONTROLS_Z_BOOST = 1000;
const MIN_TEXT_POSITION_OFFSET = -5000;
const MAX_TEXT_POSITION_OFFSET = 5000;
const UNASSIGNED_BRAND_LABEL = "未分类";
const IMAGE_EXTENSION_REGEX = /\.([a-z0-9]{2,5})$/i;
const HIDDEN_LAYER_SCROLL_STEP = 180;
type AgentPsdTextPositionOffset = {
offsetX: number;
offsetY: number;
};
type AgentPsdLayerPositionOffset = AgentPsdTextPositionOffset;
function clampTextPositionOffsetValue(value: number) {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(
MIN_TEXT_POSITION_OFFSET,
Math.min(MAX_TEXT_POSITION_OFFSET, value)
);
}
function normalizeTextPositionOffset(
value?: Partial<AgentPsdTextPositionOffset> | null
): AgentPsdTextPositionOffset {
return {
offsetX: Number(
clampTextPositionOffsetValue(Number(value?.offsetX || 0)).toFixed(2)
),
offsetY: Number(
clampTextPositionOffsetValue(Number(value?.offsetY || 0)).toFixed(2)
),
};
}
function hasTextPositionOffset(
value?: Partial<AgentPsdTextPositionOffset> | null
) {
const normalized = normalizeTextPositionOffset(value);
return (
Math.abs(normalized.offsetX) > 0.01 || Math.abs(normalized.offsetY) > 0.01
);
}
function normalizeLayerPositionOffset(
value?: Partial<AgentPsdLayerPositionOffset> | null
) {
return normalizeTextPositionOffset(value);
}
function hasLayerPositionOffset(
value?: Partial<AgentPsdLayerPositionOffset> | null
) {
return hasTextPositionOffset(value);
}
function buildTemplatePreviewThumbUrl(
previewUrl: string,
params?: {
width?: number;
quality?: number;
}
) {
const normalizedUrl = String(previewUrl || "").trim();
if (!normalizedUrl) {
return "";
}
const width = Math.max(80, Math.round(params?.width || 160));
const quality = Math.max(40, Math.round(params?.quality || 72));
const directOptimizedUrl = buildImageProxyUrl(normalizedUrl, {
width,
quality,
format: "webp",
});
if (directOptimizedUrl !== normalizedUrl) {
return directOptimizedUrl;
}
return buildImageProxyUrl(normalizedUrl, {
width,
quality,
format: "webp",
delivery: "proxy",
});
}
function buildPsdLayerPreviewUrl(
imageUrl: string,
params?: {
width?: number;
quality?: number;
delivery?: "proxy";
}
) {
const width = Math.max(96, Math.min(1280, Math.round(params?.width || 720)));
const quality = Math.max(52, Math.min(82, Math.round(params?.quality || 68)));
if (params?.delivery === "proxy") {
return buildImageProxyUrl(imageUrl, {
width,
quality,
format: "webp",
delivery: "proxy",
});
}
return buildTemplatePreviewThumbUrl(imageUrl, { width, quality });
}
function buildPsdLayerMaskUrl(maskUrl: string, layerWidth: number) {
const normalizedUrl = String(maskUrl || "").trim();
if (!normalizedUrl) {
return "";
}
return buildImageProxyUrl(normalizedUrl, {
width: Math.max(96, Math.min(1280, Math.round(layerWidth || 720))),
quality: 100,
format: "png",
delivery: "proxy",
});
}
function getPsdLayerPreviewSourceUrl(
layer: Pick<AgentPsdTemplateLayerSlot, "sourceUrl" | "previewUrl">
) {
return (
String(layer.sourceUrl || "").trim() ||
String(layer.previewUrl || "").trim()
);
}
function buildTemplatePreviewFallbackUrls(
template: Pick<AgentPsdTemplate, "previewUrl" | "renderBaseUrl">,
params?: {
width?: number;
quality?: number;
preferRenderBase?: boolean;
}
) {
const sourceUrls = (
params?.preferRenderBase
? [template.renderBaseUrl, template.previewUrl]
: [template.previewUrl, template.renderBaseUrl]
)
.map((url) => String(url || "").trim())
.filter(Boolean);
const candidates = [
...sourceUrls.map((url) =>
buildTemplatePreviewThumbUrl(url, {
width: params?.width,
quality: params?.quality,
})
),
...sourceUrls,
];
const seen = new Set<string>();
return candidates.filter((url) => {
if (!url || seen.has(url)) {
return false;
}
seen.add(url);
return true;
});
}
function TemplatePreviewImage({
template,
alt,
className,
width,
quality,
preferRenderBase,
decoding = "async",
fetchPriority,
loading = "lazy",
style,
}: {
template: Pick<AgentPsdTemplate, "name" | "previewUrl" | "renderBaseUrl">;
alt?: string;
className?: string;
style?: CSSProperties;
width?: number;
quality?: number;
preferRenderBase?: boolean;
decoding?: "async" | "auto" | "sync";
fetchPriority?: "auto" | "high" | "low";
loading?: "eager" | "lazy";
}) {
const fallbackUrls = useMemo(
() =>
buildTemplatePreviewFallbackUrls(template, {
width,
quality,
preferRenderBase,
}),
[
preferRenderBase,
quality,
template.previewUrl,
template.renderBaseUrl,
width,
]
);
const fallbackUrlKey = fallbackUrls.join("\n");
const [activeUrlIndex, setActiveUrlIndex] = useState(0);
useEffect(() => {
setActiveUrlIndex(0);
}, [fallbackUrlKey]);
const src = fallbackUrls[activeUrlIndex] || "";
if (!src) {
return (
<div
aria-label={alt || template.name}
className={className}
role="img"
style={style}
/>
);
}
const intrinsicSize = Math.max(1, Math.round(width || 1));
return (
<img
alt={alt || template.name}
className={className}
decoding={decoding}
fetchPriority={fetchPriority}
height={intrinsicSize}
loading={loading}
onError={() =>
setActiveUrlIndex((current) =>
current + 1 < fallbackUrls.length ? current + 1 : current
)
}
src={src}
style={style}
width={intrinsicSize}
/>
);
}
function getScrollAreaViewportElement(root: HTMLDivElement | null) {
return (
(root?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLDivElement | null) || root
);
}
function isLikelyStableRenderImageUrl(url: string) {
const normalized = String(url || "").trim();
if (!normalized) {
return true;
}
if (
normalized.startsWith("data:") ||
normalized.startsWith("blob:") ||
normalized.startsWith("/api/image-proxy")
) {
return true;
}
if (typeof window === "undefined") {
return false;
}
try {
const parsed = new URL(normalized, window.location.origin);
if (parsed.origin === window.location.origin) {
return true;
}
const hostname = parsed.hostname.toLowerCase();
return (
hostname.includes("aliyuncs.com") ||
hostname.includes("oss-cn-") ||
hostname.includes("cloudfront.net") ||
hostname.includes("r2.dev")
);
} catch {
return false;
}
}
function inferImageExtension(url: string, contentType: string) {
const normalizedContentType = String(contentType || "").toLowerCase();
if (normalizedContentType.includes("png")) {
return "png";
}
if (normalizedContentType.includes("webp")) {
return "webp";
}
if (
normalizedContentType.includes("jpeg") ||
normalizedContentType.includes("jpg")
) {
return "jpg";
}
if (normalizedContentType.includes("gif")) {
return "gif";
}
if (normalizedContentType.includes("avif")) {
return "avif";
}
try {
const pathname = new URL(url, "https://placeholder.invalid").pathname;
const matched = pathname.match(IMAGE_EXTENSION_REGEX);
if (matched?.[1]) {
return matched[1].toLowerCase();
}
} catch {
// Ignore malformed URLs and fall back to png.
}
return "png";
}
function normalizeManagedBrandName(value?: string | null) {
const normalized = String(value || "").trim();
if (!normalized || normalized === "未分配品牌") {
return "";
}
return normalized;
}
function matchesTemplateBrand(template: AgentPsdTemplate, brandName?: string | null) {
const normalizedBrandName = normalizeManagedBrandName(brandName).toLowerCase();
if (!normalizedBrandName) {
return true;
}
return normalizeManagedBrandName(template.brandName).toLowerCase() === normalizedBrandName;
}
function getEffectiveTemplateCategoryTags(
template: AgentPsdTemplate,
activeCategoryTags: string[]
) {
const templateCategoryTags = normalizeAgentPsdTemplateCategoryTags(
template.categoryTags
);
if (templateCategoryTags.length === 0) {
return [];
}
const activeCategoryTagSet = new Set(
activeCategoryTags.map((tag) => tag.trim().toLowerCase()).filter(Boolean)
);
if (activeCategoryTagSet.size === 0) {
return templateCategoryTags;
}
return templateCategoryTags.filter(
(tag) =>
tag === AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG ||
activeCategoryTagSet.has(tag.trim().toLowerCase())
);
}
function matchesTemplateSelectedCategories(
template: AgentPsdTemplate,
selectedCategoryNames: string[],
activeCategoryTags: string[]
) {
if (selectedCategoryNames.length === 0) {
return true;
}
const rawTemplateCategoryTags = normalizeAgentPsdTemplateCategoryTags(
template.categoryTags
);
if (rawTemplateCategoryTags.length === 0) {
return false;
}
const templateCategoryTags = getEffectiveTemplateCategoryTags(
template,
activeCategoryTags
);
if (templateCategoryTags.length === 0) {
return false;
}
if (templateCategoryTags.includes(AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG)) {
return true;
}
const selectedCategorySet = new Set(
selectedCategoryNames.map((tag) => tag.trim().toLowerCase()).filter(Boolean)
);
return templateCategoryTags.some((tag) =>
selectedCategorySet.has(tag.trim().toLowerCase())
);
}
function getTemplateMetaLabel(
template: AgentPsdTemplate,
showSecondaryCategory: boolean,
showBrand = false
) {
const metaParts = [
showBrand
? normalizeManagedBrandName(template.brandName) || UNASSIGNED_BRAND_LABEL
: "",
`${template.width} x ${template.height}`,
showSecondaryCategory
? String(template.secondaryCategory || "").trim()
: "",
].filter(Boolean);
return metaParts.join(" · ");
}
function getTemplateCategoryTagLabels(template: AgentPsdTemplate) {
return normalizeAgentPsdTemplateCategoryTags(template.categoryTags)
.map((tag) =>
tag === AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG ? "适合所有" : tag
)
.filter(Boolean);
}
function renderTemplateCategoryTagRow(
template: AgentPsdTemplate,
params?: {
compact?: boolean;
}
) {
const compact = params?.compact === true;
const labels = getTemplateCategoryTagLabels(template);
if (labels.length === 0) {
return null;
}
const visibleLabels = compact ? labels.slice(0, 2) : labels.slice(0, 3);
const hiddenCount = Math.max(0, labels.length - visibleLabels.length);
return (
<div
className={
compact ? "mt-1.5 flex flex-wrap gap-1" : "mt-2 flex flex-wrap gap-1.5"
}
>
{visibleLabels.map((label) => (
<span
className={
compact
? "rounded-full border border-white/10 bg-white/[0.05] px-1.5 py-0.5 text-[10px] text-white/68 leading-none"
: "rounded-full border border-white/10 bg-white/[0.05] px-2 py-0.5 text-[10px] text-white/72 leading-none"
}
key={`${template.id}-category-tag-${label}`}
>
{label}
</span>
))}
{hiddenCount > 0 ? (
<span
className={
compact
? "rounded-full border border-white/10 bg-white/[0.03] px-1.5 py-0.5 text-[10px] text-white/45 leading-none"
: "rounded-full border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[10px] text-white/45 leading-none"
}
>
+{hiddenCount}
</span>
) : null}
</div>
);
}
function renderTemplateSelectContent(
template: AgentPsdTemplate,
params?: {
showSecondaryCategory?: boolean;
showBrand?: boolean;
compact?: boolean;
}
) {
const showSecondaryCategory = params?.showSecondaryCategory === true;
const showBrand = params?.showBrand === true;
const compact = params?.compact === true;
const metaLabel = getTemplateMetaLabel(
template,
showSecondaryCategory,
showBrand
);
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"
}
>
<TemplatePreviewImage
className="h-full w-full object-cover"
loading="lazy"
template={template}
width={compact ? 96 : 92}
/>
</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}
{renderTemplateCategoryTagRow(template, { compact })}
</div>
</div>
);
}
function renderTemplatePickerCardContent(
template: AgentPsdTemplate,
params?: {
showSecondaryCategory?: boolean;
showBrand?: boolean;
emphasize?: boolean;
eager?: boolean;
}
) {
const showSecondaryCategory = params?.showSecondaryCategory === true;
const showBrand = params?.showBrand === true;
const emphasize = params?.emphasize === true;
const eager = params?.eager === true;
const metaLabel = getTemplateMetaLabel(
template,
showSecondaryCategory,
showBrand
);
return (
<div className="flex min-w-0 items-center gap-3.5">
<div className="flex h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/5 shadow-inner">
<TemplatePreviewImage
className="h-full w-full object-cover"
fetchPriority={eager ? "high" : "low"}
loading={eager ? "eager" : "lazy"}
quality={66}
template={template}
width={112}
/>
</div>
<div className="min-w-0">
<div
className={
emphasize
? "truncate font-semibold text-[15px] text-white leading-tight"
: "truncate font-medium text-sm text-white leading-tight"
}
>
{template.name}
</div>
{metaLabel ? (
<div className="mt-1 truncate text-[11px] text-white/45">
{metaLabel}
</div>
) : null}
{renderTemplateCategoryTagRow(template)}
</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 getDefaultSourceImageLayer(
layers: AgentPsdTemplateLayerSlot[]
): AgentPsdTemplateImageLayerSlot | null {
const editableImageLayers = layers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdImageLayerSlot(layer)
);
return (
editableImageLayers.find((layer) => layer.isDefaultResultSource) ||
editableImageLayers[0] ||
null
);
}
function createInitialState(
template: AgentPsdTemplate | null,
sourceImageUrl: string
) {
const imageAssignments: Record<string, string> = {};
const imagePlacements: Record<string, AgentPsdImagePlacement> = {};
const layerOrderIds: string[] = [];
const layerPositionOffsets: Record<string, AgentPsdLayerPositionOffset> = {};
const textAssignments: Record<string, string> = {};
const textPositionOffsets: Record<string, AgentPsdTextPositionOffset> = {};
if (!template) {
return {
imageAssignments,
imagePlacements,
layerOrderIds,
layerPositionOffsets,
textAssignments,
textPositionOffsets,
};
}
const defaultSourceImageLayerId = sourceImageUrl
? getDefaultSourceImageLayer(template.layers)?.id || ""
: "";
for (const layer of template.layers) {
layerOrderIds.push(layer.id);
if (isAgentPsdLayerSuperLocked(layer)) {
continue;
}
if (
isAgentPsdImageLayerSlot(layer) &&
layer.id === defaultSourceImageLayerId &&
sourceImageUrl
) {
imageAssignments[layer.id] = sourceImageUrl;
imagePlacements[layer.id] = getDefaultAgentPsdImagePlacement();
}
if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) {
textAssignments[layer.id] = layer.defaultText;
}
}
return {
imageAssignments,
imagePlacements,
layerOrderIds,
layerPositionOffsets,
textAssignments,
textPositionOffsets,
};
}
function normalizeLayerTransferKey(value: string) {
return String(value || "")
.trim()
.toLowerCase();
}
function getLayerTransferKeys(layer: AgentPsdTemplateLayerSlot) {
const keys = new Set<string>();
const addKey = (value?: string | null) => {
const normalized = normalizeLayerTransferKey(String(value || ""));
if (normalized) {
keys.add(normalized);
}
};
addKey(layer.id);
addKey(layer.layerId);
addKey(layer.layerPath);
addKey(layer.layerName);
if (isAgentPsdImageLayerSlot(layer)) {
addKey(`image:${layer.layerPath}`);
addKey(`image:${layer.layerName}`);
addKey(`image:${layer.layerPath}:${layer.width}x${layer.height}`);
addKey(`image:${layer.layerName}:${layer.width}x${layer.height}`);
if (layer.isDefaultResultSource) {
addKey("__default_result_source__");
}
}
if (isAgentPsdTextLayerSlot(layer)) {
addKey(`text:${layer.layerPath}`);
addKey(`text:${layer.layerName}`);
addKey(`text:${layer.layerPath}:${layer.width}x${layer.height}`);
addKey(`text:${layer.layerName}:${layer.width}x${layer.height}`);
}
return Array.from(keys);
}
function findMatchingLayer<TLayer extends AgentPsdTemplateLayerSlot>(params: {
nextLayer: TLayer;
previousLayers: TLayer[];
usedPreviousLayerIds?: Set<string>;
}) {
const { nextLayer, previousLayers, usedPreviousLayerIds } = params;
const nextLayerKeySet = new Set(getLayerTransferKeys(nextLayer));
for (const previousLayer of previousLayers) {
if (usedPreviousLayerIds?.has(previousLayer.id)) {
continue;
}
const previousKeys = getLayerTransferKeys(previousLayer);
if (previousKeys.some((key) => nextLayerKeySet.has(key))) {
return previousLayer;
}
}
return null;
}
function resolveTransferredSelectedLayerId(params: {
nextTemplate: AgentPsdTemplate | null;
previousTemplate: AgentPsdTemplate | null;
previousSelectedLayerId: string;
}) {
const { nextTemplate, previousTemplate, previousSelectedLayerId } = params;
if (!nextTemplate) {
return "";
}
if (!previousTemplate || !previousSelectedLayerId) {
return getPreferredSelectedLayerId(nextTemplate);
}
const previousLayer =
previousTemplate.layers.find(
(layer) => layer.id === previousSelectedLayerId
) || null;
if (!previousLayer) {
return getPreferredSelectedLayerId(nextTemplate);
}
const nextCandidateLayers = nextTemplate.layers.filter(
(layer) =>
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
((isAgentPsdImageLayerSlot(previousLayer) &&
isAgentPsdImageLayerSlot(layer)) ||
(isAgentPsdTextLayerSlot(previousLayer) &&
isAgentPsdTextLayerSlot(layer)))
);
const previousLayerKeySet = new Set(getLayerTransferKeys(previousLayer));
const matchedLayer = nextCandidateLayers.find((layer) =>
getLayerTransferKeys(layer).some((key) => previousLayerKeySet.has(key))
);
return matchedLayer?.id || getPreferredSelectedLayerId(nextTemplate);
}
function createTemplateEditorState(params: {
nextTemplate: AgentPsdTemplate | null;
previousTemplate: AgentPsdTemplate | null;
sourceImageUrl: string;
previousImageAssignments: Record<string, string>;
previousImagePlacements: Record<string, AgentPsdImagePlacement>;
previousLayerPositionOffsets: Record<string, AgentPsdLayerPositionOffset>;
previousTextAssignments: Record<string, string>;
previousTextColorAssignments: Record<string, string>;
previousTextPositionOffsets: Record<string, AgentPsdTextPositionOffset>;
}) {
const {
nextTemplate,
previousTemplate,
sourceImageUrl,
previousImageAssignments,
previousImagePlacements,
previousLayerPositionOffsets,
previousTextAssignments,
previousTextColorAssignments,
previousTextPositionOffsets,
} = params;
const initialState = createInitialState(nextTemplate, sourceImageUrl);
const nextState = {
imageAssignments: { ...initialState.imageAssignments },
imagePlacements: { ...initialState.imagePlacements },
layerOrderIds: [...initialState.layerOrderIds],
layerPositionOffsets: { ...initialState.layerPositionOffsets },
textAssignments: { ...initialState.textAssignments },
textColorAssignments: {} as Record<string, string>,
textPositionOffsets: { ...initialState.textPositionOffsets },
};
if (!nextTemplate || !previousTemplate) {
return nextState;
}
const usedPreviousLayerIds = new Set<string>();
for (const nextLayer of nextTemplate.layers) {
if (isAgentPsdLayerSuperLocked(nextLayer)) {
continue;
}
const matchedPreviousLayer = findMatchingLayer({
nextLayer,
previousLayers: previousTemplate.layers,
usedPreviousLayerIds,
});
if (!matchedPreviousLayer) {
continue;
}
const previousLayerPositionOffset =
previousLayerPositionOffsets[matchedPreviousLayer.id];
if (isAgentPsdLayerSuperLocked(matchedPreviousLayer)) {
usedPreviousLayerIds.add(matchedPreviousLayer.id);
continue;
}
if (hasLayerPositionOffset(previousLayerPositionOffset)) {
nextState.layerPositionOffsets[nextLayer.id] =
normalizeLayerPositionOffset(previousLayerPositionOffset);
}
usedPreviousLayerIds.add(matchedPreviousLayer.id);
}
const previousEditableImageLayers = previousTemplate.layers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdImageLayerSlot(layer)
);
const nextEditableImageLayers = nextTemplate.layers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdImageLayerSlot(layer)
);
const usedPreviousImageLayerIds = new Set<string>();
const usedNextImageLayerIds = new Set<string>();
for (const nextLayer of nextEditableImageLayers) {
const matchedPreviousLayer = findMatchingLayer({
nextLayer,
previousLayers: previousEditableImageLayers,
usedPreviousLayerIds: usedPreviousImageLayerIds,
});
if (!matchedPreviousLayer) {
continue;
}
const previousImageUrl = String(
previousImageAssignments[matchedPreviousLayer.id] || ""
).trim();
if (!previousImageUrl) {
continue;
}
nextState.imageAssignments[nextLayer.id] = previousImageUrl;
nextState.imagePlacements[nextLayer.id] = normalizeAgentPsdImagePlacement(
previousImagePlacements[matchedPreviousLayer.id]
);
usedPreviousImageLayerIds.add(matchedPreviousLayer.id);
usedNextImageLayerIds.add(nextLayer.id);
}
const unmatchedPreviousImageLayers = previousEditableImageLayers.filter(
(layer) =>
!usedPreviousImageLayerIds.has(layer.id) &&
Boolean(String(previousImageAssignments[layer.id] || "").trim())
);
if (unmatchedPreviousImageLayers.length === 1) {
const fallbackNextLayer =
nextEditableImageLayers.find(
(layer) =>
!usedNextImageLayerIds.has(layer.id) && layer.isDefaultResultSource
) ||
nextEditableImageLayers.find(
(layer) => !usedNextImageLayerIds.has(layer.id)
);
if (fallbackNextLayer) {
const fallbackPreviousLayer = unmatchedPreviousImageLayers[0];
nextState.imageAssignments[fallbackNextLayer.id] = String(
previousImageAssignments[fallbackPreviousLayer.id] || ""
).trim();
nextState.imagePlacements[fallbackNextLayer.id] =
normalizeAgentPsdImagePlacement(
previousImagePlacements[fallbackPreviousLayer.id]
);
}
}
const previousEditableTextLayers = previousTemplate.layers.filter(
(layer): layer is AgentPsdTemplateTextLayerSlot =>
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdTextLayerSlot(layer)
);
const nextEditableTextLayers = nextTemplate.layers.filter(
(layer): layer is AgentPsdTemplateTextLayerSlot =>
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdTextLayerSlot(layer)
);
const usedPreviousTextLayerIds = new Set<string>();
for (const nextLayer of nextEditableTextLayers) {
const matchedPreviousLayer = findMatchingLayer({
nextLayer,
previousLayers: previousEditableTextLayers,
usedPreviousLayerIds: usedPreviousTextLayerIds,
});
if (!matchedPreviousLayer) {
continue;
}
const previousTextValue = previousTextAssignments[matchedPreviousLayer.id];
if (typeof previousTextValue === "string") {
nextState.textAssignments[nextLayer.id] = previousTextValue;
}
const previousTextColor = String(
previousTextColorAssignments[matchedPreviousLayer.id] || ""
).trim();
if (previousTextColor) {
nextState.textColorAssignments[nextLayer.id] = previousTextColor;
}
const previousTextPositionOffset =
previousTextPositionOffsets[matchedPreviousLayer.id];
if (hasTextPositionOffset(previousTextPositionOffset)) {
nextState.textPositionOffsets[nextLayer.id] = normalizeTextPositionOffset(
previousTextPositionOffset
);
}
usedPreviousTextLayerIds.add(matchedPreviousLayer.id);
}
return nextState;
}
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 && !isAgentPsdLayerSuperLocked(layer)
);
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,
positionOffset?: Partial<AgentPsdLayerPositionOffset> | null
): CSSProperties {
const normalizedOffset = normalizeLayerPositionOffset(positionOffset);
return {
left: layer.x + normalizedOffset.offsetX,
top: layer.y + normalizedOffset.offsetY,
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 typeof value === "string"
? value
: layer.placeholder || layer.layerName;
}
function hasRenderableTextLayerPreview(
layer: AgentPsdTemplateTextLayerSlot,
value?: string
) {
return Boolean(
String(value || "").trim() || String(layer.placeholder || "").trim()
);
}
function getResolvedTextLayerColor(
layer: AgentPsdTemplateTextLayerSlot,
value?: string
) {
const fallbackColor = normalizeHexColor(layer.textColor, "#111111");
return normalizeHexColor(value || fallbackColor, fallbackColor);
}
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 textAlign = resolveAgentPsdTextLayerEffectiveAlign(layer);
const offsetX =
textAlign === "right"
? layer.width - width
: textAlign === "center"
? (layer.width - width) / 2
: 0;
return {
...resolvedLayout,
width,
height,
offsetX,
offsetY: -resolvedLayout.visualOffsetY,
};
}
function renderTextLayerPreviewContent(
layer: AgentPsdTemplateTextLayerSlot,
value: string,
color: string
) {
const resolvedText = getResolvedTextPreview(layer, value);
const resolvedLayout = getTextLayerRenderFrame(layer, resolvedText);
const textAlign = resolveAgentPsdTextLayerEffectiveAlign(layer);
const textAnchor =
textAlign === "center" ? "middle" : textAlign === "right" ? "end" : "start";
const textX =
textAlign === "center"
? resolvedLayout.width / 2
: textAlign === "right"
? resolvedLayout.width - layer.paddingX
: layer.paddingX;
return (
<svg
className="block overflow-visible"
height={resolvedLayout.height}
viewBox={`0 0 ${resolvedLayout.width} ${resolvedLayout.height}`}
width={resolvedLayout.width}
style={{
width: resolvedLayout.width,
height: resolvedLayout.height,
}}
xmlns="http://www.w3.org/2000/svg"
>
<text
fill={color}
fontSize={resolvedLayout.fontSize}
fontWeight={layer.fontWeight}
letterSpacing={layer.letterSpacing}
style={{
fontFamily: buildTextLayerFontFamily(layer),
textRendering: "geometricPrecision",
}}
textAnchor={textAnchor}
xmlSpace="preserve"
>
{resolvedLayout.lines.map((line, index) => (
<tspan
key={`${layer.id}-preview-line-${index + 1}`}
x={textX}
y={
layer.paddingY +
resolvedLayout.fontSize +
index * resolvedLayout.lineHeightPx
}
>
{line || "\u00a0"}
</tspan>
))}
</text>
<foreignObject height="0" width="0" x="0" y="0">
<div
style={{
fontFamily: buildTextLayerFontFamily(layer),
fontSize: 0,
}}
>
&nbsp;
</div>
</foreignObject>
</svg>
);
}
function getAssignedImageMaskStyle(
layer: AgentPsdTemplateImageLayerSlot,
frameLayer: AgentPsdTemplateLayerSlot,
layers: AgentPsdTemplateLayerSlot[]
): CSSProperties {
const clipMaskLayer = layer.clipMaskLayerId
? layers.find((item) => item.id === layer.clipMaskLayerId)
: null;
const fallbackMaskUrl =
layer.forceClipMask && clipMaskLayer
? getPsdLayerPreviewSourceUrl(clipMaskLayer)
: "";
const normalizedMaskUrl = String(
layer.maskUrl || fallbackMaskUrl || ""
).trim();
const hasMask = Boolean(normalizedMaskUrl);
const maskImageUrl = hasMask
? `url(${buildPsdLayerMaskUrl(normalizedMaskUrl, frameLayer.width)})`
: undefined;
return {
WebkitMaskImage: maskImageUrl,
maskImage: maskImageUrl,
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,
maskMode: hasMask ? "alpha" : undefined,
};
}
function getPlacedAssignedImageStyle(params: {
layer: AgentPsdTemplateImageLayerSlot;
frameLayer?: AgentPsdTemplateLayerSlot | null;
imageSize: AgentPsdImageSize;
placement: AgentPsdImagePlacement;
}): CSSProperties {
const { layer, imageSize, placement } = params;
const frameLayer = params.frameLayer || layer;
const normalizedPlacement = normalizeAgentPsdImagePlacement(placement);
const frameContentBounds =
isAgentPsdImageLayerSlot(frameLayer) ? frameLayer.contentBounds : undefined;
const baseFrame = resolveAgentPsdImageBaseFrame({
containerWidth: frameLayer.width,
containerHeight: frameLayer.height,
imageWidth: imageSize.width,
imageHeight: imageSize.height,
replaceMode: layer.replaceMode,
contentBounds: frameContentBounds,
});
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 getAssignedImageFrameLayer(
layer: AgentPsdTemplateImageLayerSlot,
layers: AgentPsdTemplateLayerSlot[]
) {
if (!layer.forceClipMask || !layer.clipMaskLayerId) {
return layer;
}
return layers.find((item) => item.id === layer.clipMaskLayerId) || 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,
positionOffset?: Partial<AgentPsdTextPositionOffset> | null
) {
return (
hasEditedTextValue(layer, value) ||
getResolvedTextLayerColor(layer, color) !==
getResolvedTextLayerColor(layer) ||
hasTextPositionOffset(positionOffset)
);
}
export function AgentPsdTemplateEditorDialog({
open,
onOpenChange,
sourceImageUrl,
brandName,
selectedCategoryNames,
sizeInfoText,
templateMode,
onDerivedImageCreated,
onRenderedResultCreated,
standalone = false,
onBack,
pendingImages,
activePendingImageId,
onActivePendingImageIdChange,
pendingRenderedResultUrls,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceImageUrl: string | null;
brandName?: string | null;
selectedCategoryNames?: string[] | null;
sizeInfoText?: string | null;
templateMode?: AgentPsdTemplateMode | null;
onDerivedImageCreated?: (imageUrl: string) => void;
onRenderedResultCreated?: (imageUrl: string) => void;
standalone?: boolean;
onBack?: () => void;
pendingImages?: CanvasPsdTemplatePendingImage[] | null;
activePendingImageId?: string | null;
onActivePendingImageIdChange?: (imageId: string) => void;
pendingRenderedResultUrls?: Record<string, string | undefined> | null;
}) {
const normalizedBrandName = String(brandName || "").trim();
const normalizedSelectedCategoryNames = useMemo(
() =>
normalizeAgentPsdTemplateCategoryTags(selectedCategoryNames).filter(
(tag) => tag !== AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG
),
[selectedCategoryNames]
);
const normalizedTemplateMode =
templateMode === "sku" ? "sku" : templateMode === "main" ? "main" : "";
const normalizedPendingImages = useMemo(
() =>
Array.isArray(pendingImages)
? pendingImages.filter(
(item) =>
typeof item?.id === "string" &&
item.id.trim().length > 0 &&
typeof item?.imageUrl === "string" &&
item.imageUrl.trim().length > 0
)
: [],
[pendingImages]
);
const templateListPrefetchUrl = useMemo(
() =>
buildTemplateListRequestUrl({
brandName: normalizedBrandName,
summaryOnly: true,
}),
[normalizedBrandName]
);
const templateListRequestUrl = useMemo(() => {
if (!open) {
return null;
}
return templateListPrefetchUrl;
}, [open, templateListPrefetchUrl]);
const { data, isLoading } = useSWR<AgentPsdTemplateListResponse>(
templateListRequestUrl,
fetcher,
{
dedupingInterval: 30_000,
keepPreviousData: true,
revalidateOnFocus: false,
}
);
const allTemplates = useMemo(
() => (data?.success ? data.templates : []),
[data]
);
const rawTextColorConfig = useMemo(
() => normalizeAgentPsdTextColorConfig(data?.textColorConfig),
[data?.textColorConfig]
);
const secondaryCategoryConfig = useMemo(
() =>
normalizeAgentPsdTemplateSecondaryCategoryConfig(
data?.secondaryCategoryConfig
),
[data?.secondaryCategoryConfig]
);
const categoryTagConfig = useMemo(
() => normalizeAgentPsdTemplateCategoryTagConfig(data?.categoryTagConfig),
[data?.categoryTagConfig]
);
const activeCategoryTags = useMemo(
() => categoryTagConfig.tags,
[categoryTagConfig.tags]
);
const templates = useMemo(
() =>
allTemplates
.filter((template) => matchesTemplateBrand(template, normalizedBrandName))
.filter((template) =>
matchesTemplateSelectedCategories(
template,
normalizedSelectedCategoryNames,
activeCategoryTags
)
),
[
activeCategoryTags,
allTemplates,
normalizedBrandName,
normalizedSelectedCategoryNames,
]
);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const selectedTemplateDetailRequestUrl = useMemo(() => {
if (!open || !selectedTemplateId) {
return null;
}
return `/api/psd-templates/${encodeURIComponent(selectedTemplateId)}`;
}, [open, selectedTemplateId]);
const {
data: selectedTemplateDetailData,
isLoading: isSelectedTemplateDetailLoading,
} = useSWR<AgentPsdTemplateMutationResponse>(
selectedTemplateDetailRequestUrl,
templateDetailFetcher,
{
dedupingInterval: 60_000,
keepPreviousData: true,
revalidateOnFocus: false,
}
);
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 [layerOrderIds, setLayerOrderIds] = useState<string[]>([]);
const [layerPositionOffsets, setLayerPositionOffsets] = useState<
Record<string, AgentPsdLayerPositionOffset>
>({});
const [textAssignments, setTextAssignments] = useState<
Record<string, string>
>({});
const [textColorAssignments, setTextColorAssignments] = useState<
Record<string, string>
>({});
const [textPositionOffsets, setTextPositionOffsets] = useState<
Record<string, AgentPsdTextPositionOffset>
>({});
const [hiddenLayerIds, setHiddenLayerIds] = useState<string[]>([]);
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
const [extendingSlotId, setExtendingSlotId] = 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 [canScrollHiddenLayersUp, setCanScrollHiddenLayersUp] = useState(false);
const [canScrollHiddenLayersDown, setCanScrollHiddenLayersDown] =
useState(false);
const previewViewportRef = useRef<HTMLDivElement | null>(null);
const hiddenLayerScrollAreaRef = useRef<HTMLDivElement | null>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const previousSelectedTemplateRef = useRef<AgentPsdTemplate | null>(null);
const layerDragStateRef = useRef<{
layerId: string;
pointerId: number;
startClientX: number;
startClientY: number;
startOffset: AgentPsdLayerPositionOffset;
} | null>(null);
const textDragStateRef = useRef<{
layerId: string;
pointerId: number;
startClientX: number;
startClientY: number;
startOffset: AgentPsdTextPositionOffset;
} | null>(null);
const imageAssignmentsRef = useRef(imageAssignments);
const imagePlacementsRef = useRef(imagePlacements);
const layerPositionOffsetsRef = useRef(layerPositionOffsets);
const textAssignmentsRef = useRef(textAssignments);
const textColorAssignmentsRef = useRef(textColorAssignments);
const textPositionOffsetsRef = useRef(textPositionOffsets);
const archivedRenderImageUrlCacheRef = useRef<Record<string, string>>({});
const archivedRenderImageUrlPromiseCacheRef = useRef<
Record<string, Promise<string>>
>({});
const selectedLayerIdRef = useRef(selectedLayerId);
imageAssignmentsRef.current = imageAssignments;
imagePlacementsRef.current = imagePlacements;
layerPositionOffsetsRef.current = layerPositionOffsets;
textAssignmentsRef.current = textAssignments;
textColorAssignmentsRef.current = textColorAssignments;
textPositionOffsetsRef.current = textPositionOffsets;
selectedLayerIdRef.current = selectedLayerId;
useEffect(() => {
if (!open) {
archivedRenderImageUrlCacheRef.current = {};
archivedRenderImageUrlPromiseCacheRef.current = {};
}
}, [open]);
useEffect(() => {
if (!open || typeof document === "undefined") {
return;
}
const html = document.documentElement;
const body = document.body;
const previousHtmlOverflow = html.style.overflow;
const previousBodyOverflow = body.style.overflow;
const previousHtmlOverscrollBehavior = html.style.overscrollBehavior;
const previousBodyOverscrollBehavior = body.style.overscrollBehavior;
const previousHtmlBackgroundColor = html.style.backgroundColor;
const previousBodyBackgroundColor = body.style.backgroundColor;
const previousBodyPaddingRight = body.style.paddingRight;
const scrollbarWidth = Math.max(0, window.innerWidth - html.clientWidth);
const currentBodyPaddingRight = Number.parseFloat(
window.getComputedStyle(body).paddingRight || "0"
);
html.style.overflow = "hidden";
body.style.overflow = "hidden";
html.style.overscrollBehavior = "none";
body.style.overscrollBehavior = "none";
html.style.backgroundColor = "#050505";
body.style.backgroundColor = "#050505";
if (scrollbarWidth > 0) {
body.style.paddingRight = `${currentBodyPaddingRight + scrollbarWidth}px`;
}
return () => {
html.style.overflow = previousHtmlOverflow;
body.style.overflow = previousBodyOverflow;
html.style.overscrollBehavior = previousHtmlOverscrollBehavior;
body.style.overscrollBehavior = previousBodyOverscrollBehavior;
html.style.backgroundColor = previousHtmlBackgroundColor;
body.style.backgroundColor = previousBodyBackgroundColor;
body.style.paddingRight = previousBodyPaddingRight;
};
}, [open]);
const selectedTemplateSummary = useMemo(
() =>
templates.find((template) => template.id === selectedTemplateId) || null,
[selectedTemplateId, templates]
);
const selectedTemplateDetail = useMemo(() => {
if (
!selectedTemplateDetailData?.success ||
!selectedTemplateDetailData.template
) {
return null;
}
return selectedTemplateDetailData.template.id === selectedTemplateId
? selectedTemplateDetailData.template
: null;
}, [selectedTemplateDetailData, selectedTemplateId]);
const selectedTemplate = useMemo(
() => selectedTemplateDetail || selectedTemplateSummary,
[selectedTemplateDetail, selectedTemplateSummary]
);
const selectedTemplateDetailError =
selectedTemplateDetailData?.success === false
? String(selectedTemplateDetailData.error || "").trim()
: "";
const isSelectedTemplateLoading =
Boolean(selectedTemplateSummary) &&
(selectedTemplateSummary?.layers.length || 0) === 0 &&
isSelectedTemplateDetailLoading;
const effectiveTemplateBrandName = useMemo(
() =>
normalizedBrandName || String(selectedTemplate?.brandName || "").trim(),
[normalizedBrandName, selectedTemplate?.brandName]
);
const textColorConfig = useMemo(
() =>
resolveAgentPsdTextColorConfigForBrand(
rawTextColorConfig,
effectiveTemplateBrandName
),
[effectiveTemplateBrandName, rawTextColorConfig]
);
const shouldShowTemplateBrand = useMemo(
() =>
!normalizedBrandName &&
templates.some((template) =>
Boolean(normalizeManagedBrandName(template.brandName))
),
[normalizedBrandName, templates]
);
const selectedCategorySummaryText = useMemo(
() => normalizedSelectedCategoryNames.join(" / "),
[normalizedSelectedCategoryNames]
);
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 selectedTemplateLayerById = useMemo(
() => new Map(selectedTemplateLayers.map((layer) => [layer.id, layer])),
[selectedTemplateLayers]
);
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 preferredInitialTemplate = useMemo(() => {
if (normalizedTemplateMode === "sku" && skuModeTemplates.length > 0) {
return skuModeTemplates[0] || null;
}
if (normalizedTemplateMode === "main" && mainModeTemplates.length > 0) {
return mainModeTemplates[0] || null;
}
if (mainModeTemplates.length > 0) {
return mainModeTemplates[0] || null;
}
if (skuModeTemplates.length > 0) {
return skuModeTemplates[0] || null;
}
return templates[0] || null;
}, [mainModeTemplates, normalizedTemplateMode, skuModeTemplates, templates]);
const editableImageLayers = useMemo(
() =>
selectedTemplateLayers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdImageLayerSlot(layer)
),
[selectedTemplateLayers]
);
const editableTextLayers = useMemo(
() =>
selectedTemplateLayers.filter(
(layer): layer is AgentPsdTemplateTextLayerSlot =>
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdTextLayerSlot(layer)
),
[selectedTemplateLayers]
);
const orderedTemplateLayers = useMemo(() => {
if (selectedTemplateLayers.length === 0) {
return [] as AgentPsdTemplateLayerSlot[];
}
const layerMap = new Map(
selectedTemplateLayers.map((layer) => [layer.id, layer])
);
const normalizedOrder = [
...layerOrderIds
.map((layerId) => layerMap.get(layerId) || null)
.filter((layer): layer is AgentPsdTemplateLayerSlot => Boolean(layer)),
...selectedTemplateLayers.filter((layer) => !layerOrderIds.includes(layer.id)),
];
return normalizedOrder;
}, [layerOrderIds, selectedTemplateLayers]);
const orderedPreviewLayers = useMemo(
() => [...orderedTemplateLayers].reverse(),
[orderedTemplateLayers]
);
const hiddenLayerIdSet = useMemo(
() =>
new Set(
hiddenLayerIds
.map((layerId) => String(layerId || "").trim())
.filter(Boolean)
),
[hiddenLayerIds]
);
useEffect(() => {
const viewport = getScrollAreaViewportElement(
hiddenLayerScrollAreaRef.current
);
if (!viewport) {
setCanScrollHiddenLayersUp(false);
setCanScrollHiddenLayersDown(false);
return;
}
const updateScrollButtons = () => {
const nextCanScrollUp = viewport.scrollTop > 4;
const nextCanScrollDown =
viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight - 4;
setCanScrollHiddenLayersUp(nextCanScrollUp);
setCanScrollHiddenLayersDown(nextCanScrollDown);
};
updateScrollButtons();
viewport.addEventListener("scroll", updateScrollButtons, { passive: true });
const animationFrameId = window.requestAnimationFrame(updateScrollButtons);
return () => {
window.cancelAnimationFrame(animationFrameId);
viewport.removeEventListener("scroll", updateScrollButtons);
};
}, [
hiddenLayerIds.length,
open,
orderedPreviewLayers.length,
selectedTemplate?.id,
]);
const orderedVisiblePreviewLayers = useMemo(
() =>
orderedPreviewLayers.filter((layer) => !hiddenLayerIdSet.has(layer.id)),
[hiddenLayerIdSet, orderedPreviewLayers]
);
const orderedEditableLayers = useMemo(
() =>
orderedPreviewLayers.filter(
(layer) => layer.isEditable && !isAgentPsdLayerSuperLocked(layer)
),
[orderedPreviewLayers]
);
const orderedUserManageablePreviewLayers = useMemo(
() =>
orderedPreviewLayers.filter((layer) => !isAgentPsdLayerSuperLocked(layer)),
[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 layerStackZIndexById = useMemo(() => {
return new Map(
orderedPreviewLayers.map((layer, index) => [
layer.id,
(index + 1) * PSD_LAYER_Z_INDEX_STEP,
])
);
}, [orderedPreviewLayers]);
const renderBaseZIndex = useMemo(() => {
const defaultSourceImageLayer = getDefaultSourceImageLayer(
selectedTemplateLayers
);
const defaultSourceImageZIndex = defaultSourceImageLayer
? layerStackZIndexById.get(defaultSourceImageLayer.id) || 0
: 0;
return defaultSourceImageZIndex + PSD_RENDER_BASE_Z_INDEX_OFFSET;
}, [layerStackZIndexById, selectedTemplateLayers]);
const shouldShowCompositeFallback = useMemo(
() =>
orderedVisiblePreviewLayers.length === 0 ||
orderedVisiblePreviewLayers.some(
(layer) => !getPsdLayerPreviewSourceUrl(layer)
),
[orderedVisiblePreviewLayers]
);
const effectiveImageAssignments = useMemo(() => {
const nextAssignments: Record<string, string> = {};
if (!selectedTemplate) {
return nextAssignments;
}
const defaultSourceImageLayerId = sourceImageUrl?.trim()
? getDefaultSourceImageLayer(editableImageLayers)?.id || ""
: "";
for (const layer of editableImageLayers) {
const explicit = String(imageAssignments[layer.id] || "").trim();
if (explicit) {
nextAssignments[layer.id] = explicit;
continue;
}
if (layer.id === defaultSourceImageLayerId && 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 effectiveTextPositionOffsets = useMemo(() => {
const nextOffsets: Record<string, AgentPsdTextPositionOffset> = {};
if (!selectedTemplate) {
return nextOffsets;
}
for (const layer of editableTextLayers) {
nextOffsets[layer.id] = normalizeTextPositionOffset(
textPositionOffsets[layer.id]
);
}
return nextOffsets;
}, [editableTextLayers, selectedTemplate, textPositionOffsets]);
const effectiveLayerPositionOffsets = useMemo(() => {
const nextOffsets: Record<string, AgentPsdLayerPositionOffset> = {};
if (!selectedTemplate) {
return nextOffsets;
}
for (const layer of selectedTemplateLayers) {
if (isAgentPsdLayerSuperLocked(layer)) {
continue;
}
nextOffsets[layer.id] = normalizeLayerPositionOffset(
layerPositionOffsets[layer.id]
);
}
return nextOffsets;
}, [layerPositionOffsets, selectedTemplate, selectedTemplateLayers]);
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(() => {
const layer =
selectedTemplateLayers.find((entry) => entry.id === selectedLayerId) ||
null;
return layer && !isAgentPsdLayerSuperLocked(layer) ? layer : null;
}, [selectedLayerId, selectedTemplateLayers]);
const isSelectedLayerHidden = Boolean(
selectedLayer && hiddenLayerIdSet.has(selectedLayer.id)
);
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 selectedTextPositionOffset = selectedTextLayer
? effectiveTextPositionOffsets[selectedTextLayer.id] ||
normalizeTextPositionOffset()
: normalizeTextPositionOffset();
const isSelectedImageExtending = Boolean(
selectedImageLayer && extendingSlotId === selectedImageLayer.id
);
function hideLayerTemporarily(layerId: string) {
const normalizedLayerId = String(layerId || "").trim();
if (!normalizedLayerId) {
return;
}
const layer = selectedTemplateLayerById.get(normalizedLayerId);
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
return;
}
setHiddenLayerIds((current) =>
current.includes(normalizedLayerId)
? current
: [...current, normalizedLayerId]
);
}
function restoreHiddenLayer(layerId: string) {
const normalizedLayerId = String(layerId || "").trim();
if (!normalizedLayerId) {
return;
}
const layer = selectedTemplateLayerById.get(normalizedLayerId);
if (layer && isAgentPsdLayerSuperLocked(layer)) {
return;
}
setHiddenLayerIds((current) =>
current.filter((currentLayerId) => currentLayerId !== normalizedLayerId)
);
}
const selectedLayerPositionPanel = selectedLayer ? (
<div className="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5 shadow-inner">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-semibold text-sm text-white">图层层级</div>
<div className="mt-1.5 text-white/50 text-xs">
选中图层后可调整与其他图层的上下关系。
</div>
</div>
<Move className="h-5 w-5 shrink-0 text-emerald-300" />
</div>
<div className="grid grid-cols-2 gap-2">
<Button
className="h-10 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.04] text-white/75 hover:bg-white/[0.08] hover:text-white"
onClick={() => moveLayerOrder(selectedLayer.id, "forward")}
type="button"
variant="outline"
>
<ChevronUp className="h-4 w-4" />
上移一层
</Button>
<Button
className="h-10 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.04] text-white/75 hover:bg-white/[0.08] hover:text-white"
onClick={() => moveLayerOrder(selectedLayer.id, "backward")}
type="button"
variant="outline"
>
<ChevronDown className="h-4 w-4" />
下移一层
</Button>
<Button
className="h-10 justify-center rounded-xl border-white/10 bg-white/[0.04] text-white/75 hover:bg-white/[0.08] hover:text-white"
onClick={() => moveLayerOrder(selectedLayer.id, "front")}
type="button"
variant="outline"
>
置于最上
</Button>
<Button
className="h-10 justify-center rounded-xl border-white/10 bg-white/[0.04] text-white/75 hover:bg-white/[0.08] hover:text-white"
onClick={() => moveLayerOrder(selectedLayer.id, "back")}
type="button"
variant="outline"
>
置于最下
</Button>
</div>
</div>
) : null;
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-2 gap-2">
{sourceImageUrl ? (
<Button
className="h-11 w-full min-w-0 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.02] px-2.5 text-xs text-white/80 transition-all hover:border-emerald-500/30 hover:bg-emerald-500/10 hover:text-emerald-400"
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 whitespace-nowrap">使用当前图片</span>
</Button>
) : null}
<Button
className="h-11 w-full min-w-0 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.02] px-2.5 text-xs text-white/80 transition-all hover:border-emerald-500/30 hover:bg-emerald-500/10 hover:text-emerald-400 disabled:opacity-50"
disabled={
uploadingSlotId === selectedImageLayer.id ||
isSelectedImageExtending
}
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 whitespace-nowrap">上传替换图片</span>
</Button>
<Button
className={`h-11 w-full min-w-0 justify-center gap-1.5 rounded-xl border-white/10 bg-white/[0.02] px-2.5 text-xs text-white/80 transition-all hover:border-emerald-500/30 hover:bg-emerald-500/10 hover:text-emerald-400 disabled:opacity-50 ${
sourceImageUrl ? "col-span-2" : ""
}`}
disabled={
!selectedImageAssignmentUrl || Boolean(isSelectedImageExtending)
}
onClick={() =>
void handleImageExtend(
selectedImageLayer.id,
selectedImageAssignmentUrl
)
}
size="sm"
type="button"
variant="outline"
>
{isSelectedImageExtending ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
) : (
<Expand className="h-4 w-4 shrink-0" />
)}
<span className="min-w-0 whitespace-nowrap">图片拓展</span>
</Button>
</div>
{selectedImageAssignmentUrl ? (
<>
<div className="grid grid-cols-2 gap-2">
<Button
className="h-10 w-full rounded-lg border-white/10 bg-white/[0.02] px-2.5 text-xs text-white/70 transition-all hover:border-white/20 hover:bg-white/[0.06] hover:text-white"
onClick={() => resetImagePlacement(selectedImageLayer.id)}
size="sm"
type="button"
variant="outline"
>
重置位置
</Button>
<Button
className="h-10 w-full rounded-lg px-2.5 text-xs text-red-400/80 transition-all hover:bg-red-500/10 hover:text-red-400"
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-white/70 text-xs">
<div className="flex items-center gap-2">
<ZoomIn className="h-4 w-4" />
<span className="font-medium">图片缩放</span>
</div>
<span className="rounded-md border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 font-medium font-mono text-[11px] text-emerald-400">
{Math.round(selectedImagePlacement.scale * 100)}%
</span>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.02] p-3 shadow-inner">
<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/10 bg-white/[0.04] text-white/70 transition-colors hover:bg-white/[0.1] hover:text-white"
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-500 to-emerald-400"
step={0.01}
thumbClassName="h-4 w-4 border-2 border-[#1a1a1a] bg-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.3)] hover:scale-110 transition-transform"
trackClassName="h-1.5 rounded-full bg-white/10"
value={[selectedImagePlacement.scale]}
/>
<button
aria-label="放大图片"
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-white/70 transition-colors hover:bg-white/[0.1] hover:text-white"
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="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-4 shadow-inner">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-semibold text-sm text-white">
槽位内图片位置
</div>
<div className="mt-1.5 text-white/50 text-xs">
图层整体拖动已经改到中间预览区;这里保留槽位内图片的细调。
</div>
</div>
<ImageIcon className="h-5 w-5 shrink-0 text-orange-300" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-white/50 text-xs">水平 X</Label>
<Input
className="h-10 rounded-xl border-white/10 bg-black/20 text-white"
inputMode="numeric"
onChange={(event) => {
const nextValue = Number(event.target.value);
if (!Number.isFinite(nextValue)) return;
updateImagePlacement(selectedImageLayer.id, (current) => ({
...current,
offsetX: nextValue,
}));
}}
type="number"
value={Math.round(selectedImagePlacement.offsetX)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-white/50 text-xs">垂直 Y</Label>
<Input
className="h-10 rounded-xl border-white/10 bg-black/20 text-white"
inputMode="numeric"
onChange={(event) => {
const nextValue = Number(event.target.value);
if (!Number.isFinite(nextValue)) return;
updateImagePlacement(selectedImageLayer.id, (current) => ({
...current,
offsetY: nextValue,
}));
}}
type="number"
value={Math.round(selectedImagePlacement.offsetY)}
/>
</div>
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<div />
<Button
className="h-9 w-12 justify-self-center rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
nudgeImagePlacementOffset(selectedImageLayer.id, 0, -1)
}
type="button"
variant="outline"
>
↑
</Button>
<div />
<Button
className="h-9 w-12 justify-self-center rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
nudgeImagePlacementOffset(selectedImageLayer.id, -1, 0)
}
type="button"
variant="outline"
>
←
</Button>
<Button
className="h-9 justify-self-center rounded-xl border-white/10 bg-white/[0.04] px-3 text-white/60 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
updateImagePlacement(selectedImageLayer.id, (current) => ({
...current,
offsetX: 0,
offsetY: 0,
}))
}
type="button"
variant="outline"
>
归零
</Button>
<Button
className="h-9 w-12 justify-self-center rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
nudgeImagePlacementOffset(selectedImageLayer.id, 1, 0)
}
type="button"
variant="outline"
>
→
</Button>
<div />
<Button
className="h-9 w-12 justify-self-center rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
nudgeImagePlacementOffset(selectedImageLayer.id, 0, 1)
}
type="button"
variant="outline"
>
↓
</Button>
<div />
</div>
</div>
<div className="flex justify-end">
<Button
className="rounded-lg px-3 text-white/60 transition-all hover:bg-white/5 hover:text-white/90"
onClick={() => resetImagePlacement(selectedImageLayer.id)}
size="sm"
type="button"
variant="ghost"
>
重置位置与缩放
</Button>
</div>
</>
) : null}
</div>
) : null;
const textLayerQuickColorPanel =
editableTextLayers.length > 0 ? (
<div className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.03] p-4 shadow-inner">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 font-semibold text-sm text-white">
<Type className="h-4 w-4 text-emerald-300" />
<span>文字颜色快捷编辑</span>
</div>
<p className="mt-1 text-[11px] leading-5 text-white/45">
这里会显示当前模板里所有可编辑文字,直接改色;点击文字名称可进入位置微调。
</p>
</div>
<span className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-white/50">
{editableTextLayers.length} 个文字
</span>
</div>
<div className="max-h-[240px] space-y-2 overflow-y-auto pr-1">
{editableTextLayers.map((layer) => {
const currentColor = getResolvedTextLayerColor(
layer,
effectiveTextColorAssignments[layer.id] || layer.textColor
);
const previewText = getResolvedTextPreview(
layer,
effectiveTextAssignments[layer.id]
);
const isActive = selectedLayerId === layer.id;
return (
<div
className={`grid grid-cols-[minmax(0,1fr)_44px_auto] items-center gap-2 rounded-xl border px-3 py-2.5 transition-all ${
isActive
? "border-emerald-500/45 bg-emerald-500/10"
: "border-white/[0.08] bg-black/15 hover:border-white/15 hover:bg-white/[0.03]"
}`}
key={`quick-text-color-${layer.id}`}
>
<button
className="min-w-0 text-left"
onClick={() => setSelectedLayerId(layer.id)}
title="选中后可编辑文字位置"
type="button"
>
<div className="truncate font-medium text-[12px] text-white/85">
{layer.layerName || layer.placeholder || "文字图层"}
</div>
<div className="mt-0.5 truncate text-[11px] text-white/38">
{previewText}
</div>
</button>
<input
aria-label={`${layer.layerName || layer.placeholder || "文字图层"}颜色`}
className="h-9 w-10 cursor-pointer rounded-lg border border-white/15 bg-transparent p-1"
onChange={(event) =>
updateTextColorAssignment(layer.id, event.target.value)
}
type="color"
value={currentColor}
/>
<button
className="rounded-lg px-2 py-1 text-[11px] text-white/45 transition-colors hover:bg-white/[0.06] hover:text-white/80"
onClick={() =>
updateTextColorAssignment(layer.id, layer.textColor)
}
type="button"
>
原色
</button>
</div>
);
})}
</div>
</div>
) : null;
const fontFaceCss = useMemo(
() => buildPreviewFontFaces(selectedTemplate),
[selectedTemplate]
);
const selectedTemplateSelectValue = selectedTemplate?.id;
const hasTemplateOptions = templates.length > 0;
useEffect(() => {
if (typeof window === "undefined" || !templateListPrefetchUrl) {
return;
}
const preloadTemplates = () => {
void preload(templateListPrefetchUrl, fetcher);
};
if (typeof window.requestIdleCallback === "function") {
const idleId = window.requestIdleCallback(preloadTemplates, {
timeout: 1200,
});
return () => window.cancelIdleCallback?.(idleId);
}
const timeoutId = window.setTimeout(preloadTemplates, 300);
return () => window.clearTimeout(timeoutId);
}, [templateListPrefetchUrl]);
useEffect(() => {
if (!preferredInitialTemplate) {
return;
}
const detailUrl = `/api/psd-templates/${encodeURIComponent(preferredInitialTemplate.id)}`;
void preload(detailUrl, templateDetailFetcher);
}, [preferredInitialTemplate]);
useEffect(() => {
if (open) {
return;
}
setSelectedTemplateId("");
setSelectedLayerId("");
setImageAssignments({});
setImagePlacements({});
setImageSizes({});
setTextAssignments({});
setTextColorAssignments({});
setTextPositionOffsets({});
setHiddenLayerIds([]);
setRenderedResultUrl("");
setRightPanelView("editor");
previousSelectedTemplateRef.current = null;
}, [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 = createTemplateEditorState({
nextTemplate: selectedTemplate,
previousTemplate: previousSelectedTemplateRef.current,
sourceImageUrl: sourceImageUrl || "",
previousImageAssignments: imageAssignmentsRef.current,
previousImagePlacements: imagePlacementsRef.current,
previousLayerPositionOffsets: layerPositionOffsetsRef.current,
previousTextAssignments: textAssignmentsRef.current,
previousTextColorAssignments: textColorAssignmentsRef.current,
previousTextPositionOffsets: textPositionOffsetsRef.current,
});
setImageAssignments(initialState.imageAssignments);
setImagePlacements(initialState.imagePlacements);
setLayerOrderIds(initialState.layerOrderIds);
setLayerPositionOffsets(initialState.layerPositionOffsets);
setTextAssignments(initialState.textAssignments);
setTextColorAssignments(initialState.textColorAssignments);
setTextPositionOffsets(initialState.textPositionOffsets);
setHiddenLayerIds([]);
setSelectedLayerId(
resolveTransferredSelectedLayerId({
nextTemplate: selectedTemplate,
previousTemplate: previousSelectedTemplateRef.current,
previousSelectedLayerId: selectedLayerIdRef.current,
})
);
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, sourceImageUrl]);
useEffect(() => {
if (hiddenLayerIds.length === 0) {
return;
}
const validLayerIds = new Set(
selectedTemplateLayers
.filter((layer) => !isAgentPsdLayerSuperLocked(layer))
.map((layer) => layer.id)
);
setHiddenLayerIds((current) =>
current.filter((layerId) => validLayerIds.has(layerId))
);
}, [hiddenLayerIds.length, selectedTemplateLayers]);
useEffect(() => {
previousSelectedTemplateRef.current = selectedTemplate;
}, [selectedTemplate]);
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]);
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 parseActionJsonResponse<{ url?: string }>(
response,
"素材上传失败",
getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试")
);
if (!payload?.url) {
throw new Error("图片上传失败");
}
const uploadedImageUrl = payload.url;
setImageAssignments((current) => ({
...current,
[slotId]: uploadedImageUrl,
}));
setImagePlacements((current) => ({
...current,
[slotId]: getDefaultAgentPsdImagePlacement(),
}));
setImageSizes((current) => {
const next = { ...current };
delete next[slotId];
return next;
});
toast.success("替换图片已上传");
} catch (error) {
toast.error(
getUserFacingRequestErrorMessage(error, "素材上传失败", {
rateLimitMessage: getFriendlyRateLimitMessage(
"当前上传过于频繁,请稍后再试"
),
})
);
} finally {
setUploadingSlotId(null);
event.target.value = "";
}
}
async function handleImageExtend(slotId: string, imageUrl: string) {
const normalizedImageUrl = String(imageUrl || "").trim();
if (!normalizedImageUrl) {
toast.error("请先选择或上传一张替换图片");
return;
}
try {
setExtendingSlotId(slotId);
const response = await fetch("/api/extend", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_url: normalizedImageUrl,
prompt: "PSD 套版图片拓展处理",
includeBase64: false,
}),
});
const payload = await parseActionJsonResponse<{
original_url?: string;
thumbnail_url?: string;
}>(
response,
"图片拓展失败",
getFriendlyRateLimitMessage("当前图片拓展请求过于频繁,请稍后再试")
);
const nextImageUrl = String(
payload?.original_url || payload?.thumbnail_url || ""
).trim();
if (!nextImageUrl) {
throw new Error("图片拓展成功,但未返回可用图片地址");
}
setImageAssignments((current) => ({
...current,
[slotId]: nextImageUrl,
}));
setImagePlacements((current) => ({
...current,
[slotId]: getDefaultAgentPsdImagePlacement(),
}));
setImageSizes((current) => {
const next = { ...current };
delete next[slotId];
return next;
});
onDerivedImageCreated?.(nextImageUrl);
toast.success(
onDerivedImageCreated
? "图片拓展完成,已替换当前槽位并同步保存到结果区"
: "图片拓展完成,已替换当前槽位"
);
} catch (error) {
toast.error(
getUserFacingRequestErrorMessage(error, "图片拓展失败", {
rateLimitMessage: getFriendlyRateLimitMessage(
"当前图片拓展请求过于频繁,请稍后再试"
),
})
);
} finally {
setExtendingSlotId(null);
}
}
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 nudgeImagePlacementOffset(
layerId: string,
deltaX: number,
deltaY: number
) {
updateImagePlacement(layerId, (current) => ({
...current,
offsetX: Number(((current.offsetX || 0) + deltaX).toFixed(2)),
offsetY: Number(((current.offsetY || 0) + deltaY).toFixed(2)),
}));
}
function resetImagePlacement(layerId: string) {
setImagePlacements((current) => ({
...current,
[layerId]: getDefaultAgentPsdImagePlacement(),
}));
}
function updateLayerPositionOffset(
layerId: string,
updater: (
current: AgentPsdLayerPositionOffset
) => Partial<AgentPsdLayerPositionOffset>
) {
const layer = selectedTemplateLayerById.get(String(layerId || "").trim());
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
return;
}
setLayerPositionOffsets((current) => ({
...current,
[layerId]: normalizeLayerPositionOffset({
...normalizeLayerPositionOffset(current[layerId]),
...updater(normalizeLayerPositionOffset(current[layerId])),
}),
}));
}
function moveLayerOrder(
layerId: string,
direction: "forward" | "backward" | "front" | "back"
) {
const normalizedLayerId = String(layerId || "").trim();
if (!normalizedLayerId) {
return;
}
const layer = selectedTemplateLayerById.get(normalizedLayerId);
if (!layer || isAgentPsdLayerSuperLocked(layer)) {
return;
}
setLayerOrderIds((current) => {
const fallbackOrder = selectedTemplateLayers.map((layer) => layer.id);
const baseOrder = current.length > 0 ? [...current] : fallbackOrder;
const currentIndex = baseOrder.indexOf(normalizedLayerId);
if (currentIndex < 0) {
return current;
}
const nextOrder = [...baseOrder];
const [removed] = nextOrder.splice(currentIndex, 1);
if (!removed) {
return current;
}
if (direction === "front") {
nextOrder.push(removed);
} else if (direction === "back") {
nextOrder.unshift(removed);
} else if (direction === "forward") {
nextOrder.splice(Math.min(baseOrder.length - 1, currentIndex + 1), 0, removed);
} else {
nextOrder.splice(Math.max(0, currentIndex - 1), 0, removed);
}
return nextOrder;
});
}
function updateTextColorAssignment(layerId: string, color: string) {
setTextColorAssignments((current) => ({
...current,
[layerId]: normalizeHexColor(color, current[layerId] || "#111111"),
}));
}
function updateTextPositionOffset(
layerId: string,
updater: (
current: AgentPsdTextPositionOffset
) => Partial<AgentPsdTextPositionOffset>
) {
setTextPositionOffsets((current) => ({
...current,
[layerId]: normalizeTextPositionOffset({
...normalizeTextPositionOffset(current[layerId]),
...updater(normalizeTextPositionOffset(current[layerId])),
}),
}));
}
function nudgeTextPositionOffset(
layerId: string,
deltaX: number,
deltaY: number
) {
updateTextPositionOffset(layerId, (current) => ({
offsetX: current.offsetX + deltaX,
offsetY: current.offsetY + deltaY,
}));
}
function resetTextPositionOffset(layerId: string) {
setTextPositionOffsets((current) => {
const next = { ...current };
delete next[layerId];
return next;
});
}
function handleHiddenLayerListScroll(direction: "up" | "down") {
const viewport = getScrollAreaViewportElement(
hiddenLayerScrollAreaRef.current
);
if (!viewport) {
return;
}
viewport.scrollBy({
top:
direction === "up"
? -HIDDEN_LAYER_SCROLL_STEP
: HIDDEN_LAYER_SCROLL_STEP,
behavior: "smooth",
});
}
async function archiveRenderImageUrl(
rawUrl: string,
label: string
): Promise<string> {
const normalizedUrl = String(rawUrl || "").trim();
if (!normalizedUrl || isLikelyStableRenderImageUrl(normalizedUrl)) {
return normalizedUrl;
}
const cachedUrl = archivedRenderImageUrlCacheRef.current[normalizedUrl];
if (cachedUrl) {
return cachedUrl;
}
const cachedPromise =
archivedRenderImageUrlPromiseCacheRef.current[normalizedUrl];
if (cachedPromise) {
return cachedPromise;
}
const archivePromise = (async () => {
const fetchCandidates = Array.from(
new Set(
[
normalizedUrl,
buildImageProxyUrl(normalizedUrl, { delivery: "proxy" }),
].filter(Boolean)
)
);
let lastError: unknown = null;
for (const candidateUrl of fetchCandidates) {
try {
const response = await fetch(candidateUrl, {
method: "GET",
cache: "no-store",
});
if (!response.ok) {
throw new Error(`${label}读取失败(${response.status})`);
}
const blob = await response.blob();
if (!blob.size) {
throw new Error(`${label}读取结果为空`);
}
const extension = inferImageExtension(
normalizedUrl,
blob.type || response.headers.get("content-type") || ""
);
const formData = new FormData();
formData.set(
"file",
new File(
[blob],
`psd-render-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`,
{
type: blob.type || "image/png",
}
)
);
const uploadResponse = await fetchUploadWithRetry(formData);
const uploadPayload = await parseActionJsonResponse<{ url?: string }>(
uploadResponse,
`${label}转存失败`,
getFriendlyRateLimitMessage("当前上传过于频繁,请稍后再试")
);
const uploadedUrl = String(uploadPayload?.url || "").trim();
if (!uploadedUrl) {
throw new Error(`${label}转存失败`);
}
archivedRenderImageUrlCacheRef.current[normalizedUrl] = uploadedUrl;
return uploadedUrl;
} catch (error) {
lastError = error;
}
}
throw new Error(
getUserFacingRequestErrorMessage(
lastError,
`${label}无法转存到本站存储,请重新上传图片后再试`
)
);
})();
archivedRenderImageUrlPromiseCacheRef.current[normalizedUrl] =
archivePromise;
try {
return await archivePromise;
} finally {
delete archivedRenderImageUrlPromiseCacheRef.current[normalizedUrl];
}
}
async function normalizeRenderImageInputs() {
let nextSourceImageUrl = sourceImageUrl || "";
const nextImageAssignments = { ...effectiveImageAssignments };
const layerNameById = new Map(
editableImageLayers.map((layer) => [layer.id, layer.layerName])
);
const archiveJobs = [
{
type: "source" as const,
layerId: "",
url: sourceImageUrl || "",
label: "PSD 套版主图",
},
...Object.entries(nextImageAssignments).map(([layerId, layerUrl]) => ({
type: "assignment" as const,
layerId,
url: layerUrl,
label: `PSD 图层素材「${layerNameById.get(layerId) || layerId}」`,
})),
].filter((job) => String(job.url || "").trim());
const archivedJobs = await mapWithConcurrency(
archiveJobs,
PSD_RENDER_INPUT_ARCHIVE_CONCURRENCY,
async (job) => ({
...job,
archivedUrl: await archiveRenderImageUrl(job.url, job.label),
})
);
for (const job of archivedJobs) {
if (job.type === "source") {
nextSourceImageUrl = job.archivedUrl;
} else {
nextImageAssignments[job.layerId] = job.archivedUrl;
}
}
return {
sourceImageUrl: nextSourceImageUrl,
imageAssignments: nextImageAssignments,
};
}
function handleLayerDragStart(
layer: AgentPsdTemplateLayerSlot,
event: ReactPointerEvent<HTMLElement>
) {
if (isAgentPsdLayerSuperLocked(layer)) {
return;
}
event.preventDefault();
event.stopPropagation();
setSelectedLayerId(layer.id);
const shouldDragAssignedImageContent =
isAgentPsdImageLayerSlot(layer) &&
Boolean(String(effectiveImageAssignments[layer.id] || "").trim());
layerDragStateRef.current = {
layerId: layer.id,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startOffset: shouldDragAssignedImageContent
? normalizeLayerPositionOffset(effectiveImagePlacements[layer.id])
: normalizeLayerPositionOffset(effectiveLayerPositionOffsets[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 = layerDragStateRef.current;
if (!dragState || dragState.layerId !== layer.id) {
return;
}
if (shouldDragAssignedImageContent) {
updateImagePlacement(layer.id, (current) => ({
...current,
offsetX:
dragState.startOffset.offsetX +
(moveEvent.clientX - dragState.startClientX) / stageScale,
offsetY:
dragState.startOffset.offsetY +
(moveEvent.clientY - dragState.startClientY) / stageScale,
}));
return;
}
updateLayerPositionOffset(layer.id, () => ({
offsetX:
dragState.startOffset.offsetX +
(moveEvent.clientX - dragState.startClientX) / stageScale,
offsetY:
dragState.startOffset.offsetY +
(moveEvent.clientY - dragState.startClientY) / stageScale,
}));
};
const handlePointerUp = (upEvent: PointerEvent) => {
if (layerDragStateRef.current?.pointerId === upEvent.pointerId) {
layerDragStateRef.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);
}
function handleTextDragStart(
layer: AgentPsdTemplateTextLayerSlot,
event: ReactPointerEvent<HTMLDivElement>
) {
if (isAgentPsdLayerSuperLocked(layer)) {
return;
}
event.preventDefault();
event.stopPropagation();
setSelectedLayerId(layer.id);
textDragStateRef.current = {
layerId: layer.id,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startOffset: normalizeTextPositionOffset(
effectiveTextPositionOffsets[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 = textDragStateRef.current;
if (!dragState || dragState.layerId !== layer.id) {
return;
}
updateTextPositionOffset(layer.id, () => ({
offsetX:
dragState.startOffset.offsetX +
(moveEvent.clientX - dragState.startClientX) / stageScale,
offsetY:
dragState.startOffset.offsetY +
(moveEvent.clientY - dragState.startClientY) / stageScale,
}));
};
const handlePointerUp = (upEvent: PointerEvent) => {
if (textDragStateRef.current?.pointerId === upEvent.pointerId) {
textDragStateRef.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;
}
if (selectedTemplate.layers.length === 0) {
toast.error("模板详情仍在加载,请稍候再试");
return;
}
try {
setIsRendering(true);
setRenderedResultUrl("");
const normalizedRenderInputs = await normalizeRenderImageInputs();
if (normalizedRenderInputs.sourceImageUrl !== (sourceImageUrl || "")) {
const defaultSourceLayer = getDefaultSourceImageLayer(editableImageLayers);
if (defaultSourceLayer) {
setImageAssignments((current) => ({
...current,
[defaultSourceLayer.id]: normalizedRenderInputs.sourceImageUrl,
}));
}
}
setImageAssignments((current) => ({
...current,
...normalizedRenderInputs.imageAssignments,
}));
const payload = (await requestLongTaskResult({
endpoint: "/api/psd-templates/render",
timeoutMs: 8 * 60 * 1000,
maxPollFailures: 8,
payload: {
templateId: selectedTemplate.id,
sourceImageUrl: normalizedRenderInputs.sourceImageUrl,
hiddenLayerIds,
layerOrderIds,
imageAssignments: normalizedRenderInputs.imageAssignments,
imagePlacements: effectiveImagePlacements,
textAssignments: effectiveTextAssignments,
textColorAssignments: effectiveTextColorAssignments,
layerPositionOffsets: effectiveLayerPositionOffsets,
textPositionOffsets: effectiveTextPositionOffsets,
},
})) as AgentPsdTemplateRenderResponse;
if (!payload?.success || !payload.result?.url) {
throw new Error(payload?.error || "PSD 模板渲染失败");
}
setRenderedResultUrl(payload.result.url);
setRightPanelView("export");
onRenderedResultCreated?.(payload.result.url);
toast.success(
onRenderedResultCreated
? "模板替换结果已生成,并已加入结果区"
: "模板替换结果已生成"
);
} catch (error) {
toast.error(
getUserFacingRequestErrorMessage(error, "PSD 模板渲染失败", {
rateLimitMessage: getFriendlyRateLimitMessage(
"当前 PSD 渲染请求过于频繁,请稍后再试"
),
})
);
} finally {
setIsRendering(false);
}
}
function handleSwitchSelectedTemplateMode(nextMode: AgentPsdTemplateMode) {
if (selectedTemplateMode === nextMode) {
return;
}
const nextTemplates =
nextMode === "sku" ? skuModeTemplates : mainModeTemplates;
if (nextTemplates.length === 0) {
toast.error(
selectedCategorySummaryText
? nextMode === "sku"
? `当前品类“${selectedCategorySummaryText}”下暂无 SKU 图模式模板`
: `当前品类“${selectedCategorySummaryText}”下暂无主图模式模板`
: nextMode === "sku"
? "当前品牌下暂无 SKU 图模式模板"
: "当前品牌下暂无主图模式模板"
);
return;
}
setSelectedTemplateId(nextTemplates[0].id);
}
function renderTemplatePoolSection({
mode,
templates: poolTemplates,
}: {
mode: AgentPsdTemplateMode;
templates: AgentPsdTemplate[];
}) {
const isSkuMode = mode === "sku";
const title = isSkuMode ? "SKU图模式模板" : "主图模式模板";
const description = selectedCategorySummaryText
? `当前按品类“${selectedCategorySummaryText}”筛选,同时保留“适合所有”的模板。`
: normalizedBrandName
? `这里集中展示当前品牌下的${isSkuMode ? " SKU 图" : "主图"}模式模板。`
: `当前未选择品牌,这里展示全部品牌下的${isSkuMode ? " SKU 图" : "主图"}模式模板。`;
const emptyMessage = selectedCategorySummaryText
? `当前品类“${selectedCategorySummaryText}”下暂无${isSkuMode ? " SKU 图" : "主图"}模式模板`
: normalizedBrandName
? `当前品牌“${normalizedBrandName}”下暂无${isSkuMode ? " SKU 图" : "主图"}模式模板`
: `当前暂无可用的${isSkuMode ? " SKU 图" : "主图"}模式模板,请先去后台配置。`;
const loadingMessage = `正在加载${isSkuMode ? " SKU 图" : "主图"}模式模板...`;
const isCurrentResultMode = normalizedTemplateMode === mode;
return (
<section className="flex min-h-[560px] min-w-0 flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-[#141416] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_18px_45px_rgba(0,0,0,0.22)]">
<div className="flex items-start justify-between gap-4 border-white/[0.07] border-b pb-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold text-base text-white">{title}</h3>
{isCurrentResultMode ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2 py-0.5 font-medium text-[11px] text-emerald-300">
当前模式
</span>
) : null}
</div>
<p className="mt-2 text-white/50 text-xs leading-relaxed">
{description}
</p>
</div>
<div className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 font-medium text-white/70 text-xs">
{poolTemplates.length} 个模板
</div>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1">
{isLoading ? (
<div className="grid gap-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
className="rounded-xl border border-white/10 border-dashed bg-white/[0.02] px-4 py-5 text-center text-sm text-white/50 shadow-inner"
key={`${mode}-template-loading-${index}`}
>
{loadingMessage}
</div>
))}
</div>
) : poolTemplates.length > 0 ? (
<div className="grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(260px,1fr))]">
{poolTemplates.map((template, index) => (
<button
className={`rounded-xl border p-3 text-left transition-all duration-200 ${
selectedTemplateId === template.id
? "border-emerald-500/50 bg-emerald-500/10 shadow-[0_0_15px_rgba(16,185,129,0.1)]"
: "border-white/[0.08] bg-white/[0.02] hover:border-white/[0.15] hover:bg-white/[0.04]"
}`}
key={template.id}
onClick={() => setSelectedTemplateId(template.id)}
type="button"
>
{renderTemplatePickerCardContent(template, {
showSecondaryCategory:
shouldGroupTemplatesBySecondaryCategory,
showBrand: shouldShowTemplateBrand,
emphasize: true,
eager: index < 4,
})}
</button>
))}
</div>
) : (
<div className="flex min-h-[260px] flex-1 flex-col items-center justify-center rounded-xl border border-white/10 border-dashed bg-white/[0.02] px-6 py-10 text-center text-sm text-white/50 shadow-inner">
{emptyMessage}
</div>
)}
</div>
</section>
);
}
function renderPendingImageQueue() {
if (normalizedPendingImages.length === 0) {
return null;
}
return (
<div className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.03] p-3 shadow-inner">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Images className="h-4 w-4 text-emerald-300" />
<div className="font-semibold text-sm text-white">
待处理图片
</div>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-white/60">
{normalizedPendingImages.length} 张
</div>
</div>
<div className="grid gap-2">
{normalizedPendingImages.map((image, index) => {
const isActive = activePendingImageId === image.id;
const renderedResultUrl = pendingRenderedResultUrls?.[image.id];
return (
<button
key={`pending-psd-image-${image.id}`}
type="button"
onClick={() => onActivePendingImageIdChange?.(image.id)}
className={`flex items-center gap-3 rounded-xl border p-2 text-left transition-colors ${
isActive
? "border-emerald-400/40 bg-emerald-500/10"
: "border-white/10 bg-white/[0.02] hover:border-white/20 hover:bg-white/[0.05]"
}`}
>
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg border border-white/10 bg-black/30">
<img
alt={image.title || `待处理图片 ${index + 1}`}
className="h-full w-full object-cover"
decoding="async"
loading={index < 3 ? "eager" : "lazy"}
src={buildPsdLayerPreviewUrl(image.imageUrl, {
width: 112,
quality: 64,
})}
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-[12px] text-white/88">
{image.title || `待处理图片 ${index + 1}`}
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-white/45">
<span>{isActive ? "当前编辑中" : "点击切换"}</span>
{renderedResultUrl ? (
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/20 bg-emerald-500/10 px-1.5 py-0.5 text-emerald-200">
<CheckCircle2 className="h-3 w-3" />
已导出
</span>
) : null}
</div>
</div>
</button>
);
})}
</div>
</div>
);
}
const content = (
<>
<style
dangerouslySetInnerHTML={{
__html: `
.psd-preview-viewport::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.psd-preview-viewport::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 5px;
}
.psd-preview-viewport::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 5px;
border: 2px solid transparent;
background-clip: padding-box;
}
.psd-preview-viewport::-webkit-scrollbar-thumb:hover {
background-color: rgba(16, 185, 129, 0.5);
}
.psd-layer-visibility-scroll {
scrollbar-color: rgba(16, 185, 129, 0.55) rgba(255, 255, 255, 0.08);
scrollbar-width: thin;
}
.psd-layer-visibility-scroll::-webkit-scrollbar {
width: 10px;
}
.psd-layer-visibility-scroll::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
}
.psd-layer-visibility-scroll::-webkit-scrollbar-thumb {
background: rgba(16, 185, 129, 0.55);
border: 2px solid rgba(10, 10, 10, 0.9);
border-radius: 999px;
}
.psd-layer-visibility-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(16, 185, 129, 0.75);
}
`,
}}
/>
<div className="flex h-full min-h-0 flex-col overflow-hidden overscroll-none rounded-[24px] border border-white/12 bg-[#0a0a0a] p-0 text-white shadow-[0_40px_100px_rgba(0,0,0,0.8)]">
{fontFaceCss ? <style>{fontFaceCss}</style> : null}
{/* Top Header Bar */}
<div className="flex h-16 shrink-0 items-center justify-between border-white/12 border-b bg-[#0a0a0a] px-6">
<div className="flex items-center gap-3">
{standalone ? (
<Button
className="h-9 w-9 rounded-full border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() => {
if (onBack) {
onBack();
return;
}
onOpenChange(false);
}}
size="icon"
variant="ghost"
>
<ArrowLeft className="h-4 w-4" />
</Button>
) : null}
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/20 text-emerald-400">
<Sparkles className="h-4 w-4" />
</div>
<div>
<h2 className="font-semibold text-base text-white tracking-wide">
{standalone ? "PSD 套版工作台" : "PSD 模板编辑器"}
</h2>
{standalone && normalizedPendingImages.length > 0 ? (
<p className="mt-0.5 text-[11px] text-white/45">
当前待处理 {normalizedPendingImages.length} 张,品牌:{normalizedBrandName || "未选择"}
</p>
) : null}
</div>
</div>
<div className="flex items-center gap-3">
{selectedTemplate && (
<Button
className="h-9 rounded-xl bg-emerald-500 px-4 font-medium text-white shadow-[0_2px_10px_rgba(16,185,129,0.2)] ring-1 ring-emerald-500/50 transition-all hover:bg-emerald-400 hover:shadow-[0_4px_15px_rgba(16,185,129,0.3)] disabled:opacity-50"
disabled={
isRendering ||
isSelectedTemplateLoading ||
selectedTemplateLayers.length === 0
}
onClick={handleRender}
>
{isRendering || isSelectedTemplateLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{renderedResultUrl ? "重新编辑应用导出" : "应用并导出"}
</Button>
)}
<div className="h-4 w-[1px] bg-white/10" />
<button
className="flex h-8 w-8 items-center justify-center rounded-full text-white/50 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => onOpenChange(false)}
type="button"
>
<svg
fill="none"
height="18"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<line x1="18" x2="6" y1="6" y2="18" />
<line x1="6" x2="18" y1="6" y2="18" />
</svg>
</button>
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden xl:grid-cols-[320px_minmax(0,1fr)_380px]">
<div
className={`min-h-0 border-white/12 border-b bg-[#0a0a0a] xl:border-r xl:border-b-0 ${
selectedTemplate ? "p-5" : "p-5 xl:col-span-2 xl:p-6"
}`}
>
<ScrollArea
className={selectedTemplate ? "h-full pr-4" : "h-full pr-3"}
>
{selectedTemplate ? (
<div className="space-y-4">
{renderPendingImageQueue()}
<div className="space-y-2">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-base text-white">
选择模板
</div>
</div>
</div>
{selectedCategorySummaryText ? (
<div className="rounded-lg border border-white/10 bg-white/[0.02] px-3 py-2 text-white/50 text-xs shadow-inner">
{`当前按品类“${selectedCategorySummaryText}”筛选模板,同时保留“适合所有”的模板。`}
</div>
) : null}
{hasTemplateOptions ? (
<div className="space-y-2.5">
<div className="inline-flex rounded-[14px] bg-white/5 p-1 shadow-inner ring-1 ring-white/10">
<button
className={`rounded-xl px-4 py-1.5 font-medium text-xs transition-all duration-200 ${
selectedTemplateMode === "main"
? "bg-emerald-500 text-white shadow-[0_2px_10px_rgba(16,185,129,0.3)] ring-1 ring-emerald-500/50"
: "text-white/60 hover:bg-white/5 hover:text-white"
}`}
onClick={() =>
handleSwitchSelectedTemplateMode("main")
}
type="button"
>
主图模式
</button>
<button
className={`rounded-xl px-4 py-1.5 font-medium text-xs transition-all duration-200 ${
selectedTemplateMode === "sku"
? "bg-emerald-500 text-white shadow-[0_2px_10px_rgba(16,185,129,0.3)] ring-1 ring-emerald-500/50"
: "text-white/60 hover:bg-white/5 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 rounded-xl border border-white/10 bg-white/[0.02] px-3 py-2 text-white/90 shadow-inner transition-colors hover:border-white/20 hover:bg-white/[0.04] focus:ring-1 focus:ring-emerald-500/50 data-[state=open]:border-emerald-500/50 [&>span]:contents">
<div className="min-w-0 flex-1 pr-3">
{renderTemplateSelectContent(selectedTemplate, {
showSecondaryCategory:
shouldGroupTemplatesBySecondaryCategory,
showBrand: shouldShowTemplateBrand,
})}
</div>
</SelectTrigger>
<SelectContent
className="border-white/10 bg-[#141416] text-white shadow-2xl"
style={{
backgroundColor: "#141416",
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,
showBrand:
shouldShowTemplateBrand,
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, {
showBrand: shouldShowTemplateBrand,
compact: true,
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>
<div className="mx-auto w-full max-w-[220px] overflow-hidden rounded-2xl border border-white/10 bg-white/5 shadow-inner">
<div
style={{
aspectRatio: `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`,
}}
>
<TemplatePreviewImage
className="h-full w-full object-contain"
fetchPriority="high"
loading="eager"
quality={68}
template={selectedTemplate}
width={440}
/>
</div>
</div>
{sourceImageUrl ? null : (
<div className="rounded-xl border border-white/10 border-dashed bg-white/[0.02] px-4 py-3 text-white/50 text-xs leading-relaxed shadow-inner">
当前未从生图结果带入图片。请先在下方图片图层中点击“上传替换图片”,再开始套用
PSD 模板。
</div>
)}
</div>
) : (
<div className="space-y-4">
{renderPendingImageQueue()}
<div className="grid min-h-[640px] gap-5 xl:grid-cols-2">
{renderTemplatePoolSection({
mode: "main",
templates: mainModeTemplates,
})}
{renderTemplatePoolSection({
mode: "sku",
templates: skuModeTemplates,
})}
</div>
</div>
)}
</ScrollArea>
</div>
<div
className={
selectedTemplate
? "flex min-h-0 flex-col border-white/12 border-b bg-[#0a0a0a] p-6 xl:border-r xl:border-b-0"
: "hidden"
}
>
<div
className="psd-preview-viewport relative flex min-h-0 flex-1 items-center justify-center overflow-auto rounded-[28px] border border-white/[0.08] bg-[#141416] p-4 shadow-[inset_0_2px_20px_rgba(0,0,0,0.4)]"
ref={previewViewportRef}
>
{selectedTemplate ? (
selectedTemplateLayers.length === 0 ? (
<div className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/50 shadow-inner">
<TemplatePreviewImage
alt={`${selectedTemplate.name} 快速预览`}
className="absolute inset-0 h-full w-full object-contain opacity-35 blur-[1px]"
fetchPriority="high"
loading="eager"
quality={58}
template={selectedTemplate}
width={720}
/>
<div className="absolute inset-0 bg-[#141416]/72" />
<div className="relative z-10 flex flex-col items-center">
{selectedTemplateDetailError ? (
<>
<div className="font-medium text-white/80">
模板详情加载失败
</div>
<div className="mt-2 max-w-md text-xs leading-relaxed text-white/45">
{selectedTemplateDetailError}
</div>
</>
) : (
<>
<Loader2 className="mb-4 h-7 w-7 animate-spin text-emerald-400" />
<div className="font-medium text-white/80">
正在加载模板图层与编辑配置
</div>
<div className="mt-2 max-w-md text-xs leading-relaxed text-white/45">
模板卡片已经优先显示,当前正在按需加载这个模板的完整图层、字体和编辑底图。
</div>
</>
)}
</div>
</div>
) : (
<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 &&
orderedVisiblePreviewLayers.length > 0 ? (
<TemplatePreviewImage
alt={`${selectedTemplate.name} 编辑底图`}
className="absolute inset-0 h-full w-full object-cover"
fetchPriority="high"
loading="eager"
preferRenderBase
quality={68}
style={{ zIndex: renderBaseZIndex }}
template={selectedTemplate}
width={960}
/>
) : null}
<div className="pointer-events-none absolute inset-0">
{orderedVisiblePreviewLayers.map((layer) => {
const isLayerSuperLocked =
isAgentPsdLayerSuperLocked(layer);
const layerPositionOffset =
effectiveLayerPositionOffsets[layer.id] ||
normalizeLayerPositionOffset();
const baseZIndex =
layerStackZIndexById.get(layer.id) || 1;
const layerBoxStyle = {
left: layer.x + layerPositionOffset.offsetX,
top: layer.y + layerPositionOffset.offsetY,
width: layer.width,
height: layer.height,
} satisfies CSSProperties;
if (isAgentPsdImageLayerSlot(layer)) {
const assignedUrl =
effectiveImageAssignments[layer.id];
const layerImageUrl =
assignedUrl ||
getPsdLayerPreviewSourceUrl(layer);
if (!layerImageUrl) {
return null;
}
const assignedFrameLayer = assignedUrl
? getAssignedImageFrameLayer(
layer,
selectedTemplateLayers
)
: layer;
const assignedFrameOffset =
effectiveLayerPositionOffsets[
assignedFrameLayer.id
] || normalizeLayerPositionOffset();
const assignedFrameBoxStyle = {
left:
assignedFrameLayer.x +
assignedFrameOffset.offsetX,
top:
assignedFrameLayer.y +
assignedFrameOffset.offsetY,
width: assignedFrameLayer.width,
height: assignedFrameLayer.height,
} satisfies CSSProperties;
const assignedImageContainerStyle = assignedUrl
? {
...assignedFrameBoxStyle,
...getAssignedImageMaskStyle(
layer,
assignedFrameLayer,
selectedTemplateLayers
),
}
: layerBoxStyle;
const assignedFrameBaseZIndex =
layerStackZIndexById.get(
assignedFrameLayer.id
) || baseZIndex;
const previewZIndex = assignedUrl
? assignedFrameLayer.id === layer.id
? Math.max(baseZIndex, renderBaseZIndex + 1)
: assignedFrameBaseZIndex + 1
: baseZIndex;
return (
<div
className={`absolute overflow-hidden ${
isLayerSuperLocked
? "pointer-events-none"
: "pointer-events-auto"
}`}
key={layer.id}
onClick={
isLayerSuperLocked
? undefined
: (event) => {
event.stopPropagation();
setSelectedLayerId(layer.id);
}
}
onPointerDown={(event) =>
isLayerSuperLocked
? undefined
: handleLayerDragStart(layer, event)
}
style={{
...assignedImageContainerStyle,
zIndex: previewZIndex,
cursor:
isLayerSuperLocked
? "default"
: layerDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: isLayerSuperLocked
? "auto"
: "none",
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
>
<img
alt={layer.layerName}
className={
assignedUrl ? "" : "h-full w-full"
}
decoding="async"
src={buildPsdLayerPreviewUrl(layerImageUrl, {
width: assignedUrl
? Math.max(layer.width, layer.height)
: layer.width,
quality: assignedUrl ? 74 : 68,
delivery: assignedUrl ? "proxy" : undefined,
})}
style={
assignedUrl && imageSizes[layer.id]
? getPlacedAssignedImageStyle({
layer,
frameLayer: assignedFrameLayer,
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 value =
effectiveTextAssignments[layer.id] ?? "";
const resolvedTextColor =
effectiveTextColorAssignments[layer.id] ||
layer.textColor;
const textPositionOffset =
effectiveTextPositionOffsets[layer.id] ||
normalizeTextPositionOffset();
const hasRenderableText =
hasRenderableTextLayerPreview(layer, value);
const layerPreviewSourceUrl =
getPsdLayerPreviewSourceUrl(layer);
if (
!hasRenderableText &&
layerPreviewSourceUrl
) {
return (
<div
className={`absolute overflow-hidden ${
isLayerSuperLocked
? "pointer-events-none"
: "pointer-events-auto"
}`}
key={layer.id}
onClick={
isLayerSuperLocked
? undefined
: (event) => {
event.stopPropagation();
setSelectedLayerId(layer.id);
}
}
onPointerDown={(event) =>
isLayerSuperLocked
? undefined
: handleTextDragStart(layer, event)
}
style={{
...layerBoxStyle,
zIndex: baseZIndex,
cursor:
isLayerSuperLocked
? "default"
: textDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: isLayerSuperLocked
? "auto"
: "none",
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
>
<img
alt={layer.layerName}
className="h-full w-full object-fill"
decoding="async"
src={buildPsdLayerPreviewUrl(
layerPreviewSourceUrl,
{
width: layer.width,
quality: 68,
}
)}
/>
</div>
);
}
if (!hasRenderableText) {
return null;
}
const textFrame = getTextLayerRenderFrame(
layer,
value
);
return (
<div
className={`absolute overflow-visible ${
isLayerSuperLocked
? "pointer-events-none"
: "pointer-events-auto"
}`}
key={layer.id}
onClick={
isLayerSuperLocked
? undefined
: (event) => {
event.stopPropagation();
setSelectedLayerId(layer.id);
}
}
onPointerDown={(event) =>
isLayerSuperLocked
? undefined
: handleTextDragStart(layer, event)
}
style={{
left:
layer.x +
layerPositionOffset.offsetX +
textPositionOffset.offsetX +
textFrame.offsetX,
top:
layer.y +
layerPositionOffset.offsetY +
textPositionOffset.offsetY +
textFrame.offsetY,
width: textFrame.width,
height: textFrame.height,
zIndex: baseZIndex,
cursor:
isLayerSuperLocked
? "default"
: textDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: isLayerSuperLocked
? "auto"
: "none",
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
>
{renderTextLayerPreviewContent(
layer,
value,
resolvedTextColor
)}
</div>
);
})}
</div>
<div className="absolute inset-0">
{orderedVisiblePreviewLayers
.filter(
(layer) =>
!layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer)
)
.map((layer) => {
const isSelected = selectedLayerId === layer.id;
const baseZIndex =
layerStackZIndexById.get(layer.id) || 1;
const frameStyle = getLayerFrameStyle(
layer,
isSelected,
effectiveLayerPositionOffsets[layer.id]
);
return (
<div
aria-label={`普通图层 ${layer.layerName}`}
className="pointer-events-none absolute overflow-hidden rounded-lg transition-all"
key={`static-layer-frame-${layer.id}`}
style={{
...frameStyle,
zIndex:
baseZIndex +
(isSelected
? SELECTED_LAYER_INTERACTION_Z_BOOST
: 0),
background: isSelected
? "rgba(255,255,255,0.05)"
: "transparent",
cursor:
layerDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: "none",
}}
title={`普通图层:${layer.layerName}`}
/>
);
})}
{orderedEditableImageLayers.map((layer) => {
if (hiddenLayerIdSet.has(layer.id)) {
return null;
}
const isSelected = selectedLayerId === layer.id;
const baseZIndex =
layerStackZIndexById.get(layer.id) || 1;
const frameStyle = getLayerFrameStyle(
layer,
isSelected,
effectiveLayerPositionOffsets[layer.id]
);
const hasAssignedImage = Boolean(
String(
effectiveImageAssignments[layer.id] || ""
).trim()
);
return isSelected && hasAssignedImage ? (
<div
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
className="pointer-events-none absolute overflow-hidden rounded-lg transition-all"
key={layer.id}
style={{
...frameStyle,
zIndex:
baseZIndex +
SELECTED_LAYER_INTERACTION_Z_BOOST,
background: "rgba(249,115,22,0.08)",
cursor:
layerDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: "none",
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
/>
) : (
<button
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
className="absolute overflow-hidden rounded-lg transition-all"
key={layer.id}
onClick={() => setSelectedLayerId(layer.id)}
onPointerDown={(event) =>
handleLayerDragStart(layer, event)
}
style={{
...frameStyle,
zIndex:
baseZIndex +
(isSelected
? SELECTED_LAYER_INTERACTION_Z_BOOST
: 0),
background: isSelected
? "rgba(249,115,22,0.08)"
: "transparent",
cursor:
layerDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: "none",
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
type="button"
/>
);
})}
{orderedEditableImageLayers.map((layer) => {
if (hiddenLayerIdSet.has(layer.id)) {
return null;
}
const isSelected = selectedLayerId === layer.id;
const hasAssignedImage = Boolean(
String(
effectiveImageAssignments[layer.id] || ""
).trim()
);
const baseZIndex =
layerStackZIndexById.get(layer.id) || 1;
if (!isSelected || !hasAssignedImage) {
return null;
}
return (
<div
className="absolute flex items-center gap-1 rounded-full border border-white/10 bg-black/80 p-1 text-white shadow-xl backdrop-blur-md"
key={`${layer.id}-controls`}
style={{
left:
layer.x +
(
effectiveLayerPositionOffsets[layer.id]
?.offsetX || 0
) +
layer.width -
112,
top: Math.max(
8,
layer.y +
(
effectiveLayerPositionOffsets[layer.id]
?.offsetY || 0
) -
44
),
zIndex:
baseZIndex +
SELECTED_LAYER_CONTROLS_Z_BOOST,
}}
>
<button
aria-label="缩小图片"
className="rounded-full p-1.5 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
onClick={() =>
nudgeImageScale(layer.id, -0.1)
}
title="缩小"
type="button"
>
<ZoomOut className="h-4 w-4" />
</button>
<button
aria-label="放大图片"
className="rounded-full p-1.5 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
onClick={() => nudgeImageScale(layer.id, 0.1)}
title="放大"
type="button"
>
<ZoomIn className="h-4 w-4" />
</button>
<div className="h-3.5 w-px bg-white/20" />
<button
aria-label="重置图片位置和缩放"
className="rounded-full p-1.5 text-emerald-400 transition-colors hover:bg-white/20 hover:text-emerald-300"
onClick={() => resetImagePlacement(layer.id)}
title="重置"
type="button"
>
<RefreshCcw className="h-4 w-4" />
</button>
</div>
);
})}
{orderedEditableTextLayers.map((layer) => {
if (hiddenLayerIdSet.has(layer.id)) {
return null;
}
const isSelected = selectedLayerId === layer.id;
const baseZIndex =
layerStackZIndexById.get(layer.id) || 1;
const textPositionOffset =
effectiveTextPositionOffsets[layer.id] ||
normalizeTextPositionOffset();
const layerPositionOffset =
effectiveLayerPositionOffsets[layer.id] ||
normalizeLayerPositionOffset();
const frameStyle = {
...getLayerFrameStyle(
layer,
isSelected,
layerPositionOffset
),
left:
layer.x +
layerPositionOffset.offsetX +
textPositionOffset.offsetX,
top:
layer.y +
layerPositionOffset.offsetY +
textPositionOffset.offsetY,
} satisfies CSSProperties;
const currentValue =
effectiveTextAssignments[layer.id] ?? "";
const resolvedTextColor =
effectiveTextColorAssignments[layer.id] ||
layer.textColor;
const layerPreviewSourceUrl =
getPsdLayerPreviewSourceUrl(layer);
const hasRenderableText =
hasRenderableTextLayerPreview(
layer,
currentValue
);
const preserveSourceAppearance =
!hasRenderableText &&
!!layerPreviewSourceUrl &&
!hasCustomizedTextLayerAppearance(
layer,
currentValue,
resolvedTextColor,
textPositionOffset
);
const editedTextFrame = getTextLayerRenderFrame(
layer,
currentValue
);
if (isSelected) {
return (
<div
className="pointer-events-none absolute overflow-visible rounded-lg"
key={layer.id}
style={{
...frameStyle,
zIndex:
baseZIndex +
SELECTED_LAYER_INTERACTION_Z_BOOST,
background: "transparent",
cursor:
textDragStateRef.current?.layerId ===
layer.id
? "grabbing"
: "grab",
touchAction: "none",
}}
>
{preserveSourceAppearance &&
layerPreviewSourceUrl ? (
<img
alt={layer.layerName}
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
decoding="async"
src={buildPsdLayerPreviewUrl(
layerPreviewSourceUrl,
{
width: layer.width,
quality: 68,
}
)}
/>
) : null}
{!preserveSourceAppearance &&
hasRenderableText ? (
<div
className="absolute"
style={{
left: editedTextFrame.offsetX,
top: editedTextFrame.offsetY,
width: editedTextFrame.width,
height: editedTextFrame.height,
}}
>
{renderTextLayerPreviewContent(
layer,
currentValue,
resolvedTextColor
)}
</div>
) : null}
</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:
baseZIndex +
(isSelected
? SELECTED_LAYER_INTERACTION_Z_BOOST
: 0),
background: isSelected
? "rgba(56,189,248,0.05)"
: "transparent",
}}
title={`${getLayerLabel(layer)}:${layer.layerName}`}
type="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-base text-white">
SKU图模式模板
</div>
<div className="mt-1.5 text-white/50 text-xs">
{normalizedTemplateMode === "sku"
? "当前结果属于 SKU 图模式,可直接从这里选择并进入编辑。"
: selectedCategorySummaryText
? `当前按品类“${selectedCategorySummaryText}”筛选,同时展示“适合所有”的 SKU 图模式模板。`
: normalizedBrandName
? "这里集中展示当前品牌下的 SKU 图模式模板。"
: "当前未选择品牌,这里展示全部品牌下的 SKU 图模式模板。"}
</div>
</div>
<div className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 font-medium text-white/70 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/10 border-dashed bg-white/[0.02] px-4 py-5 text-center text-sm text-white/50 shadow-inner"
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, index) => (
<button
className={`rounded-2xl border p-3 text-left transition-all duration-200 ${
selectedTemplateId === template.id
? "border-emerald-500/50 bg-emerald-500/10 shadow-[0_0_15px_rgba(16,185,129,0.1)]"
: "border-white/[0.08] bg-white/[0.02] hover:border-white/[0.15] hover:bg-white/[0.04]"
}`}
key={template.id}
onClick={() => setSelectedTemplateId(template.id)}
type="button"
>
{renderTemplatePickerCardContent(template, {
showSecondaryCategory:
shouldGroupTemplatesBySecondaryCategory,
showBrand: shouldShowTemplateBrand,
emphasize: true,
eager: index < 4,
})}
</button>
))}
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] px-6 py-10 text-center text-sm text-white/50 shadow-inner">
{selectedCategorySummaryText
? `当前品类“${selectedCategorySummaryText}”下暂无 SKU 图模式模板`
: normalizedBrandName
? `当前品牌“${normalizedBrandName}”下暂无 SKU 图模式模板`
: "当前暂无可用的 SKU 图模式模板,请先去后台配置。"}
</div>
)}
</div>
)}
</div>
</div>
<div className="flex min-h-0 flex-col bg-[#0a0a0a] p-5 shadow-[-20px_0_40px_rgba(0,0,0,0.5)] xl:p-6">
{renderedResultUrl ? (
<div className="mb-4 flex items-center justify-end gap-3">
<div className="inline-flex rounded-[14px] bg-white/5 p-1 shadow-inner ring-1 ring-white/10">
<button
className={`rounded-xl px-4 py-1.5 font-medium text-xs transition-all duration-200 ${
rightPanelView === "editor"
? "bg-emerald-500 text-white shadow-[0_2px_10px_rgba(16,185,129,0.3)] ring-1 ring-emerald-500/50"
: "text-white/60 hover:bg-white/5 hover:text-white"
}`}
onClick={() => setRightPanelView("editor")}
type="button"
>
编辑面板
</button>
<button
className={`rounded-xl px-4 py-1.5 font-medium text-xs transition-all duration-200 ${
rightPanelView === "export"
? "bg-emerald-500 text-white shadow-[0_2px_10px_rgba(16,185,129,0.3)] ring-1 ring-emerald-500/50"
: "text-white/60 hover:bg-white/5 hover:text-white"
}`}
onClick={() => setRightPanelView("export")}
type="button"
>
导出结果
</button>
</div>
</div>
) : null}
<Input
accept="image/png,image/jpeg,image/webp,image/heic,image/heif,.heic,.heif"
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}
/>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-4">
{isSelectedLayerHidden && selectedLayer ? (
<div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-amber-500/20 bg-amber-500/5 p-8 text-center text-sm text-white/60 shadow-inner">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full border border-amber-500/20 bg-amber-500/10 shadow-inner">
<EyeOff className="h-6 w-6 text-amber-300" />
</div>
<div className="font-medium text-white">
图层已临时删除
</div>
<div className="mt-2 max-w-xs text-xs leading-relaxed text-white/45">
当前会话里不会再预览或导出图层“{selectedLayer.layerName}
”,但不会改动模板本身。
</div>
<Button
className="mt-5 rounded-xl bg-emerald-500 px-4 text-white hover:bg-emerald-400"
onClick={() => restoreHiddenLayer(selectedLayer.id)}
type="button"
>
恢复这个图层
</Button>
</div>
) : selectedImageLayer ? (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto pr-1">
<div className="sticky top-0 z-20 -mx-1 bg-[#0a0a0a]/95 px-1 pb-3 backdrop-blur-md">
{selectedImageAdjustmentPanel}
</div>
{selectedLayerPositionPanel}
{textLayerQuickColorPanel}
</div>
) : selectedTextLayer ? (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02] p-4 shadow-inner xl:p-5">
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pr-1">
{selectedLayerPositionPanel}
<Textarea
className="min-h-[180px] flex-1 resize-none rounded-xl border border-white/10 bg-white/[0.02] px-4 py-3 text-sm text-white/90 shadow-inner placeholder:text-white/30 focus-visible:border-emerald-500/50 focus-visible:bg-white/[0.04] focus-visible:ring-1 focus-visible:ring-emerald-500/50 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-x-4 gap-y-2 rounded-xl bg-white/[0.02] px-4 py-3 text-white/60 text-xs shadow-inner">
<span className="flex items-center gap-1.5">
<span className="text-white/40">字体:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.fontLabel ||
selectedTextLayer.fontFamily ||
"系统字体"}
</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-white/40">字号:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.fontSize}px
</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-white/40">行高:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.lineHeight}
</span>
</span>
<span className="flex items-center gap-1.5">
<span className="text-white/40">字距:</span>
<span className="font-semibold text-white/80">
{selectedTextLayer.letterSpacing}px
</span>
</span>
</div>
<div className="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5 shadow-inner">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-semibold text-sm text-white">
文字颜色
</div>
<div className="mt-1.5 text-white/50 text-xs">
支持自定义颜色;若后台配置了品牌色,也可以直接套用预设。
</div>
</div>
<div
className="h-10 w-10 shrink-0 rounded-full border-2 border-white/20 shadow-[0_0_15px_rgba(0,0,0,0.5)]"
style={{ backgroundColor: selectedTextColor }}
/>
</div>
<div className="grid grid-cols-[52px_minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-white/[0.08] bg-black/20 p-3">
<input
aria-label="选择文字颜色"
className="h-10 w-12 cursor-pointer rounded-lg border border-white/15 bg-transparent p-1"
onChange={(event) =>
updateTextColorAssignment(
selectedTextLayer.id,
event.target.value
)
}
type="color"
value={selectedTextColor}
/>
<div className="rounded-lg bg-white/[0.04] px-3 py-2 font-mono text-[12px] text-white/75 uppercase">
{selectedTextColor}
</div>
<Button
className="rounded-lg px-3 text-white/60 hover:bg-white/5 hover:text-white"
onClick={() =>
updateTextColorAssignment(
selectedTextLayer.id,
selectedTextLayer.textColor
)
}
size="sm"
type="button"
variant="ghost"
>
重置
</Button>
</div>
{textColorConfig.enabled &&
textColorConfig.colors.length > 0 ? (
<div className="grid grid-cols-2 gap-2.5">
{textColorConfig.colors.map((color, index) => {
const isActive =
getResolvedTextLayerColor(
selectedTextLayer,
color
) ===
getResolvedTextLayerColor(
selectedTextLayer,
selectedTextColor
);
return (
<button
className={`group flex items-center gap-3 rounded-xl border px-3 py-3 text-left transition-all duration-200 ${
isActive
? "border-emerald-500/50 bg-emerald-500/10 text-white shadow-[0_0_15px_rgba(16,185,129,0.15)] ring-1 ring-emerald-500/50"
: "border-white/[0.08] bg-white/[0.02] text-white/80 hover:border-white/[0.15] hover:bg-white/[0.04]"
}`}
key={`text-color-option-${index + 1}`}
onClick={() =>
updateTextColorAssignment(
selectedTextLayer.id,
color
)
}
type="button"
>
<span
className={`h-6 w-6 shrink-0 rounded-full border-2 transition-all ${
isActive
? "scale-110 border-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.3)]"
: "border-white/20 shadow-inner"
}`}
style={{ backgroundColor: color }}
/>
<span className="flex min-w-0 flex-col">
<span
className={`font-medium text-xs transition-colors ${isActive ? "text-emerald-400" : "text-white/90 group-hover:text-white"}`}
>{`颜色 ${index + 1}`}</span>
<span
className={`font-mono text-[11px] uppercase transition-colors ${isActive ? "text-emerald-500/70" : "text-white/50 group-hover:text-white/70"}`}
>
{color}
</span>
</span>
</button>
);
})}
</div>
) : (
<div className="rounded-xl border border-white/[0.08] bg-white/[0.02] px-3 py-2 text-white/45 text-xs">
当前品牌未配置预设色,可直接使用上方颜色选择器。
</div>
)}
</div>
<div className="space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5 shadow-inner">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-semibold text-sm text-white">
文字位置
</div>
<div className="mt-1.5 text-white/50 text-xs">
可在中间预览区直接拖动文字,也可以用数值微调。
</div>
</div>
<Move className="h-5 w-5 shrink-0 text-sky-300" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-white/50 text-xs">
水平 X
</Label>
<Input
className="h-10 rounded-xl border-white/10 bg-black/20 text-white"
inputMode="numeric"
onChange={(event) => {
const nextValue = Number(event.target.value);
if (!Number.isFinite(nextValue)) return;
updateTextPositionOffset(
selectedTextLayer.id,
() => ({ offsetX: nextValue })
);
}}
type="number"
value={Math.round(
selectedTextPositionOffset.offsetX
)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-white/50 text-xs">
垂直 Y
</Label>
<Input
className="h-10 rounded-xl border-white/10 bg-black/20 text-white"
inputMode="numeric"
onChange={(event) => {
const nextValue = Number(event.target.value);
if (!Number.isFinite(nextValue)) return;
updateTextPositionOffset(
selectedTextLayer.id,
() => ({ offsetY: nextValue })
);
}}
type="number"
value={Math.round(
selectedTextPositionOffset.offsetY
)}
/>
</div>
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<div />
<Button
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
nudgeTextPositionOffset(
selectedTextLayer.id,
0,
-1
)
}
type="button"
variant="outline"
>
↑
</Button>
<div />
<Button
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
nudgeTextPositionOffset(
selectedTextLayer.id,
-1,
0
)
}
type="button"
variant="outline"
>
←
</Button>
<Button
className="h-9 rounded-xl border-white/10 bg-white/[0.04] px-3 text-white/60 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
resetTextPositionOffset(selectedTextLayer.id)
}
type="button"
variant="outline"
>
归零
</Button>
<Button
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
nudgeTextPositionOffset(
selectedTextLayer.id,
1,
0
)
}
type="button"
variant="outline"
>
→
</Button>
<div />
<Button
className="h-9 w-12 rounded-xl border-white/10 bg-white/[0.04] text-white/70 hover:bg-white/[0.08] hover:text-white"
onClick={() =>
nudgeTextPositionOffset(
selectedTextLayer.id,
0,
1
)
}
type="button"
variant="outline"
>
↓
</Button>
<div />
</div>
</div>
</div>
</div>
) : selectedLayer ? (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto pr-1">
{selectedLayerPositionPanel}
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5 text-sm text-white/60 shadow-inner">
<div className="font-medium text-white/85">
当前是普通图层
</div>
<div className="mt-2 text-xs leading-relaxed text-white/45">
这个图层当前不支持替换图片或编辑文字,但可以在预览区直接拖动位置,也可以调整上下层级。
</div>
</div>
</div>
) : selectedTemplate &&
selectedTemplateLayers.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/40 shadow-inner">
{selectedTemplateDetailError ? (
<>
<div className="font-medium text-white/80">
模板详情加载失败
</div>
<div className="mt-2 max-w-xs text-xs leading-relaxed text-white/45">
{selectedTemplateDetailError}
</div>
</>
) : (
<>
<Loader2 className="mb-4 h-6 w-6 animate-spin text-emerald-400" />
<div className="font-medium text-white/80">
正在加载这个模板的编辑图层
</div>
<div className="mt-2 max-w-xs text-xs leading-relaxed text-white/45">
首屏先展示模板列表,完整文字图层、图片图层和字体配置会按需补回来。
</div>
</>
)}
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center rounded-2xl border border-white/10 border-dashed bg-white/[0.02] p-8 text-center text-sm text-white/40 shadow-inner">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full border border-white/10 bg-white/5 shadow-inner">
<Pointer className="h-6 w-6 text-white/50" />
</div>
请在中间预览区点击图层进行编辑
</div>
)}
{orderedUserManageablePreviewLayers.length > 0 ? (
<div className="flex shrink-0 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-4 shadow-inner">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-sm text-white">
图层显示
</div>
<div className="mt-1 text-white/45 text-xs">
临时删除只影响这次套版预览和导出,不会改动 PSD
模板本身。
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.04] p-0.5 shadow-inner">
<button
className="flex h-7 w-7 items-center justify-center rounded-full text-white/55 transition-colors hover:bg-white/[0.08] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
disabled={!canScrollHiddenLayersUp}
onClick={() => handleHiddenLayerListScroll("up")}
title="向上滚动图层列表"
type="button"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-full text-white/55 transition-colors hover:bg-white/[0.08] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
disabled={!canScrollHiddenLayersDown}
onClick={() =>
handleHiddenLayerListScroll("down")
}
title="向下滚动图层列表"
type="button"
>
<ChevronDown className="h-4 w-4" />
</button>
</div>
<div className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-white/60">
已隐藏 {hiddenLayerIds.length} /{" "}
{orderedUserManageablePreviewLayers.length}
</div>
</div>
</div>
<div
className="psd-layer-visibility-scroll max-h-[220px] min-h-0 overflow-y-auto overscroll-contain pr-2"
ref={hiddenLayerScrollAreaRef}
>
<div className="space-y-2 pb-1">
{orderedUserManageablePreviewLayers.map((layer) => {
const isHidden = hiddenLayerIdSet.has(layer.id);
const isSelected = selectedLayerId === layer.id;
return (
<div
className={`flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-all ${
isSelected
? "border-emerald-500/40 bg-emerald-500/10"
: "border-white/[0.08] bg-white/[0.02] hover:border-white/[0.15] hover:bg-white/[0.04]"
}`}
key={`layer-visibility-${layer.id}`}
onClick={() => setSelectedLayerId(layer.id)}
onKeyDown={(event) => {
if (
event.key === "Enter" ||
event.key === " "
) {
event.preventDefault();
setSelectedLayerId(layer.id);
}
}}
role="button"
tabIndex={0}
>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm text-white/90">
{layer.layerName}
</div>
<div className="mt-0.5 text-[11px] text-white/45">
{layer.isEditable
? isAgentPsdImageLayerSlot(layer)
? "可编辑图片图层"
: "可编辑文字图层"
: "普通图层"}
</div>
</div>
<button
className={`inline-flex h-8 shrink-0 items-center gap-1 rounded-lg px-2.5 text-xs transition-all ${
isHidden
? "bg-emerald-500/15 text-emerald-300 hover:bg-emerald-500/20"
: "bg-red-500/10 text-red-300 hover:bg-red-500/15"
}`}
onClick={(event) => {
event.stopPropagation();
if (isHidden) {
restoreHiddenLayer(layer.id);
return;
}
hideLayerTemporarily(layer.id);
}}
type="button"
>
{isHidden ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="h-3.5 w-3.5" />
)}
{isHidden ? "恢复" : "临时删除"}
</button>
</div>
);
})}
</div>
</div>
</div>
) : null}
</div>
)}
</div>
</div>
</div>
</>
);
if (standalone) {
return (
<div className="h-dvh w-full overflow-hidden bg-[#050608]">
<div className="h-full w-full overflow-hidden p-4">
{content}
</div>
</div>
);
}
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="flex h-[96vh] w-[96vw] max-w-[1700px] flex-col overflow-hidden overscroll-none rounded-[24px] border border-white/12 bg-transparent p-0 text-white shadow-none"
hideCloseButton
onEscapeKeyDown={(event) => {
event.preventDefault();
}}
onInteractOutside={(event) => {
event.preventDefault();
}}
onPointerDownOutside={(event) => {
event.preventDefault();
}}
overlayClassName="bg-black/80 backdrop-blur-md"
>
{content}
</DialogContent>
</Dialog>
);
}