diff --git a/app/api/psd-templates/[id]/route.ts b/app/api/psd-templates/[id]/route.ts new file mode 100644 index 000000000..9c64be110 --- /dev/null +++ b/app/api/psd-templates/[id]/route.ts @@ -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> + ), + ]); + 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 模板详情读取失败"); + } +} diff --git a/app/api/psd-templates/route.ts b/app/api/psd-templates/route.ts index 8986c7c01..a2336871a 100644 --- a/app/api/psd-templates/route.ts +++ b/app/api/psd-templates/route.ts @@ -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> - )) as Awaited>; - 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> + )) as Awaited>; + 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 模板读取失败"); diff --git a/components/agents/AgentPsdTemplateEditorDialog.tsx b/components/agents/AgentPsdTemplateEditorDialog.tsx index 9ef419b2a..545d027a4 100644 --- a/components/agents/AgentPsdTemplateEditorDialog.tsx +++ b/components/agents/AgentPsdTemplateEditorDialog.tsx @@ -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 => { 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 | null; @@ -131,6 +150,38 @@ const fetcher = async (url: string): Promise => { }; } }; + +const templateDetailFetcher = async ( + url: string +): Promise => { + try { + const response = await fetch(url, { cache: "default" }); + const payload = (await response + .json() + .catch(() => null)) as Partial | 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( {template.name}
@@ -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( {template.name}
@@ -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( templateListRequestUrl, fetcher, @@ -1057,8 +1142,26 @@ export function AgentPsdTemplateEditorDialog({ ), [activeCategoryTags, allTemplates, normalizedSelectedCategoryNames] ); - const [selectedTemplateId, setSelectedTemplateId] = useState(""); + const selectedTemplateDetailRequestUrl = useMemo(() => { + if (!open || !selectedTemplateId) { + return null; + } + + return `/api/psd-templates/${encodeURIComponent(selectedTemplateId)}`; + }, [open, selectedTemplateId]); + const { + data: selectedTemplateDetailData, + isLoading: isSelectedTemplateDetailLoading, + } = useSWR( + selectedTemplateDetailRequestUrl, + templateDetailFetcher, + { + dedupingInterval: 60_000, + keepPreviousData: true, + revalidateOnFocus: false, + } + ); const [selectedLayerId, setSelectedLayerId] = useState(""); const [imageAssignments, setImageAssignments] = useState< Record @@ -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 && (
@@ -2336,41 +2525,70 @@ export function AgentPsdTemplateEditorDialog({ ref={previewViewportRef} > {selectedTemplate ? ( -
+ selectedTemplateLayers.length === 0 ? ( +
+ {selectedTemplateDetailError ? ( + <> +
+ 模板详情加载失败 +
+
+ {selectedTemplateDetailError} +
+ + ) : ( + <> + +
+ 正在加载模板图层与编辑配置 +
+
+ 模板卡片已经优先显示,当前正在按需加载这个模板的完整图层、字体和编辑底图。 +
+ + )} +
+ ) : (
- {shouldShowCompositeFallback ? ( - {`${selectedTemplate.name} - ) : null} +
+ {shouldShowCompositeFallback ? ( + {`${selectedTemplate.name} + ) : null} -
- {orderedPreviewLayers.map((layer) => { - const layerBoxStyle = { - left: layer.x, - top: layer.y, - width: layer.width, - height: layer.height, - } satisfies CSSProperties; +
+ {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({ })}
-
+
{orderedEditableImageLayers.map((layer) => { const isSelected = selectedLayerId === layer.id; const frameStyle = getLayerFrameStyle( @@ -2711,9 +2929,10 @@ export function AgentPsdTemplateEditorDialog({ ); })} +
-
+ ) ) : (
@@ -2749,7 +2968,7 @@ export function AgentPsdTemplateEditorDialog({
) : skuModeTemplates.length > 0 ? (
- {skuModeTemplates.map((template) => ( + {skuModeTemplates.map((template, index) => (
+ ) : selectedTemplate && selectedTemplateLayers.length === 0 ? ( +
+ {selectedTemplateDetailError ? ( + <> +
+ 模板详情加载失败 +
+
+ {selectedTemplateDetailError} +
+ + ) : ( + <> + +
+ 正在加载这个模板的编辑图层 +
+
+ 首屏先展示模板列表,完整文字图层、图片图层和字体配置会按需补回来。 +
+ + )} +
) : (