perf: speed up PSD template loading
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-10 22:00:28 +08:00
parent 10dc87eaf7
commit df6694de5d
3 changed files with 470 additions and 89 deletions

View File

@ -0,0 +1,97 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import {
type AgentPsdTemplateMutationResponse,
getEditableAgentPsdTemplateLayers,
isAgentPsdTemplateReady,
isAgentPsdTemplateRuntimeReady,
isAgentPsdTextLayerSlot,
} from "@/lib/agents/psd-template-config";
import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-repair";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function jsonError(status: number, error: string) {
const payload: AgentPsdTemplateMutationResponse = {
success: false,
error,
};
return NextResponse.json(payload, { status });
}
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> }
) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
return jsonError(401, "未登录");
}
try {
const { id } = await context.params;
const [templates, fonts] = await Promise.all([
readAgentPsdTemplates(),
readAgentPsdFonts().catch(
() => [] as Awaited<ReturnType<typeof readAgentPsdFonts>>
),
]);
const fontMap = new Map(fonts.map((font) => [font.id, font]));
const currentTemplate = templates.find(
(candidateTemplate) => candidateTemplate.id === id
);
if (!currentTemplate || currentTemplate.status !== "active") {
return jsonError(404, "未找到 PSD 模板");
}
if (!isAgentPsdTemplateReady(currentTemplate)) {
return jsonError(409, "PSD 模板尚未完成解析");
}
if (!isAgentPsdTemplateRuntimeReady(currentTemplate)) {
return jsonError(409, "PSD 模板资源尚未准备完成,请稍后重试");
}
if (getEditableAgentPsdTemplateLayers(currentTemplate).length === 0) {
return jsonError(409, "当前模板没有可编辑图层");
}
const template = repairAgentPsdTemplateFontBindings(currentTemplate, fonts);
const normalizedTemplate = {
...template,
layers: template.layers.map((layer) => {
if (!isAgentPsdTextLayerSlot(layer) || !layer.fontId) {
return layer;
}
const font = fontMap.get(layer.fontId);
if (!font) {
return layer;
}
return {
...layer,
fontFileUrl: font.fileUrl,
fontContentType: font.contentType,
};
}),
};
const response = NextResponse.json({
success: true,
template: normalizedTemplate,
} satisfies AgentPsdTemplateMutationResponse);
response.headers.set(
"Cache-Control",
"private, max-age=60, stale-while-revalidate=300"
);
return response;
} catch (error) {
console.error("[PSD Templates] detail failed:", error);
return jsonError(503, "PSD 模板详情读取失败");
}
}

View File

@ -1,35 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { systemSettings } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import {
AGENT_PSD_TEMPLATE_CATEGORY_TAG_CONFIG_KEY,
defaultAgentPsdTemplateCategoryTagConfig,
normalizeAgentPsdTemplateCategoryTagConfig,
} from "@/lib/agents/psd-template-category-tag-config";
import {
type AgentPsdTemplate,
type AgentPsdTemplateListResponse,
getEditableAgentPsdTemplateLayers,
isAgentPsdTextLayerSlot,
isAgentPsdTemplateReady,
isAgentPsdTemplateRuntimeReady,
isAgentPsdTextLayerSlot,
normalizeAgentPsdTemplateMode,
type AgentPsdTemplateListResponse,
} from "@/lib/agents/psd-template-config";
import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-repair";
import {
AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY,
defaultAgentPsdTemplateSecondaryCategoryConfig,
normalizeAgentPsdTemplateSecondaryCategoryConfig,
} from "@/lib/agents/psd-template-secondary-category-config";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import {
AGENT_PSD_TEXT_COLOR_CONFIG_KEY,
defaultAgentPsdTextColorConfig,
normalizeAgentPsdTextColorConfig,
resolveAgentPsdTextColorConfigForBrand,
} from "@/lib/agents/psd-text-color-config";
import {
AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY,
defaultAgentPsdTemplateSecondaryCategoryConfig,
normalizeAgentPsdTemplateSecondaryCategoryConfig,
} from "@/lib/agents/psd-template-secondary-category-config";
import {
AGENT_PSD_TEMPLATE_CATEGORY_TAG_CONFIG_KEY,
defaultAgentPsdTemplateCategoryTagConfig,
normalizeAgentPsdTemplateCategoryTagConfig,
} from "@/lib/agents/psd-template-category-tag-config";
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
import { repairAgentPsdTemplateFontBindings } from "@/lib/agents/psd-template-repair";
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
import { db } from "@/lib/db";
import { systemSettings } from "@/lib/db/schema";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@ -43,6 +44,13 @@ function jsonError(status: number, error: string) {
return NextResponse.json(payload, { status });
}
function toPublicTemplateSummary(template: AgentPsdTemplate): AgentPsdTemplate {
return {
...template,
layers: [],
};
}
export async function GET(request: NextRequest) {
const session = await auth().catch(() => null);
if (!session?.user?.id) {
@ -50,14 +58,24 @@ export async function GET(request: NextRequest) {
}
try {
const requestedBrand = String(request.nextUrl.searchParams.get("brand") || "").trim();
const requestedModeRaw = String(request.nextUrl.searchParams.get("mode") || "").trim();
const requestedBrand = String(
request.nextUrl.searchParams.get("brand") || ""
).trim();
const requestedModeRaw = String(
request.nextUrl.searchParams.get("mode") || ""
).trim();
const summaryOnly =
String(request.nextUrl.searchParams.get("summary") || "").trim() === "1";
const requestedMode =
requestedModeRaw === "main" || requestedModeRaw === "sku"
? normalizeAgentPsdTemplateMode(requestedModeRaw)
: null;
const [allTemplates, textColorSetting, secondaryCategorySetting, categoryTagSetting] =
await Promise.all([
const [
allTemplates,
textColorSetting,
secondaryCategorySetting,
categoryTagSetting,
] = await Promise.all([
readAgentPsdTemplates(),
db
.select()
@ -69,34 +87,49 @@ export async function GET(request: NextRequest) {
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY))
.where(
eq(
systemSettings.key,
AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY
)
)
.limit(1)
.then((rows) => rows[0])
.catch(() => null),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, AGENT_PSD_TEMPLATE_CATEGORY_TAG_CONFIG_KEY))
.where(
eq(systemSettings.key, AGENT_PSD_TEMPLATE_CATEGORY_TAG_CONFIG_KEY)
)
.limit(1)
.then((rows) => rows[0])
.catch(() => null),
]);
const fonts = (await readAgentPsdFonts().catch(
() => [] as Awaited<ReturnType<typeof readAgentPsdFonts>>
)) as Awaited<ReturnType<typeof readAgentPsdFonts>>;
const fontMap = new Map(fonts.map((font) => [font.id, font]));
const templates =
allTemplates
let templates = allTemplates
.filter((template) => template.status === "active")
.filter(isAgentPsdTemplateReady)
.filter((template) =>
requestedBrand ? template.brandName === requestedBrand : true
)
.filter((template) => (requestedMode ? template.mode === requestedMode : true))
.filter((template) =>
requestedMode ? template.mode === requestedMode : true
)
.filter(isAgentPsdTemplateRuntimeReady)
.filter((template) => getEditableAgentPsdTemplateLayers(template).length > 0)
.map((template) => repairAgentPsdTemplateFontBindings(template, fonts))
.map((template) => ({
.filter(
(template) => getEditableAgentPsdTemplateLayers(template).length > 0
);
if (summaryOnly) {
templates = templates.map(toPublicTemplateSummary);
} else {
const fonts = (await readAgentPsdFonts().catch(
() => [] as Awaited<ReturnType<typeof readAgentPsdFonts>>
)) as Awaited<ReturnType<typeof readAgentPsdFonts>>;
const fontMap = new Map(fonts.map((font) => [font.id, font]));
templates = templates
.map((template) => repairAgentPsdTemplateFontBindings(template, fonts))
.map((template) => ({
...template,
layers: template.layers.map((layer) => {
if (!isAgentPsdTextLayerSlot(layer) || !layer.fontId) {
@ -115,12 +148,13 @@ export async function GET(request: NextRequest) {
};
}),
}));
}
const normalizedTextColorConfig = textColorSetting
? normalizeAgentPsdTextColorConfig(textColorSetting.value)
: defaultAgentPsdTextColorConfig;
return NextResponse.json({
const response = NextResponse.json({
success: true,
templates,
textColorConfig: requestedBrand
@ -130,12 +164,19 @@ export async function GET(request: NextRequest) {
)
: normalizedTextColorConfig,
secondaryCategoryConfig: secondaryCategorySetting
? normalizeAgentPsdTemplateSecondaryCategoryConfig(secondaryCategorySetting.value)
? normalizeAgentPsdTemplateSecondaryCategoryConfig(
secondaryCategorySetting.value
)
: defaultAgentPsdTemplateSecondaryCategoryConfig,
categoryTagConfig: categoryTagSetting
? normalizeAgentPsdTemplateCategoryTagConfig(categoryTagSetting.value)
: defaultAgentPsdTemplateCategoryTagConfig,
} satisfies AgentPsdTemplateListResponse);
response.headers.set(
"Cache-Control",
"private, max-age=60, stale-while-revalidate=300"
);
return response;
} catch (error) {
console.error("[PSD Templates] list failed:", error);
return jsonError(503, "PSD 模板读取失败");

View File

@ -23,7 +23,7 @@ import {
useState,
} from "react";
import { toast } from "sonner";
import useSWR from "swr";
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";
@ -60,6 +60,7 @@ import {
type AgentPsdTemplateLayerSlot,
type AgentPsdTemplateListResponse,
type AgentPsdTemplateMode,
type AgentPsdTemplateMutationResponse,
type AgentPsdTemplateRenderResponse,
type AgentPsdTemplateTextLayerSlot,
isAgentPsdImageLayerSlot,
@ -81,9 +82,27 @@ import {
} from "@/lib/agents/psd-text-layout";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
function buildTemplateListRequestUrl(params?: {
brandName?: string | null;
summaryOnly?: boolean;
}) {
const searchParams = new URLSearchParams();
if (params?.summaryOnly !== false) {
searchParams.set("summary", "1");
}
const normalizedBrandName = String(params?.brandName || "").trim();
if (normalizedBrandName) {
searchParams.set("brand", normalizedBrandName);
}
const queryString = searchParams.toString();
return queryString ? `/api/psd-templates?${queryString}` : "/api/psd-templates";
}
const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
try {
const response = await fetch(url, { cache: "no-store" });
const response = await fetch(url, { cache: "default" });
const payload = (await response
.json()
.catch(() => null)) as Partial<AgentPsdTemplateListResponse> | null;
@ -131,6 +150,38 @@ const fetcher = async (url: string): Promise<AgentPsdTemplateListResponse> => {
};
}
};
const templateDetailFetcher = async (
url: string
): Promise<AgentPsdTemplateMutationResponse> => {
try {
const response = await fetch(url, { cache: "default" });
const payload = (await response
.json()
.catch(() => null)) as Partial<AgentPsdTemplateMutationResponse> | null;
if (!response.ok || !payload || payload.success !== true || !payload.template) {
return {
success: false,
error:
typeof payload?.error === "string"
? payload.error
: "PSD 模板详情加载失败",
};
}
return {
success: true,
template: parseAgentPsdTemplates([payload.template])[0],
};
} catch {
return {
success: false,
error: "PSD 模板详情加载失败",
};
}
};
const PREVIEW_VIEWPORT_PADDING = 56;
const PREVIEW_STAGE_MAX_SCALE = 0.82;
const PREVIEW_STAGE_TARGET_WIDTH = 760;
@ -139,6 +190,28 @@ 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 === "未分配品牌") {
@ -316,7 +389,11 @@ function renderTemplateSelectContent(
<img
alt={template.name}
className="h-full w-full object-cover"
src={template.previewUrl}
decoding="async"
loading="lazy"
src={buildTemplatePreviewThumbUrl(template.previewUrl, {
width: compact ? 96 : 92,
})}
/>
</div>
<div className="min-w-0 text-left">
@ -352,11 +429,13 @@ function renderTemplatePickerCardContent(
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,
@ -369,7 +448,12 @@ function renderTemplatePickerCardContent(
<img
alt={template.name}
className="h-full w-full object-cover"
src={template.previewUrl}
decoding="async"
fetchPriority={eager ? "high" : "low"}
loading={eager ? "eager" : "lazy"}
src={buildTemplatePreviewThumbUrl(template.previewUrl, {
width: 112,
})}
/>
</div>
<div className="min-w-0">
@ -1000,20 +1084,21 @@ export function AgentPsdTemplateEditorDialog({
);
const normalizedTemplateMode =
templateMode === "sku" ? "sku" : templateMode === "main" ? "main" : "";
const templateListPrefetchUrl = useMemo(
() =>
buildTemplateListRequestUrl({
brandName: normalizedBrandName,
summaryOnly: true,
}),
[normalizedBrandName]
);
const templateListRequestUrl = useMemo(() => {
if (!open) {
return null;
}
const searchParams = new URLSearchParams();
if (normalizedBrandName) {
searchParams.set("brand", normalizedBrandName);
}
return searchParams.size > 0
? `/api/psd-templates?${searchParams.toString()}`
: "/api/psd-templates";
}, [open, normalizedBrandName]);
return templateListPrefetchUrl;
}, [open, templateListPrefetchUrl]);
const { data, isLoading } = useSWR<AgentPsdTemplateListResponse>(
templateListRequestUrl,
fetcher,
@ -1057,8 +1142,26 @@ export function AgentPsdTemplateEditorDialog({
),
[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>
@ -1107,11 +1210,32 @@ export function AgentPsdTemplateEditorDialog({
textColorAssignmentsRef.current = textColorAssignments;
selectedLayerIdRef.current = selectedLayerId;
const selectedTemplate = useMemo(
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(),
@ -1185,6 +1309,25 @@ export function AgentPsdTemplateEditorDialog({
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(
() =>
@ -1228,6 +1371,7 @@ export function AgentPsdTemplateEditorDialog({
);
const shouldShowCompositeFallback = useMemo(
() =>
orderedPreviewLayers.length === 0 ||
orderedPreviewLayers.some(
(layer) => !String(layer.sourceUrl || "").trim()
),
@ -1530,6 +1674,35 @@ export function AgentPsdTemplateEditorDialog({
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;
@ -1959,6 +2132,10 @@ export function AgentPsdTemplateEditorDialog({
toast.error("请先选择 PSD 模板");
return;
}
if (selectedTemplate.layers.length === 0) {
toast.error("模板详情仍在加载,请稍候再试");
return;
}
try {
setIsRendering(true);
@ -2076,10 +2253,14 @@ export function AgentPsdTemplateEditorDialog({
{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}
disabled={
isRendering ||
isSelectedTemplateLoading ||
selectedTemplateLayers.length === 0
}
onClick={handleRender}
>
{isRendering ? (
{isRendering || isSelectedTemplateLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
@ -2255,7 +2436,7 @@ export function AgentPsdTemplateEditorDialog({
))}
</>
) : mainModeTemplates.length > 0 ? (
mainModeTemplates.map((template) => (
mainModeTemplates.map((template, index) => (
<button
className={`w-full rounded-2xl border p-3 text-left transition-all duration-200 ${
selectedTemplateId === template.id
@ -2271,6 +2452,7 @@ export function AgentPsdTemplateEditorDialog({
shouldGroupTemplatesBySecondaryCategory,
showBrand: shouldShowTemplateBrand,
emphasize: true,
eager: index < 2,
})}
</button>
))
@ -2298,7 +2480,14 @@ export function AgentPsdTemplateEditorDialog({
<img
alt={selectedTemplate.name}
className="h-full w-full object-contain"
src={selectedTemplate.previewUrl}
decoding="async"
src={buildTemplatePreviewThumbUrl(
selectedTemplate.previewUrl,
{
width: 440,
quality: 78,
}
)}
/>
</div>
</div>
@ -2336,41 +2525,70 @@ export function AgentPsdTemplateEditorDialog({
ref={previewViewportRef}
>
{selectedTemplate ? (
<div
className="relative shrink-0"
style={{
width: selectedTemplate.width * stageScale,
height: selectedTemplate.height * stageScale,
}}
>
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="absolute top-0 left-0 overflow-hidden rounded-[20px] shadow-2xl"
className="relative shrink-0"
style={{
width: selectedTemplate.width,
height: selectedTemplate.height,
transform: `scale(${stageScale})`,
transformOrigin: "top left",
width: selectedTemplate.width * stageScale,
height: selectedTemplate.height * stageScale,
}}
>
{shouldShowCompositeFallback ? (
<img
alt={`${selectedTemplate.name} 编辑底图`}
className="absolute inset-0 h-full w-full object-cover"
src={
selectedTemplate.renderBaseUrl ||
selectedTemplate.previewUrl
}
/>
) : null}
<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;
<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 =
@ -2487,7 +2705,7 @@ export function AgentPsdTemplateEditorDialog({
})}
</div>
<div className="absolute inset-0">
<div className="absolute inset-0">
{orderedEditableImageLayers.map((layer) => {
const isSelected = selectedLayerId === layer.id;
const frameStyle = getLayerFrameStyle(
@ -2711,9 +2929,10 @@ export function AgentPsdTemplateEditorDialog({
</button>
);
})}
</div>
</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">
@ -2749,7 +2968,7 @@ export function AgentPsdTemplateEditorDialog({
</div>
) : skuModeTemplates.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{skuModeTemplates.map((template) => (
{skuModeTemplates.map((template, index) => (
<button
className={`rounded-2xl border p-3 text-left transition-all duration-200 ${
selectedTemplateId === template.id
@ -2765,6 +2984,7 @@ export function AgentPsdTemplateEditorDialog({
shouldGroupTemplatesBySecondaryCategory,
showBrand: shouldShowTemplateBrand,
emphasize: true,
eager: index < 4,
})}
</button>
))}
@ -2953,6 +3173,29 @@ export function AgentPsdTemplateEditorDialog({
) : 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">