ai-chatbot/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.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

1257 lines
58 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 Link from "next/link";
import { useEffect, useMemo, useState, type ChangeEvent } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import {
ArrowLeft,
Image as ImageIcon,
Layers,
Loader2,
Save,
Sparkles,
Trash2,
Type,
Upload,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
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 { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import type {
AgentPsdFont,
AgentPsdFontListResponse,
AgentPsdFontMutationResponse,
} from "@/lib/agents/psd-font-config";
import type {
AgentPsdTemplate,
AgentPsdTemplateLayerSlot,
AgentPsdTemplateListResponse,
AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config";
const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json());
const SYSTEM_FONT_OPTIONS = [
{ id: "__system_sans", label: "系统无衬线", family: "sans-serif" },
{ id: "__system_serif", label: "系统衬线", family: "serif" },
{ id: "__system_mono", label: "系统等宽", family: "monospace" },
];
function cloneTemplate(template: AgentPsdTemplate | null) {
return template ? (JSON.parse(JSON.stringify(template)) as AgentPsdTemplate) : null;
}
function countEditableLayers(template: AgentPsdTemplate | null) {
return template?.layers.filter((layer) => layer.isEditable).length || 0;
}
function countEditableTextLayers(template: AgentPsdTemplate | null) {
return (
template?.layers.filter((layer) => layer.isEditable && layer.slotType === "text").length || 0
);
}
export function PsdTemplatesSettingsClient({
backHref,
backLabel,
}: {
backHref: string;
backLabel: string;
}) {
const { data, mutate } = useSWR<AgentPsdTemplateListResponse>("/api/admin/psd-templates", fetcher);
const { data: fontsData, mutate: mutateFonts } = useSWR<AgentPsdFontListResponse>(
"/api/admin/psd-fonts",
fetcher
);
const templates = useMemo(() => (data?.success ? data.templates : []), [data]);
const fonts = useMemo(() => (fontsData?.success ? fontsData.fonts : []), [fontsData]);
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const [draftTemplate, setDraftTemplate] = useState<AgentPsdTemplate | null>(null);
const [layerKeyword, setLayerKeyword] = useState("");
const [uploadName, setUploadName] = useState("");
const [uploadDescription, setUploadDescription] = useState("");
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [fontUploadName, setFontUploadName] = useState("");
const [fontFamily, setFontFamily] = useState("");
const [fontUploadFile, setFontUploadFile] = useState<File | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isUploadingFont, setIsUploadingFont] = useState(false);
const [deletingFontId, setDeletingFontId] = useState<string | null>(null);
const selectedTemplate = useMemo(
() => templates.find((template) => template.id === selectedTemplateId) || null,
[selectedTemplateId, templates]
);
const fontOptions = useMemo(
() => [
...SYSTEM_FONT_OPTIONS,
...fonts.map((font) => ({
id: font.id,
label: font.name,
family: font.family,
})),
],
[fonts]
);
useEffect(() => {
if (!templates.length) {
setSelectedTemplateId(null);
return;
}
if (!selectedTemplateId || !templates.some((template) => template.id === selectedTemplateId)) {
setSelectedTemplateId(templates[0].id);
}
}, [selectedTemplateId, templates]);
useEffect(() => {
setDraftTemplate(cloneTemplate(selectedTemplate));
setLayerKeyword("");
}, [selectedTemplate]);
const filteredLayers = useMemo(() => {
if (!draftTemplate) return [];
const keyword = layerKeyword.trim().toLowerCase();
if (!keyword) return draftTemplate.layers;
return draftTemplate.layers.filter((layer) => {
const haystack = `${layer.layerName} ${layer.layerPath} ${layer.layerId}`.toLowerCase();
return haystack.includes(keyword);
});
}, [draftTemplate, layerKeyword]);
function updateDraftLayer(
layerId: string,
updater: (layer: AgentPsdTemplateLayerSlot) => AgentPsdTemplateLayerSlot
) {
setDraftTemplate((current) => {
if (!current) return current;
return {
...current,
layers: current.layers.map((layer) => (layer.id === layerId ? updater(layer) : layer)),
};
});
}
function handleTemplateFileChange(event: ChangeEvent<HTMLInputElement>) {
const nextFile = event.target.files?.[0] || null;
setUploadFile(nextFile);
if (nextFile && !uploadName.trim()) {
setUploadName(nextFile.name.replace(/\.[^.]+$/, ""));
}
}
function handleFontFileChange(event: ChangeEvent<HTMLInputElement>) {
const nextFile = event.target.files?.[0] || null;
setFontUploadFile(nextFile);
if (nextFile) {
const normalizedName = nextFile.name.replace(/\.[^.]+$/, "");
if (!fontUploadName.trim()) {
setFontUploadName(normalizedName);
}
if (!fontFamily.trim()) {
setFontFamily(normalizedName);
}
}
}
async function handleCreateTemplate() {
if (!uploadFile) {
toast.error("请先选择 PSD / PSB 文件");
return;
}
const formData = new FormData();
formData.set("name", uploadName.trim() || uploadFile.name.replace(/\.[^.]+$/, ""));
formData.set("description", uploadDescription.trim());
formData.set("status", "disabled");
formData.set("file", uploadFile);
try {
setIsCreating(true);
const response = await fetch("/api/admin/psd-templates", {
method: "POST",
body: formData,
});
const payload = (await response.json()) as AgentPsdTemplateMutationResponse;
if (!response.ok || !payload.success || !payload.template) {
throw new Error(payload.error || "PSD 模板上传失败");
}
toast.success("PSD 模板已上传,请继续配置图层");
setUploadName("");
setUploadDescription("");
setUploadFile(null);
await mutate();
setSelectedTemplateId(payload.template.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "PSD 模板上传失败");
} finally {
setIsCreating(false);
}
}
async function handleUploadFont() {
if (!fontUploadFile) {
toast.error("请先选择字体文件");
return;
}
const formData = new FormData();
formData.set("name", fontUploadName.trim() || fontUploadFile.name.replace(/\.[^.]+$/, ""));
formData.set("family", fontFamily.trim() || fontUploadName.trim() || "CustomFont");
formData.set("file", fontUploadFile);
try {
setIsUploadingFont(true);
const response = await fetch("/api/admin/psd-fonts", {
method: "POST",
body: formData,
});
const payload = (await response.json()) as AgentPsdFontMutationResponse;
if (!response.ok || !payload.success || !payload.font) {
throw new Error(payload.error || "字体上传失败");
}
toast.success("字体已上传到字体库");
setFontUploadName("");
setFontFamily("");
setFontUploadFile(null);
await mutateFonts();
} catch (error) {
toast.error(error instanceof Error ? error.message : "字体上传失败");
} finally {
setIsUploadingFont(false);
}
}
async function handleDeleteFont(font: AgentPsdFont) {
const confirmed = window.confirm(`确认删除字体“${font.name}”吗?`);
if (!confirmed) return;
try {
setDeletingFontId(font.id);
const response = await fetch(`/api/admin/psd-fonts/${encodeURIComponent(font.id)}`, {
method: "DELETE",
});
const payload = (await response.json()) as AgentPsdFontMutationResponse;
if (!response.ok || !payload.success) {
throw new Error(payload.error || "字体删除失败");
}
toast.success("字体已删除");
await mutateFonts();
} catch (error) {
toast.error(error instanceof Error ? error.message : "字体删除失败");
} finally {
setDeletingFontId(null);
}
}
async function handleSaveTemplate() {
if (!draftTemplate) return;
try {
setIsSaving(true);
const response = await fetch(
`/api/admin/psd-templates/${encodeURIComponent(draftTemplate.id)}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: draftTemplate.name,
description: draftTemplate.description || "",
status: draftTemplate.status,
layers: draftTemplate.layers,
}),
}
);
const payload = (await response.json()) as AgentPsdTemplateMutationResponse;
if (!response.ok || !payload.success || !payload.template) {
throw new Error(payload.error || "PSD 模板保存失败");
}
toast.success("PSD 模板配置已保存");
await mutate();
setSelectedTemplateId(payload.template.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "PSD 模板保存失败");
} finally {
setIsSaving(false);
}
}
async function handleDeleteTemplate() {
if (!selectedTemplate) return;
const confirmed = window.confirm(`确认删除模板“${selectedTemplate.name}”吗?`);
if (!confirmed) return;
try {
setIsDeleting(true);
const response = await fetch(
`/api/admin/psd-templates/${encodeURIComponent(selectedTemplate.id)}`,
{
method: "DELETE",
}
);
const payload = (await response.json()) as AgentPsdTemplateMutationResponse;
if (!response.ok || !payload.success) {
throw new Error(payload.error || "PSD 模板删除失败");
}
toast.success("PSD 模板已删除");
setSelectedTemplateId(null);
await mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : "PSD 模板删除失败");
} finally {
setIsDeleting(false);
}
}
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">
<div className="container mx-auto px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-rose-600 shadow-lg shadow-orange-500/20">
<Sparkles className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-xl font-semibold tracking-tight">PSD 模板配置</h1>
<p className="text-sm text-muted-foreground">
上传 PSD 模板、管理字体库、配置图片和文字槽位,并在智能体结果区直接套版导出
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href={backHref}>
<ArrowLeft className="mr-2 h-4 w-4" />
{backLabel}
</Link>
</Button>
<Button
onClick={handleSaveTemplate}
disabled={!draftTemplate || isSaving}
size="sm"
className="bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
>
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
保存模板
</Button>
</div>
</div>
</div>
</header>
<div className="border-b bg-muted/30">
<div className="container mx-auto px-6 py-3">
<div className="flex flex-wrap items-center gap-4">
<Badge variant="secondary" className="font-mono">
{templates.length}
</Badge>
<span className="text-sm text-muted-foreground">模板数量</span>
<Badge variant="secondary" className="font-mono">
{fonts.length}
</Badge>
<span className="text-sm text-muted-foreground">字体数量</span>
<Badge variant="secondary" className="font-mono">
{countEditableLayers(draftTemplate)}
</Badge>
<span className="text-sm text-muted-foreground">当前可编辑槽位</span>
<Badge variant="secondary" className="font-mono">
{countEditableTextLayers(draftTemplate)}
</Badge>
<span className="text-sm text-muted-foreground">当前文字槽位</span>
{draftTemplate ? (
<>
<Badge variant={draftTemplate.status === "active" ? "default" : "outline"}>
{draftTemplate.status === "active" ? "已启用" : "未启用"}
</Badge>
<span className="text-sm text-muted-foreground">
{draftTemplate.width} x {draftTemplate.height}
</span>
</>
) : null}
</div>
</div>
</div>
<div className="container mx-auto grid gap-6 px-6 py-6 lg:grid-cols-[360px,minmax(0,1fr)]">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">上传新模板</CardTitle>
<CardDescription>支持 PSD / PSB,建议图层命名清晰,便于后台配置图片位和文字位。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="psdTemplateName">模板名称</Label>
<Input
id="psdTemplateName"
value={uploadName}
onChange={(event) => setUploadName(event.target.value)}
placeholder="例如:主图包装模板"
/>
</div>
<div className="space-y-2">
<Label htmlFor="psdTemplateDescription">模板说明</Label>
<Textarea
id="psdTemplateDescription"
value={uploadDescription}
onChange={(event) => setUploadDescription(event.target.value)}
placeholder="说明该模板适合哪个结果图场景"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="psdTemplateFile">PSD 文件</Label>
<Input
id="psdTemplateFile"
type="file"
accept=".psd,.psb"
onChange={handleTemplateFileChange}
/>
<p className="text-xs text-muted-foreground">
{uploadFile ? `已选择:${uploadFile.name}` : "支持 80MB 以内 PSD / PSB 文件"}
</p>
</div>
<Button onClick={handleCreateTemplate} disabled={isCreating} className="w-full">
{isCreating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传并解析模板
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">字体库</CardTitle>
<CardDescription>上传 TTF / OTF / WOFF / WOFF2,供 PSD 文字槽位渲染使用。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="psdFontName">字体名称</Label>
<Input
id="psdFontName"
value={fontUploadName}
onChange={(event) => setFontUploadName(event.target.value)}
placeholder="例如:思源黑体-中黑"
/>
</div>
<div className="space-y-2">
<Label htmlFor="psdFontFamily">CSS 字体族</Label>
<Input
id="psdFontFamily"
value={fontFamily}
onChange={(event) => setFontFamily(event.target.value)}
placeholder="例如:SourceHanSansCN-Medium"
/>
</div>
<div className="space-y-2">
<Label htmlFor="psdFontFile">字体文件</Label>
<Input
id="psdFontFile"
type="file"
accept=".ttf,.otf,.woff,.woff2"
onChange={handleFontFileChange}
/>
<p className="text-xs text-muted-foreground">
{fontUploadFile
? `已选择:${fontUploadFile.name}`
: "建议按实际字重分别上传多个字体文件"}
</p>
</div>
<Button onClick={handleUploadFont} disabled={isUploadingFont} className="w-full" variant="outline">
{isUploadingFont ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Type className="mr-2 h-4 w-4" />
)}
上传到字体库
</Button>
<Separator />
<div className="space-y-3">
{fonts.length === 0 ? (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
还没有上传字体,文字槽位目前只能使用系统基础字体。
</div>
) : (
fonts.map((font) => (
<div key={font.id} className="rounded-2xl border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{font.name}</div>
<div className="mt-1 text-xs text-muted-foreground">{font.family}</div>
<div className="mt-1 text-[11px] text-muted-foreground">{font.fileName}</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteFont(font)}
disabled={deletingFontId === font.id}
>
{deletingFontId === font.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">模板列表</CardTitle>
<CardDescription>选择一个模板继续配置槽位。</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{templates.length === 0 ? (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
还没有 PSD 模板,先上传一个模板开始配置。
</div>
) : (
templates.map((template) => {
const isActive = template.id === selectedTemplateId;
return (
<button
key={template.id}
type="button"
onClick={() => setSelectedTemplateId(template.id)}
className={`w-full rounded-2xl border p-4 text-left transition ${
isActive
? "border-orange-400/50 bg-orange-500/10"
: "border-border bg-background hover:border-orange-300/40 hover:bg-muted/30"
}`}
>
<div className="flex items-start justify-between gap-3">
<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} 图层
</div>
</div>
<Badge variant={template.status === "active" ? "default" : "outline"}>
{template.status === "active" ? "启用" : "停用"}
</Badge>
</div>
{template.description ? (
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground">
{template.description}
</div>
) : null}
</button>
);
})
)}
</CardContent>
</Card>
</div>
<div className="space-y-6">
{draftTemplate ? (
<>
<Card>
<CardHeader>
<CardTitle className="text-base">模板基础信息</CardTitle>
<CardDescription>启用前请至少配置一个可编辑槽位,文字槽位建议明确字体和字号。</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[320px,minmax(0,1fr)]">
<div className="space-y-3">
<div className="overflow-hidden rounded-2xl border bg-muted/20">
<div className="aspect-[4/5] w-full bg-black/5">
<img
src={draftTemplate.previewUrl}
alt={draftTemplate.name}
className="h-full w-full object-contain"
/>
</div>
</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}
</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}
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="draftTemplateName">模板名称</Label>
<Input
id="draftTemplateName"
value={draftTemplate.name}
onChange={(event) =>
setDraftTemplate((current) =>
current ? { ...current, name: event.target.value } : current
)
}
/>
</div>
<div className="space-y-2">
<Label>启用状态</Label>
<Select
value={draftTemplate.status}
onValueChange={(value) =>
setDraftTemplate((current) =>
current
? {
...current,
status: value === "active" ? "active" : "disabled",
}
: current
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disabled">停用</SelectItem>
<SelectItem value="active">启用</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="draftTemplateDescription">模板说明</Label>
<Textarea
id="draftTemplateDescription"
value={draftTemplate.description || ""}
onChange={(event) =>
setDraftTemplate((current) =>
current ? { ...current, description: event.target.value } : current
)
}
rows={4}
placeholder="说明该模板用于什么结果图、适合哪个业务场景"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Badge variant="outline">文件:{draftTemplate.fileName}</Badge>
<Badge variant="secondary">上传图层数:{draftTemplate.layerCount}</Badge>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteTemplate}
disabled={isDeleting}
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
删除模板
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="text-base">槽位配置</CardTitle>
<CardDescription>
图片槽位可配置默认结果图和填充方式,文字槽位可配置字体、字号、颜色和换行区域。
</CardDescription>
</div>
<div className="w-full md:w-[280px]">
<Input
value={layerKeyword}
onChange={(event) => setLayerKeyword(event.target.value)}
placeholder="按图层名 / 路径筛选"
/>
</div>
</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">
当前筛选条件下没有图层,请换个关键词试试。
</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="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">#{index + 1}</Badge>
<span className="text-sm font-medium">{layer.layerName}</span>
<Badge variant="secondary">
{layer.width} x {layer.height}
</Badge>
<Badge variant={layer.slotType === "text" ? "default" : "outline"}>
{layer.slotType === "text" ? "文字槽位" : "图片槽位"}
</Badge>
{layer.slotType === "image" && layer.maskUrl ? (
<Badge variant="outline">已生成蒙版</Badge>
) : null}
</div>
<div className="text-xs text-muted-foreground">{layer.layerPath}</div>
<div className="text-[11px] text-muted-foreground">
坐标:({layer.x}, {layer.y}) · layerId: {layer.layerId}
</div>
</div>
<div className="flex items-center gap-2 rounded-full border px-3 py-1">
<Layers className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
深度 {layer.depth}
</span>
</div>
</div>
<Separator className="my-4" />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">槽位类型</div>
<Select
value={layer.slotType}
onValueChange={(value) =>
updateDraftLayer(layer.id, (current) => {
if (value === "text" && current.slotType === "image") {
return {
...current,
slotType: "text",
defaultText: current.layerName,
placeholder: current.layerName,
fontFamily: "sans-serif",
fontLabel: "系统无衬线",
fontSize: Math.max(
16,
Math.min(current.height, Math.round(current.height * 0.78))
),
lineHeight: 1.2,
letterSpacing: 0,
fontWeight: 500,
textColor: "#111111",
textAlign: "left",
paddingX: 0,
paddingY: 0,
maxLines: 4,
};
}
if (value === "image" && current.slotType === "text") {
return {
...current,
slotType: "image",
isDefaultResultSource: false,
allowUserUploadOverride: true,
replaceMode: "cover",
};
}
return current;
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="image">图片槽位</SelectItem>
<SelectItem value="text">文字槽位</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between rounded-xl border p-3">
<div className="space-y-0.5">
<div className="text-sm font-medium">可编辑</div>
<div className="text-xs text-muted-foreground">
{layer.slotType === "text" ? "允许用户输入文字" : "允许用户上传图片"}
</div>
</div>
<Switch
checked={layer.isEditable}
onCheckedChange={(checked) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "image"
? {
...current,
isEditable: checked,
isRequired: checked ? current.isRequired : false,
isDefaultResultSource: checked
? current.isDefaultResultSource
: false,
allowUserUploadOverride: checked
? current.allowUserUploadOverride
: false,
}
: {
...current,
isEditable: checked,
isRequired: checked ? current.isRequired : false,
}
)
}
/>
</div>
<div className="flex items-center justify-between rounded-xl border p-3">
<div className="space-y-0.5">
<div className="text-sm font-medium">必传</div>
<div className="text-xs text-muted-foreground">
渲染前必须给这个槽位分配内容
</div>
</div>
<Switch
checked={layer.isEditable && layer.isRequired}
disabled={!layer.isEditable}
onCheckedChange={(checked) =>
updateDraftLayer(layer.id, (current) => ({
...current,
isRequired: current.isEditable ? checked : false,
}))
}
/>
</div>
{layer.slotType === "image" ? (
<div className="flex items-center justify-between rounded-xl border p-3">
<div className="space-y-0.5">
<div className="text-sm font-medium">默认结果图槽位</div>
<div className="text-xs text-muted-foreground">
打开结果区弹层时自动带入当前结果图
</div>
</div>
<Switch
checked={layer.isEditable && layer.isDefaultResultSource}
disabled={!layer.isEditable}
onCheckedChange={(checked) =>
setDraftTemplate((current) => {
if (!current) return current;
return {
...current,
layers: current.layers.map((item) =>
item.slotType === "image"
? item.id === layer.id
? {
...item,
isDefaultResultSource: item.isEditable
? checked
: false,
}
: {
...item,
isDefaultResultSource: false,
}
: item
),
};
})
}
/>
</div>
) : (
<div className="rounded-xl border p-3">
<div className="text-sm font-medium">文字默认值</div>
<div className="mt-1 text-xs text-muted-foreground">
作为弹层里的初始文案,也可在用户侧继续修改
</div>
<Textarea
className="mt-3"
rows={3}
value={layer.defaultText || ""}
onChange={(event) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "text"
? { ...current, defaultText: event.target.value }
: current
)
}
/>
</div>
)}
</div>
{layer.slotType === "image" ? (
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="flex items-center justify-between rounded-xl border p-3">
<div className="space-y-0.5">
<div className="text-sm font-medium">允许手动覆盖</div>
<div className="text-xs text-muted-foreground">
用户可以重新上传替换这个槽位
</div>
</div>
<Switch
checked={layer.isEditable && layer.allowUserUploadOverride}
disabled={!layer.isEditable}
onCheckedChange={(checked) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "image"
? {
...current,
allowUserUploadOverride: current.isEditable
? checked
: false,
}
: current
)
}
/>
</div>
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">填充模式</div>
<Select
value={layer.replaceMode}
disabled={!layer.isEditable}
onValueChange={(value) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "image"
? {
...current,
replaceMode:
value === "contain" || value === "stretch"
? value
: "cover",
}
: current
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cover">裁切铺满</SelectItem>
<SelectItem value="contain">完整显示</SelectItem>
<SelectItem value="stretch">强制拉伸</SelectItem>
</SelectContent>
</Select>
</div>
</div>
) : (
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">字体</div>
<Select
value={
layer.fontId ||
SYSTEM_FONT_OPTIONS.find(
(option) => option.family === layer.fontFamily
)?.id ||
"__system_sans"
}
onValueChange={(value) =>
updateDraftLayer(layer.id, (current) => {
if (current.slotType !== "text") return current;
const systemFont = SYSTEM_FONT_OPTIONS.find(
(option) => option.id === value
);
if (systemFont) {
return {
...current,
fontId: undefined,
fontFamily: systemFont.family,
fontLabel: systemFont.label,
};
}
const uploadedFont = fonts.find((font) => font.id === value);
return uploadedFont
? {
...current,
fontId: uploadedFont.id,
fontFamily: uploadedFont.family,
fontLabel: uploadedFont.name,
}
: current;
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{fontOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">字号</div>
<Input
type="number"
min={10}
max={300}
value={layer.fontSize}
onChange={(event) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "text"
? {
...current,
fontSize: Math.max(10, Number(event.target.value) || 10),
}
: current
)
}
/>
</div>
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">行高倍率</div>
<Input
type="number"
min={1}
max={3}
step="0.1"
value={layer.lineHeight}
onChange={(event) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "text"
? {
...current,
lineHeight: Math.max(
1,
Number(event.target.value) || 1
),
}
: current
)
}
/>
</div>
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">字重</div>
<Input
type="number"
min={100}
max={900}
step={100}
value={layer.fontWeight}
onChange={(event) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "text"
? {
...current,
fontWeight: Math.max(
100,
Math.min(900, Number(event.target.value) || 500)
),
}
: current
)
}
/>
</div>
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">文字颜色</div>
<Input
value={layer.textColor}
onChange={(event) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "text"
? { ...current, textColor: event.target.value }
: current
)
}
/>
</div>
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">对齐</div>
<Select
value={layer.textAlign}
onValueChange={(value) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "text"
? {
...current,
textAlign:
value === "center" || value === "right"
? value
: "left",
}
: current
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">左对齐</SelectItem>
<SelectItem value="center">居中</SelectItem>
<SelectItem value="right">右对齐</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">左右内边距</div>
<Input
type="number"
min={0}
max={200}
value={layer.paddingX}
onChange={(event) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "text"
? { ...current, paddingX: Math.max(0, Number(event.target.value) || 0) }
: current
)
}
/>
</div>
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">上下内边距</div>
<Input
type="number"
min={0}
max={200}
value={layer.paddingY}
onChange={(event) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "text"
? { ...current, paddingY: Math.max(0, Number(event.target.value) || 0) }
: current
)
}
/>
</div>
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">最多行数</div>
<Input
type="number"
min={1}
max={20}
value={layer.maxLines}
onChange={(event) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "text"
? { ...current, maxLines: Math.max(1, Number(event.target.value) || 1) }
: current
)
}
/>
</div>
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">字间距</div>
<Input
type="number"
min={-10}
max={50}
step="0.5"
value={layer.letterSpacing}
onChange={(event) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "text"
? { ...current, letterSpacing: Number(event.target.value) || 0 }
: current
)
}
/>
</div>
<div className="rounded-xl border p-3 md:col-span-2 xl:col-span-4">
<div className="text-sm font-medium">输入提示文案</div>
<Textarea
className="mt-3"
rows={2}
value={layer.placeholder || ""}
onChange={(event) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "text"
? { ...current, placeholder: event.target.value }
: current
)
}
placeholder="例如:输入品牌名 / 活动主题 / 卖点标题"
/>
</div>
</div>
)}
</div>
))
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="flex min-h-[480px] flex-col items-center justify-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted">
<ImageIcon className="h-8 w-8 text-muted-foreground" />
</div>
<div>
<div className="text-base font-medium">还没有选中的模板</div>
<div className="mt-2 text-sm text-muted-foreground">
先在左侧上传一个 PSD 模板,或者从已有模板里选择一个开始配置。
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}