feat: add manual PSD parsing workflow
This commit is contained in:
parent
93e2c5d892
commit
bfbad743c4
@ -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>
|
||||
</>
|
||||
|
||||
291
app/api/admin/psd-templates/[id]/parse/route.ts
Normal file
291
app/api/admin/psd-templates/[id]/parse/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user