ai-chatbot/components/agents/AgentPsdTemplateEditorDialog.tsx
Your Name 17dcfcd55d
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add PSD template font rendering workflow
2026-03-27 11:01:13 +08:00

465 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useState, type ChangeEvent } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import {
Download,
Image as ImageIcon,
Loader2,
Sparkles,
Type,
Upload,
} from "lucide-react";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
isAgentPsdImageLayerSlot,
isAgentPsdTextLayerSlot,
type AgentPsdTemplate,
type AgentPsdTemplateListResponse,
type AgentPsdTemplateRenderResponse,
} from "@/lib/agents/psd-template-config";
const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json());
function createInitialState(template: AgentPsdTemplate | null, sourceImageUrl: string) {
const imageAssignments: Record<string, string> = {};
const textAssignments: Record<string, string> = {};
if (!template) {
return { imageAssignments, textAssignments };
}
for (const layer of template.layers) {
if (isAgentPsdImageLayerSlot(layer) && layer.isDefaultResultSource && sourceImageUrl) {
imageAssignments[layer.id] = sourceImageUrl;
}
if (isAgentPsdTextLayerSlot(layer) && layer.defaultText) {
textAssignments[layer.id] = layer.defaultText;
}
}
return { imageAssignments, textAssignments };
}
export function AgentPsdTemplateEditorDialog({
open,
onOpenChange,
sourceImageUrl,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceImageUrl: string | null;
}) {
const { data, isLoading } = useSWR<AgentPsdTemplateListResponse>(
open ? "/api/psd-templates" : null,
fetcher
);
const templates = useMemo(() => (data?.success ? data.templates : []), [data]);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [imageAssignments, setImageAssignments] = useState<Record<string, string>>({});
const [textAssignments, setTextAssignments] = useState<Record<string, string>>({});
const [uploadingSlotId, setUploadingSlotId] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false);
const [renderedResultUrl, setRenderedResultUrl] = useState<string>("");
const selectedTemplate = useMemo(
() => templates.find((template) => template.id === selectedTemplateId) || null,
[selectedTemplateId, templates]
);
useEffect(() => {
if (!open) return;
if (templates.length > 0 && !templates.some((template) => template.id === selectedTemplateId)) {
setSelectedTemplateId(templates[0].id);
}
}, [open, selectedTemplateId, templates]);
useEffect(() => {
if (!open) return;
setRenderedResultUrl("");
const initialState = createInitialState(selectedTemplate, sourceImageUrl || "");
setImageAssignments(initialState.imageAssignments);
setTextAssignments(initialState.textAssignments);
}, [open, selectedTemplate, sourceImageUrl]);
async function handleSlotUpload(slotId: string, event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
try {
setUploadingSlotId(slotId);
const formData = new FormData();
formData.set("file", file);
const response = await fetchUploadWithRetry(formData);
const payload = await response.json();
if (!response.ok || !payload.url) {
throw new Error(payload.error || "图片上传失败");
}
setImageAssignments((current) => ({
...current,
[slotId]: payload.url,
}));
toast.success("素材已上传");
} catch (error) {
toast.error(error instanceof Error ? error.message : "素材上传失败");
} finally {
setUploadingSlotId(null);
event.target.value = "";
}
}
async function handleRender() {
if (!selectedTemplate) {
toast.error("请先选择 PSD 模板");
return;
}
try {
setIsRendering(true);
const response = await fetch("/api/psd-templates/render", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
templateId: selectedTemplate.id,
sourceImageUrl: sourceImageUrl || "",
imageAssignments,
textAssignments,
}),
});
const payload = (await response.json()) as AgentPsdTemplateRenderResponse;
if (!response.ok || !payload.success || !payload.result?.url) {
throw new Error(payload.error || "PSD 模板渲染失败");
}
setRenderedResultUrl(payload.result.url);
toast.success("套版结果已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "PSD 模板渲染失败");
} finally {
setIsRendering(false);
}
}
function handleDownload() {
if (!renderedResultUrl) return;
window.open(renderedResultUrl, "_blank", "noopener,noreferrer");
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-6xl overflow-hidden p-0">
<DialogHeader className="border-b px-6 py-5">
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-orange-500" />
套用 PSD 模板
</DialogTitle>
<DialogDescription>
当前结果图会自动填入默认图片槽位,文字槽位可直接输入,其他图片槽位可继续上传素材替换。
</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 gap-0 lg:grid-cols-[360px,minmax(0,1fr),minmax(0,1fr)]">
<div className="border-r p-5">
<div className="space-y-4">
<div className="space-y-2">
<Label>选择模板</Label>
<Select value={selectedTemplateId} onValueChange={setSelectedTemplateId}>
<SelectTrigger>
<SelectValue placeholder={isLoading ? "加载模板中..." : "请选择模板"} />
</SelectTrigger>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedTemplate ? (
<>
<div className="overflow-hidden rounded-2xl border bg-muted/20">
<div className="aspect-[4/5]">
<img
src={selectedTemplate.previewUrl}
alt={selectedTemplate.name}
className="h-full w-full object-contain"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{selectedTemplate.width} x {selectedTemplate.height}
</Badge>
<Badge variant="outline">
{
selectedTemplate.layers.filter((layer) => layer.slotType === "image").length
}{" "}
个图片槽位
</Badge>
<Badge variant="outline">
{
selectedTemplate.layers.filter((layer) => layer.slotType === "text").length
}{" "}
个文字槽位
</Badge>
</div>
{selectedTemplate.description ? (
<div className="rounded-xl border p-3 text-sm text-muted-foreground">
{selectedTemplate.description}
</div>
) : null}
<Button onClick={handleRender} disabled={isRendering} className="w-full">
{isRendering ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
生成套版结果
</Button>
</>
) : (
<div className="rounded-2xl border border-dashed p-5 text-sm text-muted-foreground">
{isLoading ? "正在加载可用模板..." : "当前还没有启用的 PSD 模板,请先去后台配置。"}
</div>
)}
</div>
</div>
<div className="border-r p-5">
<div className="mb-4">
<div className="text-sm font-medium">槽位内容</div>
<div className="mt-1 text-xs text-muted-foreground">
图片槽位支持上传和套用当前结果图,文字槽位直接输入即可。
</div>
</div>
<ScrollArea className="h-[65vh] pr-4">
<div className="space-y-4">
{selectedTemplate?.layers.length ? (
selectedTemplate.layers.map((layer) => {
if (isAgentPsdImageLayerSlot(layer)) {
const assignedUrl = imageAssignments[layer.id] || "";
const slotUploadInputId = `psd-slot-upload-${layer.id}`;
return (
<div key={layer.id} className="rounded-2xl border p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{layer.layerName}</div>
<Badge variant="outline">图片槽位</Badge>
{layer.isRequired ? <Badge variant="destructive">必传</Badge> : null}
{layer.isDefaultResultSource ? (
<Badge variant="secondary">默认结果图</Badge>
) : null}
<Badge variant="outline">
{layer.width} x {layer.height}
</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">{layer.layerPath}</div>
{assignedUrl ? (
<div className="mt-3 overflow-hidden rounded-xl border bg-muted/20">
<div className="aspect-[4/3]">
<img
src={assignedUrl}
alt={layer.layerName}
className="h-full w-full object-contain"
/>
</div>
</div>
) : (
<div className="mt-3 flex aspect-[4/3] items-center justify-center rounded-xl border border-dashed bg-muted/10 text-xs text-muted-foreground">
暂未分配素材
</div>
)}
<div className="mt-3 flex flex-wrap gap-2">
{sourceImageUrl ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setImageAssignments((current) => ({
...current,
[layer.id]: sourceImageUrl,
}))
}
>
使用当前结果图
</Button>
) : null}
{layer.allowUserUploadOverride ? (
<>
<Input
id={slotUploadInputId}
className="hidden"
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => handleSlotUpload(layer.id, event)}
/>
<Label htmlFor={slotUploadInputId} className="inline-flex">
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingSlotId === layer.id}
onClick={(event) => {
if (uploadingSlotId === layer.id) {
event.preventDefault();
}
}}
>
{uploadingSlotId === layer.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传替换图
</Button>
</Label>
</>
) : null}
{assignedUrl ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setImageAssignments((current) => {
const nextAssignments = { ...current };
delete nextAssignments[layer.id];
return nextAssignments;
})
}
>
清空
</Button>
) : null}
</div>
</div>
);
}
const textValue = textAssignments[layer.id] || "";
return (
<div key={layer.id} className="rounded-2xl border p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">{layer.layerName}</div>
<Badge variant="default">文字槽位</Badge>
{layer.isRequired ? <Badge variant="destructive">必填</Badge> : null}
<Badge variant="outline">
{layer.width} x {layer.height}
</Badge>
{layer.fontLabel || layer.fontFamily ? (
<Badge variant="secondary">{layer.fontLabel || layer.fontFamily}</Badge>
) : null}
</div>
<div className="mt-1 text-xs text-muted-foreground">{layer.layerPath}</div>
<div className="mt-3 grid gap-3">
<Textarea
rows={4}
value={textValue}
placeholder={layer.placeholder || "请输入文字"}
onChange={(event) =>
setTextAssignments((current) => ({
...current,
[layer.id]: event.target.value,
}))
}
/>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>字体:{layer.fontLabel || layer.fontFamily || "系统字体"}</span>
<span>字号:{layer.fontSize}px</span>
<span>行高:{layer.lineHeight}</span>
<span>对齐:{layer.textAlign}</span>
</div>
</div>
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed p-5 text-sm text-muted-foreground">
暂无可编辑槽位。
</div>
)}
</div>
</ScrollArea>
</div>
<div className="p-5">
<div className="mb-4">
<div className="text-sm font-medium">套版结果</div>
<div className="mt-1 text-xs text-muted-foreground">
生成后可直接预览并下载结果图。
</div>
</div>
{renderedResultUrl ? (
<div className="space-y-4">
<div className="overflow-hidden rounded-2xl border bg-muted/20">
<div className="aspect-[4/5]">
<img
src={renderedResultUrl}
alt="PSD 模板渲染结果"
className="h-full w-full object-contain"
/>
</div>
</div>
<Button onClick={handleDownload} className="w-full">
<Download className="mr-2 h-4 w-4" />
下载结果图
</Button>
</div>
) : (
<div className="flex h-[65vh] flex-col items-center justify-center rounded-2xl border border-dashed bg-muted/10 px-6 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted">
{selectedTemplate?.layers.some((layer) => layer.slotType === "text") ? (
<Type className="h-8 w-8 text-muted-foreground" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground" />
)}
</div>
<div className="mt-4 text-sm font-medium">结果还没生成</div>
<div className="mt-2 text-xs leading-6 text-muted-foreground">
选好模板和槽位内容后点击“生成套版结果”,这里会显示导出的图片。
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}