1862 lines
83 KiB
TypeScript
1862 lines
83 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 {
|
||
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>
|
||
);
|
||
}
|