3280 lines
121 KiB
TypeScript
3280 lines
121 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Expand,
|
||
Image as ImageIcon,
|
||
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,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Slider } from "@/components/ui/slider";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
type AgentPsdImagePlacement,
|
||
type AgentPsdImageSize,
|
||
getDefaultAgentPsdImagePlacement,
|
||
normalizeAgentPsdImagePlacement,
|
||
resolveAgentPsdImageBaseFrame,
|
||
} from "@/lib/agents/psd-image-placement";
|
||
import {
|
||
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,
|
||
isAgentPsdTextLayerSlot,
|
||
parseAgentPsdTemplates,
|
||
} 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 { 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: "default" });
|
||
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 模板详情加载失败",
|
||
};
|
||
}
|
||
};
|
||
|
||
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 UNASSIGNED_BRAND_LABEL = "未分类";
|
||
|
||
function buildTemplatePreviewThumbUrl(
|
||
previewUrl: string,
|
||
params?: {
|
||
width?: number;
|
||
quality?: number;
|
||
}
|
||
) {
|
||
const normalizedUrl = String(previewUrl || "").trim();
|
||
if (!normalizedUrl) {
|
||
return "";
|
||
}
|
||
|
||
const searchParams = new URLSearchParams({
|
||
url: normalizedUrl,
|
||
w: String(Math.max(80, Math.round(params?.width || 160))),
|
||
q: String(Math.max(40, Math.round(params?.quality || 72))),
|
||
f: "webp",
|
||
});
|
||
|
||
return `/api/image-proxy?${searchParams.toString()}`;
|
||
}
|
||
|
||
function normalizeManagedBrandName(value?: string | null) {
|
||
const normalized = String(value || "").trim();
|
||
if (!normalized || normalized === "未分配品牌") {
|
||
return "";
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
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"
|
||
}
|
||
>
|
||
<img
|
||
alt={template.name}
|
||
className="h-full w-full object-cover"
|
||
decoding="async"
|
||
loading="lazy"
|
||
src={buildTemplatePreviewThumbUrl(template.previewUrl, {
|
||
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">
|
||
<img
|
||
alt={template.name}
|
||
className="h-full w-full object-cover"
|
||
decoding="async"
|
||
fetchPriority={eager ? "high" : "low"}
|
||
loading={eager ? "eager" : "lazy"}
|
||
src={buildTemplatePreviewThumbUrl(template.previewUrl, {
|
||
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 createInitialState(
|
||
template: AgentPsdTemplate | null,
|
||
sourceImageUrl: string
|
||
) {
|
||
const imageAssignments: Record<string, string> = {};
|
||
const imagePlacements: Record<string, AgentPsdImagePlacement> = {};
|
||
const textAssignments: Record<string, string> = {};
|
||
|
||
if (!template) {
|
||
return { imageAssignments, imagePlacements, textAssignments };
|
||
}
|
||
|
||
for (const layer of template.layers) {
|
||
if (
|
||
isAgentPsdImageLayerSlot(layer) &&
|
||
layer.isDefaultResultSource &&
|
||
sourceImageUrl
|
||
) {
|
||
imageAssignments[layer.id] = sourceImageUrl;
|
||
imagePlacements[layer.id] = getDefaultAgentPsdImagePlacement();
|
||
}
|
||
if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) {
|
||
textAssignments[layer.id] = layer.defaultText;
|
||
}
|
||
}
|
||
|
||
return { imageAssignments, imagePlacements, textAssignments };
|
||
}
|
||
|
||
function 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 &&
|
||
((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>;
|
||
previousTextAssignments: Record<string, string>;
|
||
previousTextColorAssignments: Record<string, string>;
|
||
}) {
|
||
const {
|
||
nextTemplate,
|
||
previousTemplate,
|
||
sourceImageUrl,
|
||
previousImageAssignments,
|
||
previousImagePlacements,
|
||
previousTextAssignments,
|
||
previousTextColorAssignments,
|
||
} = params;
|
||
|
||
const initialState = createInitialState(nextTemplate, sourceImageUrl);
|
||
const nextState = {
|
||
imageAssignments: { ...initialState.imageAssignments },
|
||
imagePlacements: { ...initialState.imagePlacements },
|
||
textAssignments: { ...initialState.textAssignments },
|
||
textColorAssignments: {} as Record<string, string>,
|
||
};
|
||
|
||
if (!nextTemplate || !previousTemplate) {
|
||
return nextState;
|
||
}
|
||
|
||
const previousEditableImageLayers = previousTemplate.layers.filter(
|
||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||
layer.isEditable && isAgentPsdImageLayerSlot(layer)
|
||
);
|
||
const nextEditableImageLayers = nextTemplate.layers.filter(
|
||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||
layer.isEditable && 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 && isAgentPsdTextLayerSlot(layer)
|
||
);
|
||
const nextEditableTextLayers = nextTemplate.layers.filter(
|
||
(layer): layer is AgentPsdTemplateTextLayerSlot =>
|
||
layer.isEditable && 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;
|
||
}
|
||
|
||
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);
|
||
if (editableLayers.length === 0) {
|
||
return "";
|
||
}
|
||
|
||
const preferredImage = editableLayers.find(
|
||
(layer) => isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource
|
||
);
|
||
if (preferredImage) {
|
||
return preferredImage.id;
|
||
}
|
||
|
||
return editableLayers[0]?.id || "";
|
||
}
|
||
|
||
function getImageFit(
|
||
replaceMode: AgentPsdTemplateImageLayerSlot["replaceMode"]
|
||
): CSSProperties["objectFit"] {
|
||
if (replaceMode === "contain") return "contain";
|
||
if (replaceMode === "stretch") return "fill";
|
||
return "cover";
|
||
}
|
||
|
||
function getLayerFrameStyle(
|
||
layer: AgentPsdTemplateLayerSlot,
|
||
isSelected: boolean
|
||
): CSSProperties {
|
||
return {
|
||
left: layer.x,
|
||
top: layer.y,
|
||
width: layer.width,
|
||
height: layer.height,
|
||
border: `2px solid ${getLayerOutlineColor(layer)}`,
|
||
boxShadow: isSelected
|
||
? `0 0 0 3px rgba(255,255,255,0.16), 0 0 0 6px ${getLayerOutlineColor(layer)}55`
|
||
: `0 0 0 1px ${getLayerOutlineColor(layer)}66 inset`,
|
||
};
|
||
}
|
||
|
||
function getResolvedTextPreview(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value: string
|
||
) {
|
||
return value || layer.placeholder || layer.layerName;
|
||
}
|
||
|
||
function getResolvedTextLayerColor(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value?: string
|
||
) {
|
||
return normalizeHexColor(value || layer.textColor, layer.textColor);
|
||
}
|
||
|
||
function getTextLayerRenderFrame(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value: string
|
||
) {
|
||
const resolvedLayout = resolveTextLayout({
|
||
text: getResolvedTextPreview(layer, value),
|
||
width: layer.width,
|
||
height: layer.height,
|
||
fontSize: layer.fontSize,
|
||
lineHeight: layer.lineHeight,
|
||
letterSpacing: layer.letterSpacing,
|
||
paddingX: layer.paddingX,
|
||
paddingY: layer.paddingY,
|
||
maxLines: layer.maxLines,
|
||
autoFit: false,
|
||
preferNoWrap: true,
|
||
});
|
||
const width = Math.max(layer.width, resolvedLayout.contentWidth);
|
||
const height = Math.max(layer.height, resolvedLayout.contentHeight);
|
||
const offsetX =
|
||
layer.textAlign === "right"
|
||
? layer.width - width
|
||
: layer.textAlign === "center"
|
||
? (layer.width - width) / 2
|
||
: 0;
|
||
|
||
return {
|
||
...resolvedLayout,
|
||
width,
|
||
height,
|
||
offsetX,
|
||
offsetY: -resolvedLayout.visualOffsetY,
|
||
};
|
||
}
|
||
|
||
function renderTextLayerPreviewContent(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value: string,
|
||
color: string
|
||
) {
|
||
const resolvedText = getResolvedTextPreview(layer, value);
|
||
const resolvedLayout = getTextLayerRenderFrame(layer, resolvedText);
|
||
const textAnchor =
|
||
layer.textAlign === "center"
|
||
? "middle"
|
||
: layer.textAlign === "right"
|
||
? "end"
|
||
: "start";
|
||
const textX =
|
||
layer.textAlign === "center"
|
||
? resolvedLayout.width / 2
|
||
: layer.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,
|
||
}}
|
||
>
|
||
|
||
</div>
|
||
</foreignObject>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function getAssignedImageMaskStyle(
|
||
layer: AgentPsdTemplateImageLayerSlot
|
||
): CSSProperties {
|
||
const hasMask = Boolean(layer.maskUrl);
|
||
return {
|
||
WebkitMaskImage: hasMask ? `url(${layer.maskUrl})` : undefined,
|
||
maskImage: hasMask ? `url(${layer.maskUrl})` : undefined,
|
||
WebkitMaskSize: hasMask ? "100% 100%" : undefined,
|
||
maskSize: hasMask ? "100% 100%" : undefined,
|
||
WebkitMaskRepeat: hasMask ? "no-repeat" : undefined,
|
||
maskRepeat: hasMask ? "no-repeat" : undefined,
|
||
WebkitMaskPosition: hasMask ? "center" : undefined,
|
||
maskPosition: hasMask ? "center" : undefined,
|
||
};
|
||
}
|
||
|
||
function getPlacedAssignedImageStyle(params: {
|
||
layer: AgentPsdTemplateImageLayerSlot;
|
||
imageSize: AgentPsdImageSize;
|
||
placement: AgentPsdImagePlacement;
|
||
}): CSSProperties {
|
||
const { layer, imageSize, placement } = params;
|
||
const normalizedPlacement = normalizeAgentPsdImagePlacement(placement);
|
||
const baseFrame = resolveAgentPsdImageBaseFrame({
|
||
containerWidth: layer.width,
|
||
containerHeight: layer.height,
|
||
imageWidth: imageSize.width,
|
||
imageHeight: imageSize.height,
|
||
replaceMode: layer.replaceMode,
|
||
contentBounds: layer.contentBounds,
|
||
});
|
||
|
||
return {
|
||
position: "absolute",
|
||
left: baseFrame.centerX + normalizedPlacement.offsetX,
|
||
top: baseFrame.centerY + normalizedPlacement.offsetY,
|
||
width: baseFrame.width,
|
||
height: baseFrame.height,
|
||
transform: `translate(-50%, -50%) scale(${normalizedPlacement.scale})`,
|
||
transformOrigin: "center center",
|
||
objectFit: getImageFit(layer.replaceMode),
|
||
maxWidth: "none",
|
||
maxHeight: "none",
|
||
willChange: "transform",
|
||
};
|
||
}
|
||
|
||
function hasEditedTextValue(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value: string
|
||
) {
|
||
return (
|
||
String(value || "").replace(/\r\n/g, "\n") !==
|
||
String(layer.defaultText || "").replace(/\r\n/g, "\n")
|
||
);
|
||
}
|
||
|
||
function hasCustomizedTextLayerAppearance(
|
||
layer: AgentPsdTemplateTextLayerSlot,
|
||
value: string,
|
||
color: string
|
||
) {
|
||
return (
|
||
hasEditedTextValue(layer, value) ||
|
||
getResolvedTextLayerColor(layer, color) !== getResolvedTextLayerColor(layer)
|
||
);
|
||
}
|
||
|
||
function getTextLayerNameBadgeClassName() {
|
||
return "pointer-events-none absolute left-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-black/70 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg backdrop-blur-md";
|
||
}
|
||
|
||
function getTextLayerHintBadgeClassName() {
|
||
return "pointer-events-none absolute right-0 top-0 -translate-y-[calc(100%+6px)] rounded-full bg-sky-500/90 px-2 py-1 text-[11px] whitespace-nowrap text-white shadow-lg backdrop-blur-md";
|
||
}
|
||
|
||
export function AgentPsdTemplateEditorDialog({
|
||
open,
|
||
onOpenChange,
|
||
sourceImageUrl,
|
||
brandName,
|
||
selectedCategoryNames,
|
||
sizeInfoText,
|
||
templateMode,
|
||
onDerivedImageCreated,
|
||
onRenderedResultCreated,
|
||
}: {
|
||
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;
|
||
}) {
|
||
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 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) =>
|
||
matchesTemplateSelectedCategories(
|
||
template,
|
||
normalizedSelectedCategoryNames,
|
||
activeCategoryTags
|
||
)
|
||
),
|
||
[activeCategoryTags, allTemplates, 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 [textAssignments, setTextAssignments] = useState<
|
||
Record<string, string>
|
||
>({});
|
||
const [textColorAssignments, setTextColorAssignments] = useState<
|
||
Record<string, 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 previewViewportRef = useRef<HTMLDivElement | null>(null);
|
||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||
const previousSelectedTemplateRef = useRef<AgentPsdTemplate | null>(null);
|
||
const imageDragStateRef = useRef<{
|
||
layerId: string;
|
||
pointerId: number;
|
||
startClientX: number;
|
||
startClientY: number;
|
||
startPlacement: AgentPsdImagePlacement;
|
||
} | null>(null);
|
||
|
||
const imageAssignmentsRef = useRef(imageAssignments);
|
||
const imagePlacementsRef = useRef(imagePlacements);
|
||
const textAssignmentsRef = useRef(textAssignments);
|
||
const textColorAssignmentsRef = useRef(textColorAssignments);
|
||
const selectedLayerIdRef = useRef(selectedLayerId);
|
||
|
||
imageAssignmentsRef.current = imageAssignments;
|
||
imagePlacementsRef.current = imagePlacements;
|
||
textAssignmentsRef.current = textAssignments;
|
||
textColorAssignmentsRef.current = textColorAssignments;
|
||
selectedLayerIdRef.current = selectedLayerId;
|
||
|
||
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 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 && isAgentPsdImageLayerSlot(layer)
|
||
),
|
||
[selectedTemplateLayers]
|
||
);
|
||
const editableTextLayers = useMemo(
|
||
() =>
|
||
selectedTemplateLayers.filter(
|
||
(layer): layer is AgentPsdTemplateTextLayerSlot =>
|
||
layer.isEditable && isAgentPsdTextLayerSlot(layer)
|
||
),
|
||
[selectedTemplateLayers]
|
||
);
|
||
const orderedPreviewLayers = useMemo(
|
||
() => [...selectedTemplateLayers].reverse(),
|
||
[selectedTemplateLayers]
|
||
);
|
||
const orderedEditableLayers = useMemo(
|
||
() => orderedPreviewLayers.filter((layer) => layer.isEditable),
|
||
[orderedPreviewLayers]
|
||
);
|
||
const orderedEditableImageLayers = useMemo(
|
||
() =>
|
||
orderedEditableLayers.filter(
|
||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||
isAgentPsdImageLayerSlot(layer)
|
||
),
|
||
[orderedEditableLayers]
|
||
);
|
||
const orderedEditableTextLayers = useMemo(
|
||
() =>
|
||
orderedEditableLayers.filter(
|
||
(layer): layer is AgentPsdTemplateTextLayerSlot =>
|
||
isAgentPsdTextLayerSlot(layer)
|
||
),
|
||
[orderedEditableLayers]
|
||
);
|
||
const shouldShowCompositeFallback = useMemo(
|
||
() =>
|
||
orderedPreviewLayers.length === 0 ||
|
||
orderedPreviewLayers.some(
|
||
(layer) => !String(layer.sourceUrl || "").trim()
|
||
),
|
||
[orderedPreviewLayers]
|
||
);
|
||
|
||
const effectiveImageAssignments = useMemo(() => {
|
||
const nextAssignments: Record<string, string> = {};
|
||
if (!selectedTemplate) {
|
||
return nextAssignments;
|
||
}
|
||
|
||
for (const layer of editableImageLayers) {
|
||
const explicit = String(imageAssignments[layer.id] || "").trim();
|
||
if (explicit) {
|
||
nextAssignments[layer.id] = explicit;
|
||
continue;
|
||
}
|
||
|
||
if (layer.isDefaultResultSource && sourceImageUrl?.trim()) {
|
||
nextAssignments[layer.id] = sourceImageUrl.trim();
|
||
}
|
||
}
|
||
|
||
return nextAssignments;
|
||
}, [editableImageLayers, imageAssignments, selectedTemplate, sourceImageUrl]);
|
||
|
||
const effectiveTextAssignments = useMemo(() => {
|
||
const nextAssignments: Record<string, string> = {};
|
||
if (!selectedTemplate) {
|
||
return nextAssignments;
|
||
}
|
||
|
||
for (const layer of editableTextLayers) {
|
||
const explicit = textAssignments[layer.id];
|
||
nextAssignments[layer.id] =
|
||
typeof explicit === "string"
|
||
? explicit
|
||
: String(layer.defaultText || "");
|
||
}
|
||
|
||
return nextAssignments;
|
||
}, [editableTextLayers, selectedTemplate, textAssignments]);
|
||
const effectiveTextColorAssignments = useMemo(() => {
|
||
const nextAssignments: Record<string, string> = {};
|
||
if (!selectedTemplate) {
|
||
return nextAssignments;
|
||
}
|
||
|
||
for (const layer of editableTextLayers) {
|
||
nextAssignments[layer.id] = getResolvedTextLayerColor(
|
||
layer,
|
||
textColorAssignments[layer.id]
|
||
);
|
||
}
|
||
|
||
return nextAssignments;
|
||
}, [editableTextLayers, selectedTemplate, textColorAssignments]);
|
||
|
||
const effectiveImagePlacements = useMemo(() => {
|
||
const nextPlacements: Record<string, AgentPsdImagePlacement> = {};
|
||
if (!selectedTemplate) {
|
||
return nextPlacements;
|
||
}
|
||
|
||
for (const layer of editableImageLayers) {
|
||
if (!String(effectiveImageAssignments[layer.id] || "").trim()) {
|
||
continue;
|
||
}
|
||
nextPlacements[layer.id] = normalizeAgentPsdImagePlacement(
|
||
imagePlacements[layer.id]
|
||
);
|
||
}
|
||
|
||
return nextPlacements;
|
||
}, [
|
||
editableImageLayers,
|
||
effectiveImageAssignments,
|
||
imagePlacements,
|
||
selectedTemplate,
|
||
]);
|
||
|
||
const selectedLayer = useMemo(
|
||
() =>
|
||
selectedTemplateLayers.find((layer) => layer.id === selectedLayerId) ||
|
||
null,
|
||
[selectedLayerId, selectedTemplateLayers]
|
||
);
|
||
const selectedImageLayer =
|
||
selectedLayer && isAgentPsdImageLayerSlot(selectedLayer)
|
||
? selectedLayer
|
||
: null;
|
||
const selectedTextLayer =
|
||
selectedLayer && isAgentPsdTextLayerSlot(selectedLayer)
|
||
? selectedLayer
|
||
: null;
|
||
const selectedImageAssignmentUrl = selectedImageLayer
|
||
? String(effectiveImageAssignments[selectedImageLayer.id] || "").trim()
|
||
: "";
|
||
const selectedImagePlacement = selectedImageLayer
|
||
? normalizeAgentPsdImagePlacement(
|
||
effectiveImagePlacements[selectedImageLayer.id]
|
||
)
|
||
: getDefaultAgentPsdImagePlacement();
|
||
const selectedTextColor = selectedTextLayer
|
||
? effectiveTextColorAssignments[selectedTextLayer.id] ||
|
||
selectedTextLayer.textColor
|
||
: "#111111";
|
||
const isSelectedImageExtending = Boolean(
|
||
selectedImageLayer && extendingSlotId === selectedImageLayer.id
|
||
);
|
||
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="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 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({});
|
||
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,
|
||
previousTextAssignments: textAssignmentsRef.current,
|
||
previousTextColorAssignments: textColorAssignmentsRef.current,
|
||
});
|
||
setImageAssignments(initialState.imageAssignments);
|
||
setImagePlacements(initialState.imagePlacements);
|
||
setTextAssignments(initialState.textAssignments);
|
||
setTextColorAssignments(initialState.textColorAssignments);
|
||
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]);
|
||
|
||
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 套版图片拓展处理",
|
||
}),
|
||
});
|
||
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 resetImagePlacement(layerId: string) {
|
||
setImagePlacements((current) => ({
|
||
...current,
|
||
[layerId]: getDefaultAgentPsdImagePlacement(),
|
||
}));
|
||
}
|
||
|
||
function handleImageDragStart(
|
||
layer: AgentPsdTemplateImageLayerSlot,
|
||
event: ReactPointerEvent<HTMLDivElement>
|
||
) {
|
||
if (!String(effectiveImageAssignments[layer.id] || "").trim()) {
|
||
return;
|
||
}
|
||
|
||
imageDragStateRef.current = {
|
||
layerId: layer.id,
|
||
pointerId: event.pointerId,
|
||
startClientX: event.clientX,
|
||
startClientY: event.clientY,
|
||
startPlacement: normalizeAgentPsdImagePlacement(
|
||
effectiveImagePlacements[layer.id]
|
||
),
|
||
};
|
||
|
||
const nextTarget = event.currentTarget;
|
||
try {
|
||
nextTarget.setPointerCapture?.(event.pointerId);
|
||
} catch (error) {
|
||
console.warn(
|
||
"[AgentPsdTemplateEditorDialog] setPointerCapture failed:",
|
||
error
|
||
);
|
||
}
|
||
|
||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||
const dragState = imageDragStateRef.current;
|
||
if (!dragState || dragState.layerId !== layer.id) {
|
||
return;
|
||
}
|
||
|
||
updateImagePlacement(layer.id, () => ({
|
||
...dragState.startPlacement,
|
||
offsetX:
|
||
dragState.startPlacement.offsetX +
|
||
(moveEvent.clientX - dragState.startClientX) / stageScale,
|
||
offsetY:
|
||
dragState.startPlacement.offsetY +
|
||
(moveEvent.clientY - dragState.startClientY) / stageScale,
|
||
}));
|
||
};
|
||
|
||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||
if (imageDragStateRef.current?.pointerId === upEvent.pointerId) {
|
||
imageDragStateRef.current = null;
|
||
}
|
||
window.removeEventListener("pointermove", handlePointerMove);
|
||
window.removeEventListener("pointerup", handlePointerUp);
|
||
window.removeEventListener("pointercancel", handlePointerUp);
|
||
};
|
||
|
||
window.addEventListener("pointermove", handlePointerMove);
|
||
window.addEventListener("pointerup", handlePointerUp);
|
||
window.addEventListener("pointercancel", handlePointerUp);
|
||
}
|
||
|
||
async function handleRender() {
|
||
if (!selectedTemplate) {
|
||
toast.error("请先选择 PSD 模板");
|
||
return;
|
||
}
|
||
if (selectedTemplate.layers.length === 0) {
|
||
toast.error("模板详情仍在加载,请稍候再试");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setIsRendering(true);
|
||
setRenderedResultUrl("");
|
||
const payload = (await requestLongTaskResult({
|
||
endpoint: "/api/psd-templates/render",
|
||
timeoutMs: 8 * 60 * 1000,
|
||
payload: {
|
||
templateId: selectedTemplate.id,
|
||
sourceImageUrl: sourceImageUrl || "",
|
||
imageAssignments: effectiveImageAssignments,
|
||
imagePlacements: effectiveImagePlacements,
|
||
textAssignments: effectiveTextAssignments,
|
||
textColorAssignments: effectiveTextColorAssignments,
|
||
},
|
||
})) 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);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<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);
|
||
}
|
||
`,
|
||
}}
|
||
/>
|
||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||
<DialogContent
|
||
className="flex h-[96vh] w-[96vw] max-w-[1700px] flex-col overflow-hidden rounded-[24px] border border-white/12 bg-[#0a0a0a] p-0 text-white shadow-[0_40px_100px_rgba(0,0,0,0.8)]"
|
||
hideCloseButton
|
||
onEscapeKeyDown={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onInteractOutside={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onPointerDownOutside={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
overlayClassName="bg-black/80 backdrop-blur-md"
|
||
>
|
||
{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">
|
||
<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>
|
||
<h2 className="font-semibold text-base text-white tracking-wide">
|
||
PSD 模板编辑器
|
||
</h2>
|
||
</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" />
|
||
)}
|
||
应用并导出
|
||
</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] p-5 xl:border-r xl:border-b-0">
|
||
<ScrollArea className="h-full pr-4">
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
{selectedTemplate ? (
|
||
<div className="mb-4 flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="font-semibold text-base text-white">
|
||
选择模板
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="mb-4 flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="font-semibold text-base text-white">
|
||
主图模式模板
|
||
</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">
|
||
{mainModeTemplates.length} 个模板
|
||
</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}
|
||
{selectedTemplate && 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">
|
||
{selectedTemplate ? (
|
||
<div className="min-w-0 flex-1 pr-3">
|
||
{renderTemplateSelectContent(selectedTemplate, {
|
||
showSecondaryCategory:
|
||
shouldGroupTemplatesBySecondaryCategory,
|
||
showBrand: shouldShowTemplateBrand,
|
||
})}
|
||
</div>
|
||
) : (
|
||
<SelectValue
|
||
placeholder={
|
||
isLoading ? "加载模板中..." : "请选择模板"
|
||
}
|
||
/>
|
||
)}
|
||
</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>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{isLoading ? (
|
||
<>
|
||
{Array.from({ length: 2 }).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={`main-template-loading-${index}`}
|
||
>
|
||
正在加载主图模式模板...
|
||
</div>
|
||
))}
|
||
</>
|
||
) : mainModeTemplates.length > 0 ? (
|
||
mainModeTemplates.map((template, index) => (
|
||
<button
|
||
className={`w-full 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 < 2,
|
||
})}
|
||
</button>
|
||
))
|
||
) : (
|
||
<div className="flex 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/50 shadow-inner">
|
||
{selectedCategorySummaryText
|
||
? `当前品类“${selectedCategorySummaryText}”下暂无主图模式模板`
|
||
: normalizedBrandName
|
||
? `当前品牌“${normalizedBrandName}”下暂无主图模式模板`
|
||
: "当前暂无可用的主图模式模板,请先去后台配置。"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{selectedTemplate ? (
|
||
<>
|
||
<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)}`,
|
||
}}
|
||
>
|
||
<img
|
||
alt={selectedTemplate.name}
|
||
className="h-full w-full object-contain"
|
||
decoding="async"
|
||
src={buildTemplatePreviewThumbUrl(
|
||
selectedTemplate.previewUrl,
|
||
{
|
||
width: 440,
|
||
quality: 78,
|
||
}
|
||
)}
|
||
/>
|
||
</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 className="flex h-full w-full 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/50 shadow-inner">
|
||
{isLoading
|
||
? "正在加载可用模板..."
|
||
: hasTemplateOptions
|
||
? "请从左侧主图模式模板或右侧 SKU 图模式模板中选择一个模板开始编辑。"
|
||
: selectedCategorySummaryText && normalizedBrandName
|
||
? `当前品牌“${normalizedBrandName}”下没有命中品类“${selectedCategorySummaryText}”的 PSD 模板,请先去后台配置。`
|
||
: selectedCategorySummaryText
|
||
? `当前品类“${selectedCategorySummaryText}”下还没有可用 PSD 模板,请先去后台配置。`
|
||
: normalizedBrandName
|
||
? `当前品牌“${normalizedBrandName}”下还没有可用 PSD 模板,请先去后台配置。`
|
||
: "当前还没有启用的 PSD 模板,请先去后台配置。"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-col border-white/12 border-b bg-[#0a0a0a] p-6 xl:border-r xl:border-b-0">
|
||
<div
|
||
className="psd-preview-viewport relative flex min-h-0 flex-1 items-center justify-center overflow-auto rounded-[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="flex h-full w-full 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/50 shadow-inner">
|
||
{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
|
||
className="relative shrink-0"
|
||
style={{
|
||
width: selectedTemplate.width * stageScale,
|
||
height: selectedTemplate.height * stageScale,
|
||
}}
|
||
>
|
||
<div
|
||
className="absolute top-0 left-0 overflow-hidden rounded-[20px] shadow-2xl"
|
||
style={{
|
||
width: selectedTemplate.width,
|
||
height: selectedTemplate.height,
|
||
transform: `scale(${stageScale})`,
|
||
transformOrigin: "top left",
|
||
}}
|
||
>
|
||
{shouldShowCompositeFallback ? (
|
||
<img
|
||
alt={`${selectedTemplate.name} 编辑底图`}
|
||
className="absolute inset-0 h-full w-full object-cover"
|
||
decoding="async"
|
||
src={buildTemplatePreviewThumbUrl(
|
||
selectedTemplate.renderBaseUrl ||
|
||
selectedTemplate.previewUrl,
|
||
{
|
||
width: 1280,
|
||
quality: 80,
|
||
}
|
||
)}
|
||
/>
|
||
) : null}
|
||
|
||
<div className="pointer-events-none absolute inset-0">
|
||
{orderedPreviewLayers.map((layer) => {
|
||
const layerBoxStyle = {
|
||
left: layer.x,
|
||
top: layer.y,
|
||
width: layer.width,
|
||
height: layer.height,
|
||
} satisfies CSSProperties;
|
||
|
||
if (isAgentPsdImageLayerSlot(layer)) {
|
||
const assignedUrl =
|
||
effectiveImageAssignments[layer.id];
|
||
const layerImageUrl =
|
||
assignedUrl || layer.sourceUrl;
|
||
if (!layerImageUrl) {
|
||
return null;
|
||
}
|
||
const assignedImageContainerStyle = assignedUrl
|
||
? {
|
||
...layerBoxStyle,
|
||
...getAssignedImageMaskStyle(layer),
|
||
}
|
||
: layerBoxStyle;
|
||
|
||
return (
|
||
<div
|
||
className="absolute overflow-hidden"
|
||
key={layer.id}
|
||
style={assignedImageContainerStyle}
|
||
>
|
||
<img
|
||
alt={layer.layerName}
|
||
className={assignedUrl ? "" : "h-full w-full"}
|
||
src={layerImageUrl}
|
||
style={
|
||
assignedUrl && imageSizes[layer.id]
|
||
? getPlacedAssignedImageStyle({
|
||
layer,
|
||
imageSize: imageSizes[layer.id],
|
||
placement:
|
||
effectiveImagePlacements[layer.id],
|
||
})
|
||
: assignedUrl
|
||
? {
|
||
width: "100%",
|
||
height: "100%",
|
||
objectFit: getImageFit(
|
||
layer.replaceMode
|
||
),
|
||
}
|
||
: {
|
||
width: "100%",
|
||
height: "100%",
|
||
objectFit: "fill",
|
||
}
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isSelected = selectedLayerId === layer.id;
|
||
if (isSelected) {
|
||
return null;
|
||
}
|
||
|
||
const value =
|
||
effectiveTextAssignments[layer.id] || "";
|
||
const resolvedTextColor =
|
||
effectiveTextColorAssignments[layer.id] ||
|
||
layer.textColor;
|
||
const shouldUseEditedPreview =
|
||
hasCustomizedTextLayerAppearance(
|
||
layer,
|
||
value,
|
||
resolvedTextColor
|
||
);
|
||
|
||
if (!shouldUseEditedPreview && layer.sourceUrl) {
|
||
return (
|
||
<div
|
||
className="absolute overflow-hidden"
|
||
key={layer.id}
|
||
style={layerBoxStyle}
|
||
>
|
||
<img
|
||
alt={layer.layerName}
|
||
className="h-full w-full object-fill"
|
||
src={layer.sourceUrl}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!value && !layer.placeholder) {
|
||
return null;
|
||
}
|
||
|
||
const textFrame = getTextLayerRenderFrame(
|
||
layer,
|
||
value || layer.placeholder || layer.layerName
|
||
);
|
||
|
||
return (
|
||
<div
|
||
className="absolute overflow-visible"
|
||
key={layer.id}
|
||
style={{
|
||
left: layer.x + textFrame.offsetX,
|
||
top: layer.y + textFrame.offsetY,
|
||
width: textFrame.width,
|
||
height: textFrame.height,
|
||
}}
|
||
>
|
||
{renderTextLayerPreviewContent(
|
||
layer,
|
||
value || layer.placeholder || layer.layerName,
|
||
resolvedTextColor
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="absolute inset-0">
|
||
{orderedEditableImageLayers.map((layer) => {
|
||
const isSelected = selectedLayerId === layer.id;
|
||
const frameStyle = getLayerFrameStyle(
|
||
layer,
|
||
isSelected
|
||
);
|
||
const hasAssignedImage = Boolean(
|
||
String(
|
||
effectiveImageAssignments[layer.id] || ""
|
||
).trim()
|
||
);
|
||
|
||
return isSelected && hasAssignedImage ? (
|
||
<div
|
||
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
|
||
className="absolute overflow-hidden rounded-lg transition-all"
|
||
key={layer.id}
|
||
onClick={() => setSelectedLayerId(layer.id)}
|
||
onPointerDown={(event) =>
|
||
handleImageDragStart(layer, event)
|
||
}
|
||
role="button"
|
||
style={{
|
||
...frameStyle,
|
||
zIndex: 20,
|
||
background: "rgba(249,115,22,0.08)",
|
||
cursor:
|
||
imageDragStateRef.current?.layerId ===
|
||
layer.id
|
||
? "grabbing"
|
||
: "grab",
|
||
touchAction: "none",
|
||
}}
|
||
tabIndex={0}
|
||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||
>
|
||
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
|
||
{layer.layerName}
|
||
</div>
|
||
<div className="pointer-events-none absolute right-2 bottom-2 flex items-center gap-1 rounded-full bg-orange-500/90 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
|
||
<Move className="h-3 w-3" />
|
||
拖动调整
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<button
|
||
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
|
||
className="absolute overflow-hidden rounded-lg transition-all"
|
||
key={layer.id}
|
||
onClick={() => setSelectedLayerId(layer.id)}
|
||
style={{
|
||
...frameStyle,
|
||
zIndex: isSelected ? 20 : 10,
|
||
background: isSelected
|
||
? "rgba(249,115,22,0.08)"
|
||
: "transparent",
|
||
}}
|
||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||
type="button"
|
||
>
|
||
<div className="absolute top-2 left-2 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
|
||
{layer.layerName}
|
||
</div>
|
||
|
||
{hasAssignedImage ? null : (
|
||
<div className="pointer-events-none absolute right-2 bottom-2 rounded-full bg-orange-500/90 px-2 py-1 text-[11px] text-white shadow-md backdrop-blur-md">
|
||
点击替换图片
|
||
</div>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
|
||
{orderedEditableImageLayers.map((layer) => {
|
||
const isSelected = selectedLayerId === layer.id;
|
||
const hasAssignedImage = Boolean(
|
||
String(
|
||
effectiveImageAssignments[layer.id] || ""
|
||
).trim()
|
||
);
|
||
if (!isSelected || !hasAssignedImage) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="absolute flex items-center gap-1 rounded-full border border-white/10 bg-black/80 p-1 text-white shadow-xl backdrop-blur-md"
|
||
key={`${layer.id}-controls`}
|
||
style={{
|
||
left: layer.x + layer.width - 112,
|
||
top: Math.max(8, layer.y - 44),
|
||
zIndex: 25,
|
||
}}
|
||
>
|
||
<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) => {
|
||
const isSelected = selectedLayerId === layer.id;
|
||
const frameStyle = getLayerFrameStyle(
|
||
layer,
|
||
isSelected
|
||
);
|
||
const currentValue =
|
||
effectiveTextAssignments[layer.id] || "";
|
||
const resolvedTextColor =
|
||
effectiveTextColorAssignments[layer.id] ||
|
||
layer.textColor;
|
||
const preserveSourceAppearance =
|
||
!!layer.sourceUrl &&
|
||
!hasCustomizedTextLayerAppearance(
|
||
layer,
|
||
currentValue,
|
||
resolvedTextColor
|
||
);
|
||
const editedTextFrame = getTextLayerRenderFrame(
|
||
layer,
|
||
currentValue
|
||
);
|
||
|
||
if (isSelected) {
|
||
return (
|
||
<div
|
||
className="absolute overflow-visible rounded-lg"
|
||
key={layer.id}
|
||
style={{
|
||
...frameStyle,
|
||
zIndex: 40,
|
||
background: "transparent",
|
||
}}
|
||
>
|
||
{preserveSourceAppearance && layer.sourceUrl ? (
|
||
<img
|
||
alt={layer.layerName}
|
||
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
|
||
src={layer.sourceUrl}
|
||
/>
|
||
) : null}
|
||
{preserveSourceAppearance ? null : (
|
||
<div
|
||
className="absolute"
|
||
style={{
|
||
left: editedTextFrame.offsetX,
|
||
top: editedTextFrame.offsetY,
|
||
width: editedTextFrame.width,
|
||
height: editedTextFrame.height,
|
||
}}
|
||
>
|
||
{renderTextLayerPreviewContent(
|
||
layer,
|
||
currentValue,
|
||
resolvedTextColor
|
||
)}
|
||
</div>
|
||
)}
|
||
<div
|
||
className={getTextLayerNameBadgeClassName()}
|
||
>
|
||
{layer.layerName}
|
||
</div>
|
||
<div className={getTextLayerHintBadgeClassName()}>
|
||
请在右侧面板编辑
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<button
|
||
aria-label={`${getLayerLabel(layer)} ${layer.layerName}`}
|
||
className="absolute overflow-visible rounded-lg transition-all"
|
||
key={layer.id}
|
||
onClick={() => setSelectedLayerId(layer.id)}
|
||
onDoubleClick={() => setSelectedLayerId(layer.id)}
|
||
style={{
|
||
...frameStyle,
|
||
zIndex: isSelected ? 40 : 30,
|
||
background: isSelected
|
||
? "rgba(56,189,248,0.05)"
|
||
: "transparent",
|
||
}}
|
||
title={`${getLayerLabel(layer)}:${layer.layerName}`}
|
||
type="button"
|
||
>
|
||
<div className={getTextLayerNameBadgeClassName()}>
|
||
{layer.layerName}
|
||
</div>
|
||
|
||
<div className={getTextLayerHintBadgeClassName()}>
|
||
点击编辑文字
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
) : (
|
||
<div className="flex h-full w-full flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-5">
|
||
<div className="mb-4 flex items-center justify-between gap-3">
|
||
<div>
|
||
<div className="font-semibold text-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"
|
||
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}
|
||
/>
|
||
) : selectedImageLayer ? (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
{selectedImageAdjustmentPanel}
|
||
</div>
|
||
) : selectedTextLayer ? (
|
||
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/10 bg-white/[0.02] p-4 shadow-inner xl:p-5">
|
||
<div className="flex h-full flex-col gap-4">
|
||
<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>
|
||
{textColorConfig.enabled ? (
|
||
<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-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={() =>
|
||
setTextColorAssignments((current) => ({
|
||
...current,
|
||
[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>
|
||
) : null}
|
||
</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>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|