feat: add manual PSD parsing workflow
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-03-27 20:31:04 +08:00
parent 93e2c5d892
commit bfbad743c4
7 changed files with 828 additions and 111 deletions

View File

@ -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<File | null>(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<string | null>(null);
const [isUploadingFont, setIsUploadingFont] = useState(false);
const [deletingFontId, setDeletingFontId] = useState<string | null>(null);
const [parsingTemplateIds, setParsingTemplateIds] = useState<string[]>([]);
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<AgentPsdTemplateMutationResponse>((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 (
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
<header className="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@ -430,8 +640,13 @@ export function PsdTemplatesSettingsClient({
<Badge variant={draftTemplate.status === "active" ? "default" : "outline"}>
{draftTemplate.status === "active" ? "已启用" : "未启用"}
</Badge>
<Badge variant={getTemplateParseBadgeVariant(draftTemplate)}>
{getTemplateParseLabel(draftTemplate)}
</Badge>
<span className="text-sm text-muted-foreground">
{draftTemplate.width} x {draftTemplate.height}
{draftTemplate.parseStatus === "completed"
? `${draftTemplate.width} x ${draftTemplate.height}`
: "等待解析后显示尺寸"}
</span>
</>
) : null}
@ -507,8 +722,17 @@ export function PsdTemplatesSettingsClient({
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传并解析模板
上传模板文件
</Button>
{templateUploadStatus ? (
<div className="space-y-2 rounded-xl border border-dashed p-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{templateUploadStatus}</span>
<span>{templateUploadPercent}%</span>
</div>
<Progress value={templateUploadPercent} className="h-2" />
</div>
) : null}
</CardContent>
</Card>
@ -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 (
<div
key={template.id}
@ -630,7 +857,9 @@ export function PsdTemplatesSettingsClient({
<div className="min-w-0">
<div className="truncate text-sm font-medium">{template.name}</div>
<div className="mt-1 text-xs text-muted-foreground">
{template.width} x {template.height} · {template.layerCount} 图层
{template.parseStatus === "completed"
? `${template.width} x ${template.height} · ${template.layerCount} 图层`
: "待解析完成后生成尺寸和图层"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
品牌:{template.brandName}
@ -640,6 +869,26 @@ export function PsdTemplatesSettingsClient({
<Badge variant={template.status === "active" ? "default" : "outline"}>
{template.status === "active" ? "启用" : "停用"}
</Badge>
<Badge variant={getTemplateParseBadgeVariant(template)}>
{getTemplateParseLabel(template)}
</Badge>
<Button
type="button"
variant="outline"
size="sm"
onClick={(event) => {
event.stopPropagation();
void handleParseTemplate(template);
}}
disabled={isParsing}
>
{isParsing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{getTemplateParseActionLabel(template)}
</Button>
<Button
type="button"
variant="ghost"
@ -664,6 +913,27 @@ export function PsdTemplatesSettingsClient({
{template.description}
</div>
) : null}
{template.parseStatus === "processing" ? (
<div className="mt-3 space-y-2 rounded-xl border border-dashed p-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{template.parseStage || "正在解析 PSD..."}</span>
<span>{getTemplateParseProgress(template)}%</span>
</div>
<Progress value={getTemplateParseProgress(template)} className="h-2" />
</div>
) : null}
{template.parseStatus !== "processing" &&
template.parseStage &&
(template.parseStatus !== "completed" || Boolean(template.parseError)) ? (
<div className="mt-2 text-xs text-muted-foreground">
{template.parseStage}
</div>
) : null}
{template.parseError ? (
<div className="mt-2 rounded-xl border border-amber-300/40 bg-amber-50 px-3 py-2 text-xs text-amber-800">
{template.parseError}
</div>
) : null}
</div>
);
})
@ -684,29 +954,92 @@ export function PsdTemplatesSettingsClient({
<div className="space-y-3">
<div className="mx-auto w-full max-w-[220px] overflow-hidden rounded-2xl border bg-muted/20">
<div
className="w-full bg-black/5"
className="flex w-full items-center justify-center bg-black/5"
style={{
aspectRatio: `${Math.max(1, draftTemplate.width)} / ${Math.max(1, draftTemplate.height)}`,
aspectRatio:
draftTemplate.parseStatus === "completed"
? `${Math.max(1, draftTemplate.width)} / ${Math.max(1, draftTemplate.height)}`
: "1 / 1",
}}
>
<img
src={draftTemplate.previewUrl}
alt={draftTemplate.name}
className="h-full w-full object-contain"
/>
{draftTemplate.parseStatus === "completed" ? (
<img
src={draftTemplate.previewUrl}
alt={draftTemplate.name}
className="h-full w-full object-contain"
/>
) : (
<div className="flex flex-col items-center gap-2 px-6 text-center text-muted-foreground">
<Clock3 className="h-7 w-7" />
<div className="text-sm font-medium">{getTemplateParseLabel(draftTemplate)}</div>
<div className="text-xs">
先完成 PSD 解析,再配置图层和可编辑槽位
</div>
</div>
)}
</div>
</div>
<div className="space-y-2 rounded-xl border p-3">
<div className="flex items-center justify-between gap-2">
<Badge variant={getTemplateParseBadgeVariant(draftTemplate)}>
{getTemplateParseLabel(draftTemplate)}
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => {
void handleParseTemplate(draftTemplate);
}}
disabled={
draftTemplate.parseStatus === "processing" ||
parsingTemplateIds.includes(draftTemplate.id)
}
>
{draftTemplate.parseStatus === "processing" ||
parsingTemplateIds.includes(draftTemplate.id) ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{getTemplateParseActionLabel(draftTemplate)}
</Button>
</div>
<div className="text-xs text-muted-foreground">
{draftTemplate.parseStage ||
(draftTemplate.parseStatus === "completed"
? "解析完成,可开始配置槽位"
: "上传完成后需要手动解析,适合批量上传多个 PSD 再逐个处理")}
</div>
{draftTemplate.parseStatus === "processing" ? (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{draftTemplate.parseStage || "正在解析 PSD..."}</span>
<span>{getTemplateParseProgress(draftTemplate)}%</span>
</div>
<Progress value={getTemplateParseProgress(draftTemplate)} className="h-2" />
</div>
) : null}
{draftTemplate.parseError ? (
<div className="rounded-xl border border-amber-300/40 bg-amber-50 px-3 py-2 text-xs text-amber-800">
{draftTemplate.parseError}
</div>
) : null}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl border p-3">
<div className="text-xs text-muted-foreground">画布尺寸</div>
<div className="mt-1 text-sm font-medium">
{draftTemplate.width} x {draftTemplate.height}
{draftTemplate.parseStatus === "completed"
? `${draftTemplate.width} x ${draftTemplate.height}`
: "待解析"}
</div>
</div>
<div className="rounded-xl border p-3">
<div className="text-xs text-muted-foreground">当前槽位</div>
<div className="mt-1 text-sm font-medium">
{countEditableLayers(draftTemplate)} / {draftTemplate.layers.length}
{draftTemplate.parseStatus === "completed"
? `${countEditableLayers(draftTemplate)} / ${draftTemplate.layers.length}`
: "待解析"}
</div>
</div>
</div>
@ -783,7 +1116,12 @@ export function PsdTemplatesSettingsClient({
</SelectTrigger>
<SelectContent>
<SelectItem value="disabled">停用</SelectItem>
<SelectItem value="active">启用</SelectItem>
<SelectItem
value="active"
disabled={draftTemplate.parseStatus !== "completed"}
>
启用
</SelectItem>
</SelectContent>
</Select>
</div>
@ -807,7 +1145,15 @@ export function PsdTemplatesSettingsClient({
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary">品牌:{draftTemplate.brandName}</Badge>
<Badge variant="outline">文件:{draftTemplate.fileName}</Badge>
<Badge variant="secondary">上传图层数:{draftTemplate.layerCount}</Badge>
<Badge variant={getTemplateParseBadgeVariant(draftTemplate)}>
{getTemplateParseLabel(draftTemplate)}
</Badge>
<Badge variant="secondary">
上传图层数:
{draftTemplate.parseStatus === "completed"
? draftTemplate.layerCount
: "待解析"}
</Badge>
<Button
variant="destructive"
size="sm"
@ -847,15 +1193,63 @@ export function PsdTemplatesSettingsClient({
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[680px] pr-4">
<div className="space-y-4">
{filteredLayers.length === 0 ? (
<div className="rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
当前筛选条件下没有图层,请换个关键词试试。
{draftTemplate.parseStatus !== "completed" ? (
<div className="rounded-2xl border border-dashed p-8 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
<RefreshCw className="h-6 w-6 text-muted-foreground" />
</div>
<div className="mt-4 text-base font-medium">先解析 PSD,再配置槽位</div>
<div className="mt-2 text-sm text-muted-foreground">
当前模板还没有生成图层结构、缩略图和字体识别结果,完成解析后这里会自动显示可编辑配置项。
</div>
<div className="mt-4 flex justify-center">
<Button
variant="outline"
onClick={() => {
void handleParseTemplate(draftTemplate);
}}
disabled={
draftTemplate.parseStatus === "processing" ||
parsingTemplateIds.includes(draftTemplate.id)
}
>
{draftTemplate.parseStatus === "processing" ||
parsingTemplateIds.includes(draftTemplate.id) ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{getTemplateParseActionLabel(draftTemplate)}
</Button>
</div>
{draftTemplate.parseStatus === "processing" ? (
<div className="mx-auto mt-4 max-w-md space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{draftTemplate.parseStage || "正在解析 PSD..."}</span>
<span>{getTemplateParseProgress(draftTemplate)}%</span>
</div>
<Progress value={getTemplateParseProgress(draftTemplate)} className="h-2" />
</div>
) : (
filteredLayers.map((layer, index) => (
<div key={layer.id} className="rounded-2xl border p-4">
) : null}
{draftTemplate.parseError ? (
<div className="mx-auto mt-4 max-w-xl rounded-xl border border-amber-300/40 bg-amber-50 px-4 py-3 text-left text-xs text-amber-800">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{draftTemplate.parseError}</span>
</div>
</div>
) : null}
</div>
) : (
<ScrollArea className="h-[680px] pr-4">
<div className="space-y-4">
{filteredLayers.length === 0 ? (
<div className="rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
当前筛选条件下没有图层,请换个关键词试试。
</div>
) : (
filteredLayers.map((layer, index) => (
<div key={layer.id} className="rounded-2xl border p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted/20">
@ -1376,11 +1770,12 @@ export function PsdTemplatesSettingsClient({
</div>
</div>
)}
</div>
))
)}
</div>
</ScrollArea>
</div>
))
)}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
</>

View File

@ -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<ReturnType<typeof parsePsdTemplateFile>>["layers"];
fonts: Awaited<ReturnType<typeof readAgentPsdFonts>>;
}) {
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);
}
}

View File

@ -41,6 +41,10 @@ function jsonError(status: number, error: string) {
}
async function repairTemplateLayerPreviewsIfNeeded(template: NonNullable<Awaited<ReturnType<typeof readAgentPsdTemplates>>[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,

View File

@ -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,

View File

@ -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) {

View File

@ -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
)

View File

@ -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);
}