perf: speed up PSD template loading
This commit is contained in:
parent
10dc87eaf7
commit
df6694de5d
97
app/api/psd-templates/[id]/route.ts
Normal file
97
app/api/psd-templates/[id]/route.ts
Normal 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 模板详情读取失败");
|
||||
}
|
||||
}
|
||||
@ -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 模板读取失败");
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user