1257 lines
58 KiB
TypeScript
1257 lines
58 KiB
TypeScript
"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>
|
||
);
|
||
}
|