ai-chatbot/components/agents/AgentPsdTemplateEditorDialog.tsx
Your Name 510b0c13a6
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: clarify editable PSD slots in agent dialog
2026-03-27 21:36:50 +08:00

543 lines
24 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,
brandName,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceImageUrl: string | null;
brandName?: string | null;
}) {
const normalizedBrandName = String(brandName || "").trim();
const { data, isLoading } = useSWR<AgentPsdTemplateListResponse>(
open && normalizedBrandName
? `/api/psd-templates?brand=${encodeURIComponent(normalizedBrandName)}`
: 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]
);
const editableImageLayers = useMemo(
() =>
(selectedTemplate?.layers || []).filter((layer) =>
isAgentPsdImageLayerSlot(layer)
),
[selectedTemplate]
);
const editableTextLayers = useMemo(
() =>
(selectedTemplate?.layers || []).filter((layer) =>
isAgentPsdTextLayerSlot(layer)
),
[selectedTemplate]
);
const fixedLayerCount = useMemo(() => {
if (!selectedTemplate) return 0;
return Math.max(0, selectedTemplate.layerCount - selectedTemplate.layers.length);
}, [selectedTemplate]);
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
overlayClassName="bg-black/85 backdrop-blur-sm"
className="flex h-[92vh] w-[96vw] max-w-[1600px] flex-col overflow-hidden border border-white/10 bg-[#09090b] p-0 text-white shadow-[0_30px_120px_rgba(0,0,0,0.65)]"
>
<DialogHeader className="border-b border-white/10 bg-[#09090b] px-6 py-5">
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-orange-500" />
套用 PSD 模板
</DialogTitle>
<DialogDescription>
这里是直接把当前图片和输入文字替换进 PSD 模板,不会再次调用 AI 生图模型。
</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[340px_minmax(0,1fr)] xl:grid-cols-[340px_minmax(0,1fr)_360px]">
<div className="min-h-0 border-b border-white/10 bg-[#09090b] p-5 lg:border-b-0 lg:border-r">
<ScrollArea className="h-full pr-4">
<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="mx-auto w-full max-w-[240px] overflow-hidden rounded-2xl border border-white/10 bg-white/5">
<div
style={{
aspectRatio: `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`,
}}
>
<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.brandName}</Badge>
<Badge variant="secondary">
{selectedTemplate.width} x {selectedTemplate.height}
</Badge>
<Badge variant="outline">{editableImageLayers.length} 个可替换图片</Badge>
<Badge variant="outline">{editableTextLayers.length} 个可编辑文字</Badge>
{fixedLayerCount > 0 ? (
<Badge variant="outline">{fixedLayerCount} 个固定图层</Badge>
) : null}
</div>
{selectedTemplate.description ? (
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-sm text-muted-foreground">
{selectedTemplate.description}
</div>
) : null}
<div className="rounded-xl border border-dashed border-white/15 bg-white/[0.02] p-3 text-xs leading-6 text-muted-foreground">
用户侧这里只会显示后台已经开放的槽位:
图片图层显示为“可替换图片”,文字图层显示为“可编辑文字”,其余固定图层不会出现在这里。
</div>
<Button
onClick={handleRender}
disabled={isRendering}
className="w-full bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
>
{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 border-white/15 p-5 text-sm text-muted-foreground">
{isLoading
? "正在加载可用模板..."
: normalizedBrandName
? `当前品牌“${normalizedBrandName}”下还没有可用 PSD 模板,请先去后台配置。`
: "当前还没有启用的 PSD 模板,请先去后台配置。"}
</div>
)}
</div>
</ScrollArea>
</div>
<div className="min-h-0 border-b border-white/10 bg-[#09090b] p-5 xl:border-b-0 xl:border-r">
<div className="mb-4 flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">槽位内容</div>
{selectedTemplate ? (
<>
<Badge variant="outline">{editableImageLayers.length} 个可替换图片</Badge>
<Badge variant="outline">{editableTextLayers.length} 个可编辑文字</Badge>
</>
) : null}
</div>
<div className="mb-4 text-xs text-muted-foreground">
图片槽位直接替换图片,文字槽位直接修改文案,不会再次调用 AI 生图。
</div>
<ScrollArea className="h-full min-h-0 pr-4">
{selectedTemplate ? (
<div className="space-y-6">
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">可替换图片</div>
<Badge variant="secondary">{editableImageLayers.length}</Badge>
</div>
{editableImageLayers.length > 0 ? (
editableImageLayers.map((layer) => {
const assignedUrl = imageAssignments[layer.id] || "";
const slotUploadInputId = `psd-slot-upload-${layer.id}`;
return (
<div key={layer.id} className="rounded-2xl border border-white/10 bg-white/[0.02] 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}
{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 border-white/10 bg-white/5">
<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 border-white/15 bg-white/[0.02] 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>
);
})
) : (
<div className="rounded-2xl border border-dashed border-white/15 p-5 text-sm text-muted-foreground">
这个模板当前没有开放可替换图片槽位。
</div>
)}
</section>
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">可编辑文字</div>
<Badge variant="secondary">{editableTextLayers.length}</Badge>
</div>
{editableTextLayers.length > 0 ? (
editableTextLayers.map((layer) => {
const textValue = textAssignments[layer.id] || "";
return (
<div key={layer.id} className="rounded-2xl border border-white/10 bg-white/[0.02] 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 border-white/15 p-5 text-sm text-muted-foreground">
这个模板当前没有开放可编辑文字槽位。
</div>
)}
</section>
</div>
) : (
<div className="rounded-2xl border border-dashed border-white/15 p-5 text-sm text-muted-foreground">
{normalizedBrandName ? "请先选择一个模板。" : "请先在工作流中选择品牌后再使用 PSD 模板。"}
</div>
)}
</ScrollArea>
</div>
<div className="min-h-0 bg-[#09090b] 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="flex h-full min-h-0 flex-col justify-between gap-4">
<div className="mx-auto w-full max-w-[280px] overflow-hidden rounded-2xl border border-white/10 bg-white/5">
<div
style={{
aspectRatio: selectedTemplate
? `${Math.max(1, selectedTemplate.width)} / ${Math.max(1, selectedTemplate.height)}`
: "1 / 1",
}}
>
<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-full min-h-0 flex-col items-center justify-center rounded-2xl border border-dashed border-white/15 bg-white/[0.02] px-6 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
{editableTextLayers.length > 0 ? (
<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">
{normalizedBrandName
? "确认替换图片和文字后,点击“应用模板并导出”,这里会显示最终图片。"
: "请先返回工作流区域选择品牌,再打开 PSD 模板。"}
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}