From bfbad743c42ff26162ed8faf1bccaf91199c2584 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 27 Mar 2026 20:31:04 +0800 Subject: [PATCH] feat: add manual PSD parsing workflow --- .../psd-templates-settings-client.tsx | 467 ++++++++++++++++-- .../admin/psd-templates/[id]/parse/route.ts | 291 +++++++++++ app/api/admin/psd-templates/[id]/route.ts | 37 +- app/api/admin/psd-templates/route.ts | 99 +--- app/api/psd-templates/render/route.ts | 6 +- app/api/psd-templates/route.ts | 2 + lib/agents/psd-template-config.ts | 37 ++ 7 files changed, 828 insertions(+), 111 deletions(-) create mode 100644 app/api/admin/psd-templates/[id]/parse/route.ts diff --git a/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx b/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx index 49390f6b5..5292d27cb 100644 --- a/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx @@ -5,10 +5,13 @@ import { useEffect, useMemo, useState, type ChangeEvent } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { + AlertTriangle, ArrowLeft, + Clock3, Image as ImageIcon, Layers, Loader2, + RefreshCw, Save, Sparkles, Trash2, @@ -20,6 +23,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Progress } from "@/components/ui/progress"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; @@ -59,6 +63,64 @@ function countEditableTextLayers(template: AgentPsdTemplate | null) { ); } +function getTemplateParseLabel(template: AgentPsdTemplate | null) { + if (!template) return "未解析"; + + switch (template.parseStatus) { + case "processing": + return "解析中"; + case "completed": + return "已解析"; + case "failed": + return "解析失败"; + default: + return "待解析"; + } +} + +function getTemplateParseBadgeVariant(template: AgentPsdTemplate | null) { + if (!template) return "outline" as const; + + switch (template.parseStatus) { + case "processing": + return "secondary" as const; + case "completed": + return "default" as const; + case "failed": + return "destructive" as const; + default: + return "outline" as const; + } +} + +function getTemplateParseProgress(template: AgentPsdTemplate | null) { + if (!template) return 0; + if (template.parseStatus === "completed") return 100; + return Math.max(0, Math.min(100, Number(template.parseProgress || 0))); +} + +function getTemplateParseActionLabel(template: AgentPsdTemplate | null) { + return template?.parseStatus === "completed" ? "重新解析" : "解析 PSD"; +} + +function shouldHydrateDraftTemplate( + current: AgentPsdTemplate | null, + next: AgentPsdTemplate | null +) { + if (!current || !next) return true; + if (current.id !== next.id) return true; + + return ( + current.parseStatus !== next.parseStatus || + getTemplateParseProgress(current) !== getTemplateParseProgress(next) || + (current.parseStage || "") !== (next.parseStage || "") || + (current.parseError || "") !== (next.parseError || "") || + (current.previewUrl || "") !== (next.previewUrl || "") || + current.layers.length !== next.layers.length || + (current.layers.length === 0 && next.layers.length > 0) + ); +} + export function PsdTemplatesSettingsClient({ backHref, backLabel, @@ -107,11 +169,14 @@ export function PsdTemplatesSettingsClient({ const [fontFamily, setFontFamily] = useState(""); const [fontUploadFile, setFontUploadFile] = useState(null); const [isCreating, setIsCreating] = useState(false); + const [templateUploadPercent, setTemplateUploadPercent] = useState(0); + const [templateUploadStatus, setTemplateUploadStatus] = useState(""); const [isSaving, setIsSaving] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [deletingTemplateId, setDeletingTemplateId] = useState(null); const [isUploadingFont, setIsUploadingFont] = useState(false); const [deletingFontId, setDeletingFontId] = useState(null); + const [parsingTemplateIds, setParsingTemplateIds] = useState([]); const selectedTemplate = useMemo( () => templates.find((template) => template.id === selectedTemplateId) || null, @@ -142,10 +207,40 @@ export function PsdTemplatesSettingsClient({ }, [selectedTemplateId, templates]); useEffect(() => { - setDraftTemplate(cloneTemplate(selectedTemplate)); - setLayerKeyword(""); + if (!selectedTemplate) { + setDraftTemplate(null); + setLayerKeyword(""); + return; + } + + setDraftTemplate((current) => + shouldHydrateDraftTemplate(current, selectedTemplate) + ? cloneTemplate(selectedTemplate) + : current + ); }, [selectedTemplate]); + useEffect(() => { + setLayerKeyword(""); + }, [selectedTemplateId]); + + useEffect(() => { + const hasRunningTask = + parsingTemplateIds.length > 0 || + templates.some((template) => template.parseStatus === "processing"); + if (!hasRunningTask) { + return; + } + + const intervalId = window.setInterval(() => { + void mutate(); + }, 1200); + + return () => { + window.clearInterval(intervalId); + }; + }, [mutate, parsingTemplateIds.length, templates]); + const filteredLayers = useMemo(() => { if (!draftTemplate) return []; const keyword = layerKeyword.trim().toLowerCase(); @@ -214,24 +309,66 @@ export function PsdTemplatesSettingsClient({ try { setIsCreating(true); - const response = await fetch("/api/admin/psd-templates", { - method: "POST", - body: formData, - }); - const payload = (await response.json()) as AgentPsdTemplateMutationResponse; + setTemplateUploadPercent(0); + setTemplateUploadStatus("上传中 0%"); - if (!response.ok || !payload.success || !payload.template) { + const payload = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable) return; + const percent = Math.round((event.loaded / event.total) * 100); + setTemplateUploadPercent(percent); + setTemplateUploadStatus(`上传中 ${percent}%`); + }; + + xhr.upload.onloadend = () => { + setTemplateUploadPercent(100); + setTemplateUploadStatus("文件已上传,正在创建待解析模板..."); + }; + + xhr.onload = () => { + let responsePayload: AgentPsdTemplateMutationResponse | null = null; + try { + responsePayload = JSON.parse(xhr.responseText) as AgentPsdTemplateMutationResponse; + } catch { + reject(new Error("解析响应失败")); + return; + } + + if (xhr.status >= 200 && xhr.status < 300 && responsePayload.success) { + resolve(responsePayload); + return; + } + + reject(new Error(responsePayload.error || "PSD 模板上传失败")); + }; + + xhr.onerror = () => reject(new Error("网络错误,请稍后重试")); + xhr.ontimeout = () => reject(new Error("上传超时,请稍后重试")); + + xhr.open("POST", "/api/admin/psd-templates"); + xhr.timeout = 10 * 60 * 1000; + xhr.send(formData); + }); + + if (!payload.template) { throw new Error(payload.error || "PSD 模板上传失败"); } - toast.success("PSD 模板已上传,请继续配置图层"); + setTemplateUploadPercent(100); + setTemplateUploadStatus("上传完成,等待手动解析"); + toast.success("PSD 文件已上传,请点击“解析 PSD”生成模板图层"); setUploadName(""); setUploadBrandName(""); setUploadDescription(""); setUploadFile(null); await mutate(); setSelectedTemplateId(payload.template.id); + setDraftTemplate(cloneTemplate(payload.template)); } catch (error) { + setTemplateUploadPercent(0); + setTemplateUploadStatus(""); toast.error(error instanceof Error ? error.message : "PSD 模板上传失败"); } finally { setIsCreating(false); @@ -323,6 +460,7 @@ export function PsdTemplatesSettingsClient({ } toast.success("PSD 模板配置已保存"); + setDraftTemplate(cloneTemplate(payload.template)); await mutate(); setSelectedTemplateId(payload.template.id); } catch (error) { @@ -364,6 +502,78 @@ export function PsdTemplatesSettingsClient({ } } + async function handleParseTemplate(template = selectedTemplate) { + if (!template) return; + + const actionLabel = template.parseStatus === "completed" ? "重新解析" : "解析"; + const confirmed = + template.parseStatus === "completed" + ? window.confirm(`确认重新解析模板“${template.name}”吗?`) + : true; + if (!confirmed) return; + + try { + setParsingTemplateIds((current) => + current.includes(template.id) ? current : [...current, template.id] + ); + await mutate( + (current) => + current + ? { + ...current, + templates: current.templates.map((entry) => + entry.id === template.id + ? { + ...entry, + parseStatus: "processing", + parseProgress: Math.max(2, entry.parseProgress || 0), + parseStage: `${actionLabel}已提交`, + parseError: undefined, + } + : entry + ), + } + : current, + { revalidate: false } + ); + if (draftTemplate?.id === template.id) { + setDraftTemplate((current) => + current + ? { + ...current, + parseStatus: "processing", + parseProgress: Math.max(2, current.parseProgress || 0), + parseStage: `${actionLabel}已提交`, + parseError: undefined, + } + : current + ); + } + + const response = await fetch( + `/api/admin/psd-templates/${encodeURIComponent(template.id)}/parse`, + { + method: "POST", + } + ); + const payload = (await response.json()) as AgentPsdTemplateMutationResponse; + + if (!response.ok || !payload.success || !payload.template) { + throw new Error(payload.error || "PSD 解析失败"); + } + + toast.success(`模板“${payload.template.name}”解析完成`); + setDraftTemplate(cloneTemplate(payload.template)); + await mutate(); + setSelectedTemplateId(payload.template.id); + } catch (error) { + await mutate(); + toast.error(error instanceof Error ? error.message : "PSD 解析失败"); + } finally { + setParsingTemplateIds((current) => current.filter((item) => item !== template.id)); + } + } + return (
@@ -430,8 +640,13 @@ export function PsdTemplatesSettingsClient({ {draftTemplate.status === "active" ? "已启用" : "未启用"} + + {getTemplateParseLabel(draftTemplate)} + - {draftTemplate.width} x {draftTemplate.height} + {draftTemplate.parseStatus === "completed" + ? `${draftTemplate.width} x ${draftTemplate.height}` + : "等待解析后显示尺寸"} ) : null} @@ -507,8 +722,17 @@ export function PsdTemplatesSettingsClient({ ) : ( )} - 上传并解析模板 + 上传模板文件 + {templateUploadStatus ? ( +
+
+ {templateUploadStatus} + {templateUploadPercent}% +
+ +
+ ) : null} @@ -608,6 +832,9 @@ export function PsdTemplatesSettingsClient({ ) : ( templates.map((template) => { const isActive = template.id === selectedTemplateId; + const isParsing = + template.parseStatus === "processing" || + parsingTemplateIds.includes(template.id); return (
{template.name}
- {template.width} x {template.height} · {template.layerCount} 图层 + {template.parseStatus === "completed" + ? `${template.width} x ${template.height} · ${template.layerCount} 图层` + : "待解析完成后生成尺寸和图层"}
品牌:{template.brandName} @@ -640,6 +869,26 @@ export function PsdTemplatesSettingsClient({ {template.status === "active" ? "启用" : "停用"} + + {getTemplateParseLabel(template)} + +
); }) @@ -684,29 +954,92 @@ export function PsdTemplatesSettingsClient({
- {draftTemplate.name} + {draftTemplate.parseStatus === "completed" ? ( + {draftTemplate.name} + ) : ( +
+ +
{getTemplateParseLabel(draftTemplate)}
+
+ 先完成 PSD 解析,再配置图层和可编辑槽位 +
+
+ )}
+
+
+ + {getTemplateParseLabel(draftTemplate)} + + +
+
+ {draftTemplate.parseStage || + (draftTemplate.parseStatus === "completed" + ? "解析完成,可开始配置槽位" + : "上传完成后需要手动解析,适合批量上传多个 PSD 再逐个处理")} +
+ {draftTemplate.parseStatus === "processing" ? ( +
+
+ {draftTemplate.parseStage || "正在解析 PSD..."} + {getTemplateParseProgress(draftTemplate)}% +
+ +
+ ) : null} + {draftTemplate.parseError ? ( +
+ {draftTemplate.parseError} +
+ ) : null} +
画布尺寸
- {draftTemplate.width} x {draftTemplate.height} + {draftTemplate.parseStatus === "completed" + ? `${draftTemplate.width} x ${draftTemplate.height}` + : "待解析"}
当前槽位
- {countEditableLayers(draftTemplate)} / {draftTemplate.layers.length} + {draftTemplate.parseStatus === "completed" + ? `${countEditableLayers(draftTemplate)} / ${draftTemplate.layers.length}` + : "待解析"}
@@ -783,7 +1116,12 @@ export function PsdTemplatesSettingsClient({ 停用 - 启用 + + 启用 +
@@ -807,7 +1145,15 @@ export function PsdTemplatesSettingsClient({
品牌:{draftTemplate.brandName} 文件:{draftTemplate.fileName} - 上传图层数:{draftTemplate.layerCount} + + {getTemplateParseLabel(draftTemplate)} + + + 上传图层数: + {draftTemplate.parseStatus === "completed" + ? draftTemplate.layerCount + : "待解析"} + +
+ {draftTemplate.parseStatus === "processing" ? ( +
+
+ {draftTemplate.parseStage || "正在解析 PSD..."} + {getTemplateParseProgress(draftTemplate)}% +
+
- ) : ( - filteredLayers.map((layer, index) => ( -
+ ) : null} + {draftTemplate.parseError ? ( +
+
+ + {draftTemplate.parseError} +
+
+ ) : null} +
+ ) : ( + +
+ {filteredLayers.length === 0 ? ( +
+ 当前筛选条件下没有图层,请换个关键词试试。 +
+ ) : ( + filteredLayers.map((layer, index) => ( +
@@ -1376,11 +1770,12 @@ export function PsdTemplatesSettingsClient({
)} -
- )) - )} -
- +
+ )) + )} +
+ + )} diff --git a/app/api/admin/psd-templates/[id]/parse/route.ts b/app/api/admin/psd-templates/[id]/parse/route.ts new file mode 100644 index 000000000..47d94effc --- /dev/null +++ b/app/api/admin/psd-templates/[id]/parse/route.ts @@ -0,0 +1,291 @@ +import { NextResponse } from "next/server"; +import { upsertSystemSetting } from "@/lib/db/queries"; +import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin"; +import { findBestMatchingPsdFont } from "@/lib/agents/psd-font-matching"; +import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings"; +import { + AGENT_PSD_TEMPLATES_KEY, + type AgentPsdTemplate, + type AgentPsdTemplateLayerSlot, + type AgentPsdTemplateMutationResponse, +} from "@/lib/agents/psd-template-config"; +import { + parsePsdTemplateFile, + preparePsdTemplateRenderAssets, +} from "@/lib/agents/psd-template-parser"; +import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; +import { uploadFile } from "@/lib/storage/service"; + +export const runtime = "nodejs"; + +function jsonError(status: number, error: string) { + const payload: AgentPsdTemplateMutationResponse = { + success: false, + error, + }; + return NextResponse.json(payload, { status }); +} + +function toArrayBuffer(buffer: Buffer) { + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) as ArrayBuffer; +} + +function getErrorMessage(error: unknown) { + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + return "PSD 解析失败"; +} + +function buildInitialTemplateLayers(params: { + parsedLayers: Awaited>["layers"]; + fonts: Awaited>; +}) { + const { parsedLayers, fonts } = params; + return parsedLayers.map((layer): AgentPsdTemplateLayerSlot => { + if (layer.slotType === "text") { + const matchedFont = findBestMatchingPsdFont({ + detectedFontNames: layer.detectedFontNames || [], + fonts, + }); + + return { + ...layer, + slotType: "text", + isEditable: false, + isRequired: false, + placeholder: layer.defaultText || layer.layerName, + detectedFontName: layer.detectedFontName, + detectedFontNames: layer.detectedFontNames, + fontId: matchedFont?.font.id, + fontFamily: matchedFont?.font.family, + fontLabel: matchedFont?.font.name, + fontSize: Math.max(16, Math.min(layer.height, Math.round(layer.height * 0.78))), + lineHeight: 1.2, + letterSpacing: 0, + fontWeight: 500, + textColor: "#111111", + textAlign: "left", + paddingX: 0, + paddingY: 0, + maxLines: 4, + }; + } + + return { + ...layer, + slotType: "image", + isEditable: false, + isRequired: false, + isDefaultResultSource: false, + allowUserUploadOverride: true, + replaceMode: "cover", + }; + }); +} + +async function updateTemplate( + templateId: string, + updatedBy: string, + updater: (template: AgentPsdTemplate) => AgentPsdTemplate +) { + const templates = await readAgentPsdTemplates(); + const currentTemplate = templates.find((entry) => entry.id === templateId); + + if (!currentTemplate) { + return null; + } + + const nextTemplate = updater(currentTemplate); + await upsertSystemSetting({ + key: AGENT_PSD_TEMPLATES_KEY, + value: templates.map((entry) => (entry.id === templateId ? nextTemplate : entry)), + updatedBy, + }); + + return nextTemplate; +} + +async function updateParseProgress(params: { + templateId: string; + updatedBy: string; + progress: number; + stage: string; + error?: string; +}) { + const { templateId, updatedBy, progress, stage, error } = params; + return updateTemplate(templateId, updatedBy, (template) => ({ + ...template, + parseStatus: "processing", + parseProgress: progress, + parseStage: stage, + parseError: error, + updatedAt: new Date().toISOString(), + })); +} + +export async function POST( + _request: Request, + context: { params: Promise<{ id: string }> } +) { + const session = await requireAgentWorkflowAdmin().catch(() => null); + if (!session?.user?.id) { + return jsonError(401, "未登录"); + } + + const { id } = await context.params; + + try { + const templates = await readAgentPsdTemplates(); + const currentTemplate = templates.find((entry) => entry.id === id); + + if (!currentTemplate) { + return jsonError(404, "未找到 PSD 模板"); + } + + if (currentTemplate.parseStatus === "processing") { + return jsonError(409, "该模板正在解析中,请稍后刷新"); + } + + await updateParseProgress({ + templateId: id, + updatedBy: session.user.id, + progress: 5, + stage: "准备解析 PSD", + error: undefined, + }); + + const response = await fetch(currentTemplate.fileUrl, { + cache: "no-store", + signal: AbortSignal.timeout(60_000), + }); + + if (!response.ok) { + throw new Error(`PSD 文件下载失败 (${response.status})`); + } + + await updateParseProgress({ + templateId: id, + updatedBy: session.user.id, + progress: 18, + stage: "正在读取 PSD 文件", + error: undefined, + }); + + const fileBuffer = await response.arrayBuffer(); + const parsedTemplate = await parsePsdTemplateFile(fileBuffer); + + if (parsedTemplate.layers.length === 0) { + throw new Error("PSD 中未识别到可配置图层"); + } + + await updateParseProgress({ + templateId: id, + updatedBy: session.user.id, + progress: 42, + stage: "正在生成模板预览", + error: undefined, + }); + + const uploadedPreview = await uploadFile( + `agent-psd-preview-${id}-${Date.now()}.png`, + toArrayBuffer(parsedTemplate.previewBuffer) + ); + + await updateParseProgress({ + templateId: id, + updatedBy: session.user.id, + progress: 58, + stage: "正在匹配字体与初始化图层", + error: undefined, + }); + + const fonts = await readAgentPsdFonts().catch(() => []); + const initialLayers = buildInitialTemplateLayers({ + parsedLayers: parsedTemplate.layers, + fonts, + }); + + await updateParseProgress({ + templateId: id, + updatedBy: session.user.id, + progress: 78, + stage: "正在生成图层预览和遮罩", + error: undefined, + }); + + const preparedAssets = await preparePsdTemplateRenderAssets({ + templateId: id, + fileUrl: currentTemplate.fileUrl, + layers: initialLayers, + }); + + await updateParseProgress({ + templateId: id, + updatedBy: session.user.id, + progress: 92, + stage: "正在生成模板底图", + error: undefined, + }); + + const uploadedRenderBase = await uploadFile( + `agent-psd-base-${id}-${Date.now()}.png`, + toArrayBuffer(preparedAssets.renderBaseBuffer) + ); + + const updatedTemplate = await updateTemplate(id, session.user.id, (template) => ({ + ...template, + previewUrl: uploadedPreview.url, + previewPath: uploadedPreview.pathname, + renderBaseUrl: uploadedRenderBase.url, + renderBasePath: uploadedRenderBase.pathname, + width: parsedTemplate.width, + height: parsedTemplate.height, + layerCount: parsedTemplate.layers.length, + layers: preparedAssets.layers, + parseStatus: "completed", + parseProgress: 100, + parseStage: "解析完成", + parseError: undefined, + updatedAt: new Date().toISOString(), + })); + + if (!updatedTemplate) { + return jsonError(404, "未找到 PSD 模板"); + } + + return NextResponse.json({ + success: true, + template: updatedTemplate, + } satisfies AgentPsdTemplateMutationResponse); + } catch (error) { + const message = getErrorMessage(error); + const existingTemplate = (await readAgentPsdTemplates()).find((entry) => entry.id === id); + + if (existingTemplate) { + const fallbackStatus = + existingTemplate.parseStatus === "completed" && existingTemplate.layers.length > 0 + ? "completed" + : "failed"; + + await updateTemplate(id, session.user.id, (template) => ({ + ...template, + parseStatus: fallbackStatus, + parseProgress: fallbackStatus === "completed" ? 100 : 0, + parseStage: + fallbackStatus === "completed" ? "沿用上次成功解析结果" : undefined, + parseError: + fallbackStatus === "completed" + ? `重新解析失败:${message}` + : message, + updatedAt: new Date().toISOString(), + })).catch(() => undefined); + } + + console.error("[PSD Templates][Admin] parse failed:", error); + return jsonError(502, message); + } +} diff --git a/app/api/admin/psd-templates/[id]/route.ts b/app/api/admin/psd-templates/[id]/route.ts index 0e3516b9a..e293d2c7d 100644 --- a/app/api/admin/psd-templates/[id]/route.ts +++ b/app/api/admin/psd-templates/[id]/route.ts @@ -41,6 +41,10 @@ function jsonError(status: number, error: string) { } async function repairTemplateLayerPreviewsIfNeeded(template: NonNullable>[number]>) { + if (template.parseStatus !== "completed") { + return template; + } + const needsRepair = template.layers.some((layer) => !String(layer.previewUrl || "").trim()); const fonts = await readAgentPsdFonts().catch(() => []); const needsFontDetection = template.layers.some( @@ -192,13 +196,42 @@ export async function PUT( return jsonError(404, "未找到 PSD 模板"); } + const nextStatus = normalizeAgentPsdTemplateStatus(body.status); let nextLayers = parseAgentPsdTemplateLayers(body.layers); if (nextLayers.length === 0) { nextLayers = currentTemplate.layers; } + if (currentTemplate.parseStatus !== "completed") { + if (nextStatus === "active") { + return jsonError(400, "请先解析 PSD 模板并配置可编辑图层后再启用模板"); + } + + const updatedTemplate = { + ...currentTemplate, + name: body.name.trim(), + brandName: body.brandName.trim(), + description: String(body.description || "").trim() || undefined, + status: nextStatus, + updatedAt: new Date().toISOString(), + }; + + await upsertSystemSetting({ + key: AGENT_PSD_TEMPLATES_KEY, + value: templates.map((template) => + template.id === currentTemplate.id ? updatedTemplate : template + ), + updatedBy: session.user.id, + }); + + return NextResponse.json({ + success: true, + template: updatedTemplate, + } satisfies AgentPsdTemplateMutationResponse); + } + if ( - normalizeAgentPsdTemplateStatus(body.status) === "active" && + nextStatus === "active" && getEditableAgentPsdTemplateLayers({ ...currentTemplate, layers: nextLayers, @@ -263,7 +296,7 @@ export async function PUT( name: body.name.trim(), brandName: body.brandName.trim(), description: String(body.description || "").trim() || undefined, - status: normalizeAgentPsdTemplateStatus(body.status), + status: nextStatus, renderBaseUrl: uploadedRenderBase.url, renderBasePath: uploadedRenderBase.pathname, layers: nextLayers, diff --git a/app/api/admin/psd-templates/route.ts b/app/api/admin/psd-templates/route.ts index 84a0f8758..2fc553130 100644 --- a/app/api/admin/psd-templates/route.ts +++ b/app/api/admin/psd-templates/route.ts @@ -15,7 +15,6 @@ import { type AgentPsdTemplateMutationResponse, } from "@/lib/agents/psd-template-config"; import { - parsePsdTemplateFile, parsePsdTemplateFileFromUrl, preparePsdTemplateRenderAssets, } from "@/lib/agents/psd-template-parser"; @@ -25,6 +24,10 @@ export const runtime = "nodejs"; const MAX_TEMPLATE_FILE_SIZE = 80 * 1024 * 1024; const ALLOWED_TEMPLATE_EXTENSIONS = new Set(["psd", "psb"]); +const PLACEHOLDER_PREVIEW_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2+p4kAAAAASUVORK5CYII=", + "base64" +); function jsonListError(status: number, error: string) { const payload: AgentPsdTemplateListResponse = { @@ -51,11 +54,22 @@ function getDefaultTemplateName(fileName: string) { return fileName.replace(/\.[^.]+$/, "").trim() || "未命名 PSD 模板"; } +function toArrayBuffer(buffer: Buffer) { + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) as ArrayBuffer; +} + async function repairTemplateLayerPreviewsIfNeeded(params: { template: AgentPsdTemplate; updatedBy: string; }) { const { template } = params; + if (template.parseStatus !== "completed") { + return template; + } + const needsRepair = template.layers.some((layer) => !String(layer.previewUrl || "").trim()); const fonts = await readAgentPsdFonts().catch(() => []); const needsFontDetection = template.layers.some( @@ -239,78 +253,15 @@ export async function POST(request: NextRequest) { try { const fileBuffer = await file.arrayBuffer(); - const parsedTemplate = await parsePsdTemplateFile(fileBuffer); - - if (parsedTemplate.layers.length === 0) { - return jsonMutationError(400, "PSD 中未识别到可配置图层"); - } - const [uploadedFile, uploadedPreview] = await Promise.all([ uploadFile(file.name, fileBuffer), uploadFile( - `agent-psd-preview-${Date.now()}.png`, - parsedTemplate.previewBuffer.buffer.slice( - parsedTemplate.previewBuffer.byteOffset, - parsedTemplate.previewBuffer.byteOffset + parsedTemplate.previewBuffer.byteLength - ) as ArrayBuffer + `agent-psd-preview-placeholder-${Date.now()}.png`, + toArrayBuffer(PLACEHOLDER_PREVIEW_PNG) ), ]); const templateId = randomUUID(); - const fonts = await readAgentPsdFonts().catch(() => []); - const initialLayers = parsedTemplate.layers.map((layer): AgentPsdTemplateLayerSlot => { - if (layer.slotType === "text") { - const matchedFont = findBestMatchingPsdFont({ - detectedFontNames: layer.detectedFontNames || [], - fonts, - }); - return { - ...layer, - slotType: "text", - isEditable: false, - isRequired: false, - placeholder: layer.defaultText || layer.layerName, - detectedFontName: layer.detectedFontName, - detectedFontNames: layer.detectedFontNames, - fontId: matchedFont?.font.id, - fontFamily: matchedFont?.font.family, - fontLabel: matchedFont?.font.name, - fontSize: Math.max(16, Math.min(layer.height, Math.round(layer.height * 0.78))), - lineHeight: 1.2, - letterSpacing: 0, - fontWeight: 500, - textColor: "#111111", - textAlign: "left", - paddingX: 0, - paddingY: 0, - maxLines: 4, - }; - } - - return { - ...layer, - slotType: "image", - isEditable: false, - isRequired: false, - isDefaultResultSource: false, - allowUserUploadOverride: true, - replaceMode: "cover", - }; - }); - - const preparedAssets = await preparePsdTemplateRenderAssets({ - templateId, - fileUrl: uploadedFile.url, - layers: initialLayers, - }); - const uploadedRenderBase = await uploadFile( - `agent-psd-base-${templateId}-${Date.now()}.png`, - preparedAssets.renderBaseBuffer.buffer.slice( - preparedAssets.renderBaseBuffer.byteOffset, - preparedAssets.renderBaseBuffer.byteOffset + preparedAssets.renderBaseBuffer.byteLength - ) as ArrayBuffer - ); - const now = new Date().toISOString(); const nextTemplate: AgentPsdTemplate = { id: templateId, @@ -318,17 +269,21 @@ export async function POST(request: NextRequest) { brandName, description: description || undefined, status, + parseStatus: "pending", + parseProgress: 0, + parseStage: "等待手动解析", + parseError: undefined, fileName: file.name, fileUrl: uploadedFile.url, filePath: uploadedFile.pathname, previewUrl: uploadedPreview.url, previewPath: uploadedPreview.pathname, - renderBaseUrl: uploadedRenderBase.url, - renderBasePath: uploadedRenderBase.pathname, - width: parsedTemplate.width, - height: parsedTemplate.height, - layerCount: parsedTemplate.layers.length, - layers: preparedAssets.layers, + renderBaseUrl: undefined, + renderBasePath: undefined, + width: 1, + height: 1, + layerCount: 0, + layers: [] as AgentPsdTemplateLayerSlot[], createdAt: now, updatedAt: now, uploadedById: session.user.id, diff --git a/app/api/psd-templates/render/route.ts b/app/api/psd-templates/render/route.ts index bcef07a30..1251abd79 100644 --- a/app/api/psd-templates/render/route.ts +++ b/app/api/psd-templates/render/route.ts @@ -4,6 +4,7 @@ import { auth } from "@/app/(auth)/auth"; import { getEditableAgentPsdTemplateLayers, isAgentPsdImageLayerSlot, + isAgentPsdTemplateReady, isAgentPsdTextLayerSlot, type AgentPsdTemplateRenderResponse, } from "@/lib/agents/psd-template-config"; @@ -44,7 +45,10 @@ export async function POST(request: NextRequest) { try { const templates = await readAgentPsdTemplates(); const template = templates.find( - (entry) => entry.id === body.templateId && entry.status === "active" + (entry) => + entry.id === body.templateId && + entry.status === "active" && + isAgentPsdTemplateReady(entry) ); if (!template) { diff --git a/app/api/psd-templates/route.ts b/app/api/psd-templates/route.ts index 1cd6b672d..81c41c533 100644 --- a/app/api/psd-templates/route.ts +++ b/app/api/psd-templates/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { getEditableAgentPsdTemplateLayers, + isAgentPsdTemplateReady, type AgentPsdTemplateListResponse, } from "@/lib/agents/psd-template-config"; import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings"; @@ -28,6 +29,7 @@ export async function GET(request: NextRequest) { const requestedBrand = String(request.nextUrl.searchParams.get("brand") || "").trim(); const templates = (await readAgentPsdTemplates()) .filter((template) => template.status === "active") + .filter(isAgentPsdTemplateReady) .filter((template) => requestedBrand ? template.brandName === requestedBrand : true ) diff --git a/lib/agents/psd-template-config.ts b/lib/agents/psd-template-config.ts index 964debc2a..d1fb00c15 100644 --- a/lib/agents/psd-template-config.ts +++ b/lib/agents/psd-template-config.ts @@ -1,6 +1,11 @@ export const AGENT_PSD_TEMPLATES_KEY = "agent_psd_templates"; export type AgentPsdTemplateStatus = "active" | "disabled"; +export type AgentPsdTemplateParseStatus = + | "pending" + | "processing" + | "completed" + | "failed"; export type AgentPsdTemplateReplaceMode = "cover" | "contain" | "stretch"; export type AgentPsdTemplateSlotType = "image" | "text"; export type AgentPsdTextAlign = "left" | "center" | "right"; @@ -62,6 +67,10 @@ export interface AgentPsdTemplate { brandName: string; description?: string; status: AgentPsdTemplateStatus; + parseStatus: AgentPsdTemplateParseStatus; + parseProgress?: number; + parseStage?: string; + parseError?: string; fileName: string; fileUrl: string; filePath?: string; @@ -151,6 +160,20 @@ export function normalizeAgentPsdTemplateStatus(value: unknown): AgentPsdTemplat return normalizeText(value) === "active" ? "active" : "disabled"; } +export function normalizeAgentPsdTemplateParseStatus( + value: unknown +): AgentPsdTemplateParseStatus { + const status = normalizeText(value); + if ( + status === "pending" || + status === "processing" || + status === "failed" + ) { + return status; + } + return "completed"; +} + export function normalizeAgentPsdTemplateReplaceMode( value: unknown ): AgentPsdTemplateReplaceMode { @@ -327,12 +350,22 @@ export function parseAgentPsdTemplates(value: unknown) { continue; } + const parseStatus = normalizeAgentPsdTemplateParseStatus(record.parseStatus); + const rawParseProgress = normalizePositiveInteger(record.parseProgress); + templates.push({ id, name, brandName, description: normalizeText(record.description) || undefined, status: normalizeAgentPsdTemplateStatus(record.status), + parseStatus, + parseProgress: + parseStatus === "completed" && rawParseProgress <= 0 + ? 100 + : Math.min(100, Math.max(0, rawParseProgress)), + parseStage: normalizeText(record.parseStage) || undefined, + parseError: normalizeText(record.parseError) || undefined, fileName, fileUrl, filePath: normalizeText(record.filePath) || undefined, @@ -363,6 +396,10 @@ export function upsertAgentPsdTemplate( ); } +export function isAgentPsdTemplateReady(template: AgentPsdTemplate) { + return template.parseStatus === "completed" && template.layers.length > 0; +} + export function getEditableAgentPsdTemplateLayers(template: AgentPsdTemplate) { return template.layers.filter((layer) => layer.isEditable); }