ai-chatbot/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx
Your Name ed7534a1ff
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: expose PSD parse action in upload panel
2026-03-27 20:43:41 +08:00

1862 lines
83 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 {
AlertTriangle,
ArrowLeft,
Clock3,
Image as ImageIcon,
Layers,
Loader2,
RefreshCw,
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 { Progress } from "@/components/ui/progress";
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
);
}
function getTemplateParseLabel(template: AgentPsdTemplate | null) {
if (!template) return "未解析";
switch (template.parseStatus) {
case "processing":
return "解析中";
case "completed":
return "已解析";
case "failed":
return "解析失败";
default:
return "待解析";
}
}
function getTemplateParseBadgeVariant(template: AgentPsdTemplate | null) {
if (!template) return "outline" as const;
switch (template.parseStatus) {
case "processing":
return "secondary" as const;
case "completed":
return "default" as const;
case "failed":
return "destructive" as const;
default:
return "outline" as const;
}
}
function getTemplateParseProgress(template: AgentPsdTemplate | null) {
if (!template) return 0;
if (template.parseStatus === "completed") return 100;
return Math.max(0, Math.min(100, Number(template.parseProgress || 0)));
}
function getTemplateParseActionLabel(template: AgentPsdTemplate | null) {
return template?.parseStatus === "completed" ? "重新解析" : "解析 PSD";
}
function shouldHydrateDraftTemplate(
current: AgentPsdTemplate | null,
next: AgentPsdTemplate | null
) {
if (!current || !next) return true;
if (current.id !== next.id) return true;
return (
current.parseStatus !== next.parseStatus ||
getTemplateParseProgress(current) !== getTemplateParseProgress(next) ||
(current.parseStage || "") !== (next.parseStage || "") ||
(current.parseError || "") !== (next.parseError || "") ||
(current.previewUrl || "") !== (next.previewUrl || "") ||
current.layers.length !== next.layers.length ||
(current.layers.length === 0 && next.layers.length > 0)
);
}
export function PsdTemplatesSettingsClient({
backHref,
backLabel,
}: {
backHref: string;
backLabel: string;
}) {
const { data, mutate } = useSWR<AgentPsdTemplateListResponse>("/api/admin/psd-templates", fetcher);
const { data: siteSettingsData } = useSWR<{
agentWorkflowUiConfig?: {
inputOptions?: {
brands?: string[];
};
};
}>("/api/site-settings", fetcher);
const { data: fontsData, mutate: mutateFonts } = useSWR<AgentPsdFontListResponse>(
"/api/admin/psd-fonts",
fetcher
);
const templates = useMemo(() => (data?.success ? data.templates : []), [data]);
const brandOptions = useMemo(
() =>
Array.from(
new Set(
[
...(siteSettingsData?.agentWorkflowUiConfig?.inputOptions?.brands || []),
...templates.map((template) => template.brandName),
]
.map((item) => String(item || "").trim())
.filter(Boolean)
)
),
[siteSettingsData, templates]
);
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 [uploadBrandName, setUploadBrandName] = 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 [isCreatingAndParsing, setIsCreatingAndParsing] = useState(false);
const [templateUploadPercent, setTemplateUploadPercent] = useState(0);
const [templateUploadStatus, setTemplateUploadStatus] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deletingTemplateId, setDeletingTemplateId] = useState<string | null>(null);
const [isUploadingFont, setIsUploadingFont] = useState(false);
const [deletingFontId, setDeletingFontId] = useState<string | null>(null);
const [parsingTemplateIds, setParsingTemplateIds] = useState<string[]>([]);
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(() => {
if (!selectedTemplate) {
setDraftTemplate(null);
setLayerKeyword("");
return;
}
setDraftTemplate((current) =>
shouldHydrateDraftTemplate(current, selectedTemplate)
? cloneTemplate(selectedTemplate)
: current
);
}, [selectedTemplate]);
useEffect(() => {
setLayerKeyword("");
}, [selectedTemplateId]);
useEffect(() => {
const hasRunningTask =
parsingTemplateIds.length > 0 ||
templates.some((template) => template.parseStatus === "processing");
if (!hasRunningTask) {
return;
}
const intervalId = window.setInterval(() => {
void mutate();
}, 1200);
return () => {
window.clearInterval(intervalId);
};
}, [mutate, parsingTemplateIds.length, templates]);
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(options?: { parseAfterUpload?: boolean }) {
const parseAfterUpload = options?.parseAfterUpload === true;
if (!uploadFile) {
toast.error("请先选择 PSD / PSB 文件");
return;
}
if (!uploadBrandName.trim()) {
toast.error("请先选择模板品牌");
return;
}
const formData = new FormData();
formData.set("name", uploadName.trim() || uploadFile.name.replace(/\.[^.]+$/, ""));
formData.set("brandName", uploadBrandName.trim());
formData.set("description", uploadDescription.trim());
formData.set("status", "disabled");
formData.set("file", uploadFile);
try {
setIsCreating(!parseAfterUpload);
setIsCreatingAndParsing(parseAfterUpload);
setTemplateUploadPercent(0);
setTemplateUploadStatus("上传中 0%");
const payload = await new Promise<AgentPsdTemplateMutationResponse>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
const percent = Math.round((event.loaded / event.total) * 100);
setTemplateUploadPercent(percent);
setTemplateUploadStatus(`上传中 ${percent}%`);
};
xhr.upload.onloadend = () => {
setTemplateUploadPercent(100);
setTemplateUploadStatus("文件已上传,正在创建待解析模板...");
};
xhr.onload = () => {
let responsePayload: AgentPsdTemplateMutationResponse | null = null;
try {
responsePayload = JSON.parse(xhr.responseText) as AgentPsdTemplateMutationResponse;
} catch {
reject(new Error("解析响应失败"));
return;
}
if (xhr.status >= 200 && xhr.status < 300 && responsePayload.success) {
resolve(responsePayload);
return;
}
reject(new Error(responsePayload.error || "PSD 模板上传失败"));
};
xhr.onerror = () => reject(new Error("网络错误,请稍后重试"));
xhr.ontimeout = () => reject(new Error("上传超时,请稍后重试"));
xhr.open("POST", "/api/admin/psd-templates");
xhr.timeout = 10 * 60 * 1000;
xhr.send(formData);
});
if (!payload.template) {
throw new Error(payload.error || "PSD 模板上传失败");
}
setTemplateUploadPercent(100);
setTemplateUploadStatus(
parseAfterUpload ? "上传完成,正在提交 PSD 解析..." : "上传完成,等待手动解析"
);
toast.success(
parseAfterUpload
? "PSD 文件已上传,正在开始解析"
: "PSD 文件已上传,请点击“解析 PSD”生成模板图层"
);
setUploadName("");
setUploadBrandName("");
setUploadDescription("");
setUploadFile(null);
await mutate();
setSelectedTemplateId(payload.template.id);
setDraftTemplate(cloneTemplate(payload.template));
if (parseAfterUpload) {
await handleParseTemplate(payload.template, {
skipCompletedConfirm: true,
onStart: () => {
setTemplateUploadStatus("正在解析 PSD,请稍候...");
},
onDone: () => {
setTemplateUploadStatus("解析完成");
},
onError: (message) => {
setTemplateUploadStatus(`解析失败:${message}`);
},
});
}
} catch (error) {
setTemplateUploadPercent(0);
setTemplateUploadStatus("");
toast.error(error instanceof Error ? error.message : "PSD 模板上传失败");
} finally {
setIsCreating(false);
setIsCreatingAndParsing(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,
brandName: draftTemplate.brandName,
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 模板配置已保存");
setDraftTemplate(cloneTemplate(payload.template));
await mutate();
setSelectedTemplateId(payload.template.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "PSD 模板保存失败");
} finally {
setIsSaving(false);
}
}
async function handleDeleteTemplate(template = selectedTemplate) {
if (!template) return;
const confirmed = window.confirm(`确认删除模板“${template.name}”吗?`);
if (!confirmed) return;
try {
setIsDeleting(true);
setDeletingTemplateId(template.id);
const response = await fetch(
`/api/admin/psd-templates/${encodeURIComponent(template.id)}`,
{
method: "DELETE",
}
);
const payload = (await response.json()) as AgentPsdTemplateMutationResponse;
if (!response.ok || !payload.success) {
throw new Error(payload.error || "PSD 模板删除失败");
}
toast.success("PSD 模板已删除");
if (selectedTemplateId === template.id) {
setSelectedTemplateId(null);
}
await mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : "PSD 模板删除失败");
} finally {
setIsDeleting(false);
setDeletingTemplateId(null);
}
}
async function handleParseTemplate(
template = selectedTemplate,
options?: {
skipCompletedConfirm?: boolean;
onStart?: () => void;
onDone?: () => void;
onError?: (message: string) => void;
}
) {
if (!template) return;
const actionLabel = template.parseStatus === "completed" ? "重新解析" : "解析";
const confirmed =
template.parseStatus === "completed" && !options?.skipCompletedConfirm
? window.confirm(`确认重新解析模板“${template.name}”吗?`)
: true;
if (!confirmed) return;
try {
options?.onStart?.();
setParsingTemplateIds((current) =>
current.includes(template.id) ? current : [...current, template.id]
);
await mutate(
(current) =>
current
? {
...current,
templates: current.templates.map((entry) =>
entry.id === template.id
? {
...entry,
parseStatus: "processing",
parseProgress: Math.max(2, entry.parseProgress || 0),
parseStage: `${actionLabel}已提交`,
parseError: undefined,
}
: entry
),
}
: current,
{ revalidate: false }
);
if (draftTemplate?.id === template.id) {
setDraftTemplate((current) =>
current
? {
...current,
parseStatus: "processing",
parseProgress: Math.max(2, current.parseProgress || 0),
parseStage: `${actionLabel}已提交`,
parseError: undefined,
}
: current
);
}
const response = await fetch(
`/api/admin/psd-templates/${encodeURIComponent(template.id)}/parse`,
{
method: "POST",
}
);
const payload = (await response.json()) as AgentPsdTemplateMutationResponse;
if (!response.ok || !payload.success || !payload.template) {
throw new Error(payload.error || "PSD 解析失败");
}
toast.success(`模板“${payload.template.name}”解析完成`);
setDraftTemplate(cloneTemplate(payload.template));
await mutate();
setSelectedTemplateId(payload.template.id);
options?.onDone?.();
} catch (error) {
await mutate();
const message = error instanceof Error ? error.message : "PSD 解析失败";
options?.onError?.(message);
toast.error(message);
} finally {
setParsingTemplateIds((current) => current.filter((item) => item !== template.id));
}
}
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>
<Badge variant={getTemplateParseBadgeVariant(draftTemplate)}>
{getTemplateParseLabel(draftTemplate)}
</Badge>
<span className="text-sm text-muted-foreground">
{draftTemplate.parseStatus === "completed"
? `${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>所属品牌</Label>
{brandOptions.length > 0 ? (
<Select value={uploadBrandName} onValueChange={setUploadBrandName}>
<SelectTrigger>
<SelectValue placeholder="请选择品牌" />
</SelectTrigger>
<SelectContent>
{brandOptions.map((brand) => (
<SelectItem key={brand} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={uploadBrandName}
onChange={(event) => setUploadBrandName(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>
<div className="grid gap-3 sm:grid-cols-2">
<Button
onClick={() => {
void handleCreateTemplate();
}}
disabled={isCreating || isCreatingAndParsing}
variant="outline"
className="w-full"
>
{isCreating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
仅上传文件
</Button>
<Button
onClick={() => {
void handleCreateTemplate({ parseAfterUpload: true });
}}
disabled={isCreating || isCreatingAndParsing}
className="w-full bg-gradient-to-r from-orange-600 to-rose-600 hover:from-orange-500 hover:to-rose-500"
>
{isCreatingAndParsing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
上传并解析
</Button>
</div>
{templateUploadStatus ? (
<div className="space-y-2 rounded-xl border border-dashed p-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{templateUploadStatus}</span>
<span>{templateUploadPercent}%</span>
</div>
<Progress value={templateUploadPercent} className="h-2" />
</div>
) : null}
</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;
const isParsing =
template.parseStatus === "processing" ||
parsingTemplateIds.includes(template.id);
return (
<div
key={template.id}
role="button"
tabIndex={0}
onClick={() => setSelectedTemplateId(template.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
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.parseStatus === "completed"
? `${template.width} x ${template.height} · ${template.layerCount} 图层`
: "待解析完成后生成尺寸和图层"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
品牌:{template.brandName}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={template.status === "active" ? "default" : "outline"}>
{template.status === "active" ? "启用" : "停用"}
</Badge>
<Badge variant={getTemplateParseBadgeVariant(template)}>
{getTemplateParseLabel(template)}
</Badge>
<Button
type="button"
variant="outline"
size="sm"
onClick={(event) => {
event.stopPropagation();
void handleParseTemplate(template);
}}
disabled={isParsing}
>
{isParsing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{getTemplateParseActionLabel(template)}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-rose-500 hover:text-rose-600"
onClick={(event) => {
event.stopPropagation();
void handleDeleteTemplate(template);
}}
disabled={isDeleting}
>
{deletingTemplateId === template.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</div>
{template.description ? (
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground">
{template.description}
</div>
) : null}
{template.parseStatus === "processing" ? (
<div className="mt-3 space-y-2 rounded-xl border border-dashed p-3">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{template.parseStage || "正在解析 PSD..."}</span>
<span>{getTemplateParseProgress(template)}%</span>
</div>
<Progress value={getTemplateParseProgress(template)} className="h-2" />
</div>
) : null}
{template.parseStatus !== "processing" &&
template.parseStage &&
(template.parseStatus !== "completed" || Boolean(template.parseError)) ? (
<div className="mt-2 text-xs text-muted-foreground">
{template.parseStage}
</div>
) : null}
{template.parseError ? (
<div className="mt-2 rounded-xl border border-amber-300/40 bg-amber-50 px-3 py-2 text-xs text-amber-800">
{template.parseError}
</div>
) : null}
</div>
);
})
)}
</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-[260px,minmax(0,1fr)]">
<div className="space-y-3">
<div className="mx-auto w-full max-w-[220px] overflow-hidden rounded-2xl border bg-muted/20">
<div
className="flex w-full items-center justify-center bg-black/5"
style={{
aspectRatio:
draftTemplate.parseStatus === "completed"
? `${Math.max(1, draftTemplate.width)} / ${Math.max(1, draftTemplate.height)}`
: "1 / 1",
}}
>
{draftTemplate.parseStatus === "completed" ? (
<img
src={draftTemplate.previewUrl}
alt={draftTemplate.name}
className="h-full w-full object-contain"
/>
) : (
<div className="flex flex-col items-center gap-2 px-6 text-center text-muted-foreground">
<Clock3 className="h-7 w-7" />
<div className="text-sm font-medium">{getTemplateParseLabel(draftTemplate)}</div>
<div className="text-xs">
先完成 PSD 解析,再配置图层和可编辑槽位
</div>
</div>
)}
</div>
</div>
<div className="space-y-2 rounded-xl border p-3">
<div className="flex items-center justify-between gap-2">
<Badge variant={getTemplateParseBadgeVariant(draftTemplate)}>
{getTemplateParseLabel(draftTemplate)}
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => {
void handleParseTemplate(draftTemplate);
}}
disabled={
draftTemplate.parseStatus === "processing" ||
parsingTemplateIds.includes(draftTemplate.id)
}
>
{draftTemplate.parseStatus === "processing" ||
parsingTemplateIds.includes(draftTemplate.id) ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{getTemplateParseActionLabel(draftTemplate)}
</Button>
</div>
<div className="text-xs text-muted-foreground">
{draftTemplate.parseStage ||
(draftTemplate.parseStatus === "completed"
? "解析完成,可开始配置槽位"
: "上传完成后需要手动解析,适合批量上传多个 PSD 再逐个处理")}
</div>
{draftTemplate.parseStatus === "processing" ? (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{draftTemplate.parseStage || "正在解析 PSD..."}</span>
<span>{getTemplateParseProgress(draftTemplate)}%</span>
</div>
<Progress value={getTemplateParseProgress(draftTemplate)} className="h-2" />
</div>
) : null}
{draftTemplate.parseError ? (
<div className="rounded-xl border border-amber-300/40 bg-amber-50 px-3 py-2 text-xs text-amber-800">
{draftTemplate.parseError}
</div>
) : null}
</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.parseStatus === "completed"
? `${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">
{draftTemplate.parseStatus === "completed"
? `${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>
{brandOptions.length > 0 ? (
<Select
value={draftTemplate.brandName}
onValueChange={(value) =>
setDraftTemplate((current) =>
current ? { ...current, brandName: value } : current
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{brandOptions.map((brand) => (
<SelectItem key={brand} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={draftTemplate.brandName}
onChange={(event) =>
setDraftTemplate((current) =>
current ? { ...current, brandName: event.target.value } : current
)
}
placeholder="请输入品牌名称"
/>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<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"
disabled={draftTemplate.parseStatus !== "completed"}
>
启用
</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="secondary">品牌:{draftTemplate.brandName}</Badge>
<Badge variant="outline">文件:{draftTemplate.fileName}</Badge>
<Badge variant={getTemplateParseBadgeVariant(draftTemplate)}>
{getTemplateParseLabel(draftTemplate)}
</Badge>
<Badge variant="secondary">
上传图层数:
{draftTemplate.parseStatus === "completed"
? draftTemplate.layerCount
: "待解析"}
</Badge>
<Button
variant="destructive"
size="sm"
onClick={() => {
void 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>
{draftTemplate.parseStatus !== "completed" ? (
<div className="rounded-2xl border border-dashed p-8 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-muted">
<RefreshCw className="h-6 w-6 text-muted-foreground" />
</div>
<div className="mt-4 text-base font-medium">先解析 PSD,再配置槽位</div>
<div className="mt-2 text-sm text-muted-foreground">
当前模板还没有生成图层结构、缩略图和字体识别结果,完成解析后这里会自动显示可编辑配置项。
</div>
<div className="mt-4 flex justify-center">
<Button
variant="outline"
onClick={() => {
void handleParseTemplate(draftTemplate);
}}
disabled={
draftTemplate.parseStatus === "processing" ||
parsingTemplateIds.includes(draftTemplate.id)
}
>
{draftTemplate.parseStatus === "processing" ||
parsingTemplateIds.includes(draftTemplate.id) ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{getTemplateParseActionLabel(draftTemplate)}
</Button>
</div>
{draftTemplate.parseStatus === "processing" ? (
<div className="mx-auto mt-4 max-w-md space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{draftTemplate.parseStage || "正在解析 PSD..."}</span>
<span>{getTemplateParseProgress(draftTemplate)}%</span>
</div>
<Progress value={getTemplateParseProgress(draftTemplate)} className="h-2" />
</div>
) : null}
{draftTemplate.parseError ? (
<div className="mx-auto mt-4 max-w-xl rounded-xl border border-amber-300/40 bg-amber-50 px-4 py-3 text-left text-xs text-amber-800">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{draftTemplate.parseError}</span>
</div>
</div>
) : null}
</div>
) : (
<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="flex min-w-0 items-start gap-3">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted/20">
{layer.previewUrl ? (
<img
src={layer.previewUrl}
alt={`${layer.layerName} 预览`}
className="h-full w-full object-contain"
/>
) : (
<div className="text-[10px] text-muted-foreground">无预览</div>
)}
</div>
<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>
<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>
{layer.detectedFontName ? (
<div className="text-xs text-muted-foreground">
PSD识别字体:{layer.detectedFontName}
</div>
) : null}
</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>
);
}