465 lines
19 KiB
TypeScript
465 lines
19 KiB
TypeScript
"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>
|
||
);
|
||
}
|