ai-chatbot/app/(admin)/settings/agent-workflow/psd-templates/psd-templates-settings-client.tsx

3389 lines
162 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, useRef, useState, type ChangeEvent } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import {
AlertTriangle,
ArrowLeft,
ChevronDown,
ChevronUp,
Clock3,
Image as ImageIcon,
Layers,
Lock,
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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
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,
SelectGroup,
SelectItem,
SelectLabel,
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 {
getAgentWorkflowCategoryLabels,
getAgentWorkflowCategoryOptionsByBrand,
} from "@/lib/agents/workflow-ui-config";
import {
AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG,
getAgentPsdTemplateCategoryTagOptions,
normalizeAgentPsdTemplateCategoryTagConfig,
normalizeAgentPsdTemplateCategoryTags,
} from "@/lib/agents/psd-template-category-tag-config";
import type {
AgentPsdFont,
AgentPsdFontListResponse,
AgentPsdFontMutationResponse,
} from "@/lib/agents/psd-font-config";
import type {
AgentPsdTemplateMode,
AgentPsdTemplate,
AgentPsdTemplateLayerSlot,
AgentPsdTemplateListResponse,
AgentPsdTemplateMutationResponse,
} from "@/lib/agents/psd-template-config";
import type { AgentWorkflowUiConfig } from "@/types/agent";
const fetcher = (url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json());
async function fetchPsdTemplateList(url: string): Promise<AgentPsdTemplateListResponse> {
const response = await fetch(url, { cache: "no-store" });
const payload = (await response
.json()
.catch(() => null)) as Partial<AgentPsdTemplateListResponse> | null;
if (
!response.ok ||
!payload ||
payload.success !== true ||
!Array.isArray(payload.templates)
) {
throw new Error(
String(payload?.error || "").trim() || "PSD 模板读取失败"
);
}
return payload as AgentPsdTemplateListResponse;
}
const SYSTEM_FONT_OPTIONS = [
{ id: "__system_sans", label: "系统无衬线", family: "sans-serif" },
{ id: "__system_serif", label: "系统衬线", family: "serif" },
{ id: "__system_mono", label: "系统等宽", family: "monospace" },
];
const EMPTY_CATEGORY_SELECT_VALUE = "__none__";
const EMPTY_BRAND_SELECT_VALUE = "__unassigned__";
const ALL_TEMPLATE_BRANDS_SELECT_VALUE = "__all_template_brands__";
const SELF_CLIP_MASK_SELECT_VALUE = "__self_clip_mask__";
const UNASSIGNED_BRAND_LABEL = "未分类";
const MAX_PSD_TEMPLATE_FILE_SIZE = 80 * 1024 * 1024;
const PSD_TEMPLATE_MODE_OPTIONS: Array<{
value: AgentPsdTemplateMode;
label: string;
description: string;
}> = [
{
value: "main",
label: "主图模式",
description: "用于主图结果区的 PSD 套版模板。",
},
{
value: "sku",
label: "SKU图模式",
description: "用于 SKU 结果区的 PSD 套版模板。",
},
];
function getPsdTemplateModeLabel(mode?: AgentPsdTemplateMode | null) {
return mode === "sku" ? "SKU图模式" : "主图模式";
}
function formatUploadFileSize(size: number) {
if (!Number.isFinite(size) || size <= 0) return "0MB";
const mb = size / (1024 * 1024);
if (mb >= 1) return `${mb.toFixed(mb >= 10 ? 0 : 1)}MB`;
return `${Math.max(1, Math.round(size / 1024))}KB`;
}
function normalizeManagedBrandName(value?: string | null) {
const normalized = String(value || "").trim();
if (!normalized || normalized === "未分配品牌") {
return "";
}
return normalized;
}
function getQueuedFontFileKey(file: File) {
return [file.name, file.size, file.lastModified].join("__");
}
function mergeQueuedFontFiles(current: File[], incoming: File[]) {
const merged = [...current];
const seen = new Set(current.map((file) => getQueuedFontFileKey(file)));
for (const file of incoming) {
const key = getQueuedFontFileKey(file);
if (seen.has(key)) {
continue;
}
seen.add(key);
merged.push(file);
}
return merged;
}
function hasTemplateCategoryTag(tags: string[] | undefined, target: string) {
const normalizedTarget = String(target || "").trim().toLowerCase();
if (!normalizedTarget) {
return false;
}
return (tags || []).some((tag) => tag.trim().toLowerCase() === normalizedTarget);
}
function toggleTemplateCategoryTag(tags: string[] | undefined, target: string) {
const normalizedTags = normalizeAgentPsdTemplateCategoryTags(tags);
const normalizedTarget = normalizeAgentPsdTemplateCategoryTags([target])[0];
if (!normalizedTarget) {
return normalizedTags;
}
if (hasTemplateCategoryTag(normalizedTags, normalizedTarget)) {
return normalizedTags.filter((tag) => tag.trim().toLowerCase() !== normalizedTarget.toLowerCase());
}
return normalizeAgentPsdTemplateCategoryTags([...normalizedTags, normalizedTarget]);
}
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 && !layer.isSuperLocked)
.length || 0
);
}
function countEditableTextLayers(template: AgentPsdTemplate | null) {
return (
template?.layers.filter(
(layer) =>
layer.isEditable && !layer.isSuperLocked && layer.slotType === "text"
).length || 0
);
}
function hasTemplateParsedCanvas(template: AgentPsdTemplate | null) {
return Boolean(
template &&
Number(template.width || 0) > 1 &&
Number(template.height || 0) > 1 &&
String(template.previewUrl || "").trim()
);
}
function getTemplateParseLabel(template: AgentPsdTemplate | null) {
if (!template) return "未解析";
switch (template.parseStatus) {
case "processing":
return hasTemplateParsedCanvas(template) ? "补全中" : "解析中";
case "completed":
return "已解析";
case "failed":
return hasTemplateParsedCanvas(template) ? "补全失败" : "解析失败";
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) {
if (template?.parseStatus === "processing") {
return "补全中";
}
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,
error: templatesRequestError,
mutate,
isLoading,
} = useSWR<AgentPsdTemplateListResponse>(
"/api/admin/psd-templates",
fetchPsdTemplateList,
{
keepPreviousData: true,
revalidateOnFocus: false,
dedupingInterval: 10_000,
}
);
const { data: siteSettingsData, mutate: mutateSiteSettings } = useSWR<{
agentWorkflowUiConfig?: AgentWorkflowUiConfig;
agentPsdTemplateCategoryTagConfig?: {
tags?: 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 templateListErrorMessage = useMemo(() => {
if (templatesRequestError instanceof Error) {
return templatesRequestError.message.trim() || "PSD 模板读取失败";
}
if (!data || data.success) {
return "";
}
return String(data.error || "").trim() || "PSD 模板读取失败";
}, [data, templatesRequestError]);
const fonts = useMemo(() => (fontsData?.success ? fontsData.fonts : []), [fontsData]);
const brandOptions = useMemo(
() =>
Array.from(
new Set(
[
...(siteSettingsData?.agentWorkflowUiConfig?.inputOptions?.brands || []),
...templates.map((template) => template.brandName),
...fonts.map((font) => font.brandName || ""),
]
.map((item) => String(item || "").trim())
.filter(Boolean)
)
),
[siteSettingsData, templates, fonts]
);
const managedBrandOptions = useMemo(
() =>
Array.from(
new Set(brandOptions.map((brand) => normalizeManagedBrandName(brand)).filter(Boolean))
).sort((left, right) => left.localeCompare(right, "zh-CN")),
[brandOptions]
);
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 [uploadMode, setUploadMode] = useState<AgentPsdTemplateMode>("main");
const [uploadSecondaryCategory, setUploadSecondaryCategory] = useState("");
const [uploadCategoryTags, setUploadCategoryTags] = useState<string[]>([]);
const [uploadDescription, setUploadDescription] = useState("");
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [fontUploadName, setFontUploadName] = useState("");
const [fontFamily, setFontFamily] = useState("");
const [fontUploadBrandName, setFontUploadBrandName] = useState("");
const [fontUploadFiles, setFontUploadFiles] = useState<File[]>([]);
const fontFileInputRef = useRef<HTMLInputElement | 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 [savingTemplateBrandId, setSavingTemplateBrandId] = useState<string | null>(null);
const [savingFontBrandId, setSavingFontBrandId] = useState<string | null>(null);
const [categoryTagDraft, setCategoryTagDraft] = useState("");
const [isSavingCategoryTags, setIsSavingCategoryTags] = useState(false);
const [isFontListExpanded, setIsFontListExpanded] = useState(false);
const [templateBrandDrafts, setTemplateBrandDrafts] = useState<Record<string, string>>({});
const [fontBrandDrafts, setFontBrandDrafts] = useState<Record<string, string>>({});
const [parsingTemplateIds, setParsingTemplateIds] = useState<string[]>([]);
const [templateBrandFilter, setTemplateBrandFilter] = useState(ALL_TEMPLATE_BRANDS_SELECT_VALUE);
const selectedTemplate = useMemo(
() => templates.find((template) => template.id === selectedTemplateId) || null,
[selectedTemplateId, templates]
);
const workflowConfig = siteSettingsData?.agentWorkflowUiConfig || null;
const workflowCategoryLabels = useMemo(
() => getAgentWorkflowCategoryLabels(workflowConfig),
[workflowConfig]
);
const categoryTagConfig = useMemo(
() =>
normalizeAgentPsdTemplateCategoryTagConfig(
siteSettingsData?.agentPsdTemplateCategoryTagConfig
),
[siteSettingsData?.agentPsdTemplateCategoryTagConfig]
);
const availableCategoryTagOptions = useMemo(
() => getAgentPsdTemplateCategoryTagOptions(categoryTagConfig),
[categoryTagConfig]
);
const draftTemplateCategoryTagOptions = useMemo(
() =>
getAgentPsdTemplateCategoryTagOptions(
categoryTagConfig,
draftTemplate?.categoryTags || []
),
[categoryTagConfig, draftTemplate?.categoryTags]
);
const categoryLevelOptions = useMemo(
() => [
{
key: "third",
label: workflowCategoryLabels.thirdCategory,
},
{
key: "fourth",
label: workflowCategoryLabels.fourthCategory,
},
{
key: "fifth",
label: workflowCategoryLabels.fifthCategory,
},
],
[workflowCategoryLabels]
);
const uploadBrandCategoryOptions = useMemo(() => {
const options = getAgentWorkflowCategoryOptionsByBrand({
config: workflowConfig,
brand: uploadBrandName,
});
const currentValue = uploadSecondaryCategory.trim();
if (
currentValue &&
!options.some((option) => option.value.toLowerCase() === currentValue.toLowerCase())
) {
return [{ value: currentValue, level: "custom" as const, levelLabel: "当前值" }, ...options];
}
return options;
}, [workflowConfig, uploadBrandName, uploadSecondaryCategory]);
const draftBrandCategoryOptions = useMemo(() => {
const options = getAgentWorkflowCategoryOptionsByBrand({
config: workflowConfig,
brand: draftTemplate?.brandName,
});
const currentValue = String(draftTemplate?.secondaryCategory || "").trim();
if (
currentValue &&
!options.some((option) => option.value.toLowerCase() === currentValue.toLowerCase())
) {
return [{ value: currentValue, level: "custom" as const, levelLabel: "当前值" }, ...options];
}
return options;
}, [workflowConfig, draftTemplate?.brandName, draftTemplate?.secondaryCategory]);
const selectableDraftBrandOptions = useMemo(() => {
const currentBrand = normalizeManagedBrandName(draftTemplate?.brandName);
return Array.from(new Set([...managedBrandOptions, ...(currentBrand ? [currentBrand] : [])]));
}, [draftTemplate?.brandName, managedBrandOptions]);
const templateBrandFilterOptions = useMemo(() => {
const brands = new Set<string>();
let hasUnassigned = false;
templates.forEach((template) => {
const brand = normalizeManagedBrandName(template.brandName);
if (brand) {
brands.add(brand);
} else {
hasUnassigned = true;
}
});
return {
brands: Array.from(brands).sort((left, right) => left.localeCompare(right, "zh-CN")),
hasUnassigned,
};
}, [templates]);
const filteredTemplates = useMemo(() => {
if (templateBrandFilter === ALL_TEMPLATE_BRANDS_SELECT_VALUE) {
return templates;
}
if (templateBrandFilter === EMPTY_BRAND_SELECT_VALUE) {
return templates.filter((template) => !normalizeManagedBrandName(template.brandName));
}
return templates.filter(
(template) =>
normalizeManagedBrandName(template.brandName).toLowerCase() ===
templateBrandFilter.toLowerCase()
);
}, [templateBrandFilter, templates]);
const groupedTemplates = useMemo(() => {
const grouped = new Map<string, AgentPsdTemplate[]>();
filteredTemplates.forEach((template) => {
const label = normalizeManagedBrandName(template.brandName) || UNASSIGNED_BRAND_LABEL;
const current = grouped.get(label) || [];
current.push(template);
grouped.set(label, current);
});
return Array.from(grouped.entries())
.sort(([leftLabel], [rightLabel]) => {
if (leftLabel === UNASSIGNED_BRAND_LABEL) return -1;
if (rightLabel === UNASSIGNED_BRAND_LABEL) return 1;
return leftLabel.localeCompare(rightLabel, "zh-CN");
})
.map(([label, items]) => ({
label,
items,
}));
}, [filteredTemplates]);
const groupedFonts = useMemo(() => {
const grouped = new Map<string, AgentPsdFont[]>();
fonts.forEach((font) => {
const label = normalizeManagedBrandName(font.brandName) || UNASSIGNED_BRAND_LABEL;
const current = grouped.get(label) || [];
current.push(font);
grouped.set(label, current);
});
return Array.from(grouped.entries())
.sort(([leftLabel], [rightLabel]) => {
if (leftLabel === UNASSIGNED_BRAND_LABEL) return -1;
if (rightLabel === UNASSIGNED_BRAND_LABEL) return 1;
return leftLabel.localeCompare(rightLabel, "zh-CN");
})
.map(([label, items]) => ({
label,
items,
}));
}, [fonts]);
const fontOptions = useMemo(
() => [
...SYSTEM_FONT_OPTIONS,
...fonts.map((font) => ({
id: font.id,
label: font.name,
family: font.family,
})),
],
[fonts]
);
useEffect(() => {
if (templateListErrorMessage) {
return;
}
if (!templates.length) {
setSelectedTemplateId(null);
return;
}
if (selectedTemplateId && !templates.some((template) => template.id === selectedTemplateId)) {
setSelectedTemplateId(null);
}
}, [selectedTemplateId, templateListErrorMessage, 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 removeDraftLayer(layerId: string) {
setDraftTemplate((current) => {
if (!current) return current;
const nextLayers = current.layers.filter((layer) => layer.id !== layerId);
return {
...current,
layers: nextLayers,
layerCount: nextLayers.length,
};
});
}
function handleTemplateFileChange(event: ChangeEvent<HTMLInputElement>) {
const nextFile = event.target.files?.[0] || null;
if (nextFile && nextFile.size > MAX_PSD_TEMPLATE_FILE_SIZE) {
toast.error(
`PSD 模板不能超过 80MB,当前文件约 ${formatUploadFileSize(nextFile.size)}`
);
event.currentTarget.value = "";
setUploadFile(null);
return;
}
setUploadFile(nextFile);
if (nextFile && !uploadName.trim()) {
setUploadName(nextFile.name.replace(/\.[^.]+$/, ""));
}
}
function handleFontFileChange(event: ChangeEvent<HTMLInputElement>) {
const nextFiles = Array.from(event.target.files || []);
if (nextFiles.length === 0) {
return;
}
setFontUploadFiles((current) => mergeQueuedFontFiles(current, nextFiles));
if (fontUploadFiles.length === 0 && nextFiles.length === 1) {
const normalizedName = nextFiles[0]?.name.replace(/\.[^.]+$/, "") || "";
if (normalizedName && !fontUploadName.trim()) {
setFontUploadName(normalizedName);
}
if (normalizedName && !fontFamily.trim()) {
setFontFamily(normalizedName);
}
}
event.currentTarget.value = "";
}
function handleRemoveQueuedFontFile(file: File) {
setFontUploadFiles((current) =>
current.filter(
(item) => getQueuedFontFileKey(item) !== getQueuedFontFileKey(file)
)
);
}
function handleClearQueuedFontFiles() {
setFontUploadFiles([]);
if (fontFileInputRef.current) {
fontFileInputRef.current.value = "";
}
}
async function saveCategoryTagConfig(nextTags: string[]) {
const normalized = normalizeAgentPsdTemplateCategoryTagConfig({
tags: nextTags,
});
try {
setIsSavingCategoryTags(true);
const response = await fetch("/api/site-settings", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
agentPsdTemplateCategoryTagConfig: normalized,
}),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string }
| null;
if (!response.ok) {
throw new Error(payload?.error || "品类标签保存失败");
}
await mutateSiteSettings();
return normalized.tags;
} finally {
setIsSavingCategoryTags(false);
}
}
async function handleAddCategoryTag() {
const nextTag = normalizeAgentPsdTemplateCategoryTags([categoryTagDraft])[0];
if (!nextTag) {
toast.error("请先输入品类标签");
return;
}
if (nextTag === AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG) {
toast.error("“适合所有”是固定标签,无需重复添加");
return;
}
if (hasTemplateCategoryTag(categoryTagConfig.tags, nextTag)) {
toast.error("该品类标签已存在");
return;
}
try {
await saveCategoryTagConfig([...categoryTagConfig.tags, nextTag]);
setCategoryTagDraft("");
toast.success("品类标签已添加");
} catch (error) {
toast.error(error instanceof Error ? error.message : "品类标签保存失败");
}
}
async function handleRemoveCategoryTag(tag: string) {
const normalizedTag = normalizeAgentPsdTemplateCategoryTags([tag])[0];
if (!normalizedTag || normalizedTag === AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG) {
return;
}
try {
await saveCategoryTagConfig(
categoryTagConfig.tags.filter(
(item) => item.trim().toLowerCase() !== normalizedTag.toLowerCase()
)
);
toast.success("品类标签已移除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "品类标签保存失败");
}
}
async function handleCreateTemplate(options?: { parseAfterUpload?: boolean }) {
const parseAfterUpload = options?.parseAfterUpload === true;
if (!uploadFile) {
toast.error("请先选择 PSD / PSB 文件");
return;
}
if (uploadFile.size > MAX_PSD_TEMPLATE_FILE_SIZE) {
toast.error(
`PSD 模板不能超过 80MB,当前文件约 ${formatUploadFileSize(uploadFile.size)}`
);
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("mode", uploadMode);
formData.set("secondaryCategory", uploadSecondaryCategory.trim());
uploadCategoryTags.forEach((tag) => {
formData.append("categoryTags", tag);
});
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("上传连接被中断,请检查网络或压缩 PSD 后重试"));
xhr.onabort = () => reject(new Error("上传已取消,请重新选择 PSD 后再试"));
xhr.ontimeout = () => reject(new Error("上传超时,请检查网络或压缩 PSD 后重试"));
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("");
setUploadSecondaryCategory("");
setUploadCategoryTags([]);
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: (template) => {
setTemplateUploadStatus(
template.parseStatus === "completed"
? "解析完成"
: "快速解析完成,后台继续补全资产..."
);
},
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 (fontUploadFiles.length === 0) {
toast.error("请先选择字体文件");
return;
}
const formData = new FormData();
if (fontUploadFiles.length === 1) {
formData.set(
"name",
fontUploadName.trim() ||
fontUploadFiles[0]?.name.replace(/\.[^.]+$/, "") ||
"CustomFont"
);
}
formData.set("family", fontFamily.trim());
formData.set("brandName", fontUploadBrandName.trim());
for (const file of fontUploadFiles) {
formData.append("files", file);
}
try {
setIsUploadingFont(true);
const response = await fetch("/api/admin/psd-fonts", {
method: "POST",
body: formData,
});
const payload = (await response.json()) as AgentPsdFontMutationResponse;
const uploadedFonts = payload.fonts || (payload.font ? [payload.font] : []);
if (!response.ok || !payload.success || uploadedFonts.length === 0) {
throw new Error(payload.error || "字体上传失败");
}
toast.success(
uploadedFonts.length > 1
? `已批量上传 ${uploadedFonts.length} 个字体`
: "字体已上传到字体库"
);
setFontUploadName("");
setFontFamily("");
setFontUploadBrandName("");
setFontUploadFiles([]);
if (fontFileInputRef.current) {
fontFileInputRef.current.value = "";
}
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 handleQuickUpdateFontBrand(font: AgentPsdFont, nextBrandName: string) {
const normalizedBrandName = normalizeManagedBrandName(nextBrandName);
try {
setSavingFontBrandId(font.id);
const response = await fetch(`/api/admin/psd-fonts/${encodeURIComponent(font.id)}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: font.name,
family: font.family,
brandName: normalizedBrandName,
}),
});
const payload = (await response.json()) as AgentPsdFontMutationResponse;
if (!response.ok || !payload.success || !payload.font) {
throw new Error(payload.error || "字体品牌更新失败");
}
setFontBrandDrafts((current) => {
const next = { ...current };
delete next[font.id];
return next;
});
toast.success(normalizedBrandName ? "字体品牌已更新" : "字体已移到未分类");
await mutateFonts();
} catch (error) {
toast.error(error instanceof Error ? error.message : "字体品牌更新失败");
} finally {
setSavingFontBrandId(null);
}
}
async function handleQuickUpdateTemplateBrand(
template: AgentPsdTemplate,
nextBrandName: string
) {
const normalizedBrandName = normalizeManagedBrandName(nextBrandName);
if (!normalizedBrandName) {
toast.error("请选择品牌");
return;
}
try {
setSavingTemplateBrandId(template.id);
const response = await fetch(
`/api/admin/psd-templates/${encodeURIComponent(template.id)}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: template.name,
brandName: normalizedBrandName,
mode: template.mode,
secondaryCategory: template.secondaryCategory || "",
categoryTags: template.categoryTags || [],
description: template.description || "",
status: template.status,
layers: template.layers,
}),
}
);
const payload = (await response.json()) as AgentPsdTemplateMutationResponse;
if (!response.ok || !payload.success || !payload.template) {
throw new Error(payload.error || "模板品牌更新失败");
}
setTemplateBrandDrafts((current) => {
const next = { ...current };
delete next[template.id];
return next;
});
if (selectedTemplateId === template.id) {
setDraftTemplate(cloneTemplate(payload.template));
}
toast.success("模板品牌已更新");
await mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : "模板品牌更新失败");
} finally {
setSavingTemplateBrandId(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,
mode: draftTemplate.mode,
secondaryCategory: draftTemplate.secondaryCategory || "",
categoryTags: draftTemplate.categoryTags || [],
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?: (template: AgentPsdTemplate) => 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.parseStatus === "completed"
? `模板“${payload.template.name}”解析完成`
: `模板“${payload.template.name}”已完成快速解析,后台继续补全资产`
);
setDraftTemplate(cloneTemplate(payload.template));
await mutate();
setSelectedTemplateId(payload.template.id);
options?.onDone?.(payload.template);
} 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));
}
}
const templateEditorContent = (
<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:
hasTemplateParsedCanvas(draftTemplate)
? `${Math.max(1, draftTemplate.width)} / ${Math.max(1, draftTemplate.height)}`
: "1 / 1",
}}
>
{hasTemplateParsedCanvas(draftTemplate) ? (
<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"
? "解析完成,可开始配置槽位"
: hasTemplateParsedCanvas(draftTemplate)
? "快速解析已完成,后台正在补全图层缩略图、源图和模板底图"
: "上传完成后需要手动解析,适合批量上传多个 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">
{hasTemplateParsedCanvas(draftTemplate)
? `${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">
{hasTemplateParsedCanvas(draftTemplate)
? `${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>
{selectableDraftBrandOptions.length > 0 ? (
<Select
value={
normalizeManagedBrandName(draftTemplate.brandName) ||
EMPTY_BRAND_SELECT_VALUE
}
onValueChange={(value) =>
setDraftTemplate((current) =>
current
? {
...current,
brandName:
value === EMPTY_BRAND_SELECT_VALUE ? "未分配品牌" : value,
}
: current
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_BRAND_SELECT_VALUE}>未分类</SelectItem>
{selectableDraftBrandOptions.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 className="space-y-2">
<Label>套版模式</Label>
<Select
value={draftTemplate.mode}
onValueChange={(value) =>
setDraftTemplate((current) =>
current
? { ...current, mode: value === "sku" ? "sku" : "main" }
: current
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PSD_TEMPLATE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>二级分类</Label>
{draftBrandCategoryOptions.length > 0 ? (
<Select
value={
String(draftTemplate.secondaryCategory || "").trim() ||
EMPTY_CATEGORY_SELECT_VALUE
}
onValueChange={(value) =>
setDraftTemplate((current) =>
current
? {
...current,
secondaryCategory:
value === EMPTY_CATEGORY_SELECT_VALUE ? "" : value,
}
: current
)
}
>
<SelectTrigger>
<SelectValue placeholder="请选择模板分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_CATEGORY_SELECT_VALUE}>不设置分类</SelectItem>
{categoryLevelOptions.map((group) => {
const groupOptions = draftBrandCategoryOptions.filter(
(option) => option.level === group.key
);
if (groupOptions.length === 0) {
return null;
}
return (
<SelectGroup key={group.key}>
<SelectLabel>{group.label}</SelectLabel>
{groupOptions.map((option) => (
<SelectItem
key={`${group.key}-${option.value}`}
value={option.value}
>
{option.value}
</SelectItem>
))}
</SelectGroup>
);
})}
{draftBrandCategoryOptions.some(
(option) => option.level === "custom"
) ? (
<SelectGroup>
<SelectLabel>当前值</SelectLabel>
{draftBrandCategoryOptions
.filter((option) => option.level === "custom")
.map((option) => (
<SelectItem
key={`draft-custom-${option.value}`}
value={option.value}
>
{option.value}
</SelectItem>
))}
</SelectGroup>
) : null}
</SelectContent>
</Select>
) : (
<Input
id="draftTemplateSecondaryCategory"
value={draftTemplate.secondaryCategory || ""}
onChange={(event) =>
setDraftTemplate((current) =>
current
? { ...current, secondaryCategory: event.target.value }
: current
)
}
placeholder="例如:凳子类 / 升降桌类"
/>
)}
</div>
<div className="space-y-2 md:col-span-2">
<Label>适用品类标签</Label>
<div className="flex flex-wrap gap-2">
{draftTemplateCategoryTagOptions.map((tag) => {
const isSelected = hasTemplateCategoryTag(
draftTemplate.categoryTags,
tag
);
return (
<button
key={`draft-category-tag-${tag}`}
type="button"
onClick={() =>
setDraftTemplate((current) =>
current
? {
...current,
categoryTags: toggleTemplateCategoryTag(
current.categoryTags,
tag
),
}
: current
)
}
className={`rounded-full border px-3 py-1 text-sm transition ${
isSelected
? "border-orange-400 bg-orange-500/10 text-orange-600"
: "border-border bg-background text-muted-foreground hover:border-orange-300/50 hover:text-foreground"
}`}
>
{tag}
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
可多选。勾选“适合所有”后,模板会在所有品类下都显示;未设置标签的旧模板仍会保持可见,避免影响现有使用。
</p>
</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="secondary">模式:{getPsdTemplateModeLabel(draftTemplate.mode)}</Badge>
{draftTemplate.secondaryCategory ? (
<Badge variant="secondary">分类:{draftTemplate.secondaryCategory}</Badge>
) : null}
{(draftTemplate.categoryTags || []).map((tag) => (
<Badge key={`draft-template-tag-${tag}`} variant="secondary">
品类:{tag}
</Badge>
))}
<Badge variant="outline">文件:{draftTemplate.fileName}</Badge>
<Badge variant={getTemplateParseBadgeVariant(draftTemplate)}>
{getTemplateParseLabel(draftTemplate)}
</Badge>
<Badge variant="secondary">
当前图层数:
{hasTemplateParsedCanvas(draftTemplate)
? draftTemplate.layers.length
: "待解析"}
</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">
{hasTemplateParsedCanvas(draftTemplate)
? "快速解析已经返回图层结构,后台正在补全缩略图、源图和底图,完成后这里会自动开放完整配置。"
: "当前模板还没有生成图层结构、缩略图和字体识别结果,完成解析后这里会自动显示可编辑配置项。"}
</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">
{draftTemplate.layers.length === 0
? "当前模板已没有保留图层,可重新解析 PSD,或直接保存当前删除结果。"
: "当前筛选条件下没有图层,请换个关键词试试。"}
</div>
) : (
filteredLayers.map((layer, index) => {
const clipMaskLayerOptions =
layer.slotType === "image"
? draftTemplate.layers.filter(
(candidate) =>
candidate.id !== layer.id &&
(candidate.previewUrl || candidate.sourceUrl) &&
!(
candidate.slotType === "text" &&
!candidate.previewUrl &&
!candidate.sourceUrl
)
)
: [];
return (
<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.isSuperLocked ? (
<Badge className="gap-1 border-amber-300/60 bg-amber-100 text-amber-800 hover:bg-amber-100">
<Lock className="h-3 w-3" />
超级锁
</Badge>
) : null}
{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 flex-wrap items-center justify-end gap-2">
<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>
<Button
type="button"
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => {
if (
typeof window !== "undefined" &&
!window.confirm(
`确认删除图层“${layer.layerName}”吗?删除后需要保存模板才会正式生效。`
)
) {
return;
}
removeDraftLayer(layer.id);
}}
>
<Trash2 className="mr-1.5 h-4 w-4" />
删除图层
</Button>
</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",
styleSource: "auto",
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="flex items-center gap-1.5 text-sm font-medium">
<Lock className="h-4 w-4 text-amber-600" />
超级锁
</div>
<div className="text-xs text-muted-foreground">
开启后用户前台无法选中、编辑、拖动或临时删除该图层,鼠标点击会自动穿透到下方图层。
</div>
</div>
<Switch
checked={layer.isSuperLocked === true}
onCheckedChange={(checked) =>
updateDraftLayer(layer.id, (current) => {
if (current.slotType === "image") {
return {
...current,
isSuperLocked: checked,
isRequired: checked ? false : current.isRequired,
isDefaultResultSource: checked
? false
: current.isDefaultResultSource,
allowUserUploadOverride: checked
? false
: current.allowUserUploadOverride,
forceClipMask: checked
? false
: current.forceClipMask,
clipMaskLayerId: checked
? undefined
: current.clipMaskLayerId,
};
}
return {
...current,
isSuperLocked: checked,
isRequired: checked ? false : current.isRequired,
};
})
}
/>
</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}
disabled={layer.isSuperLocked === true}
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,
forceClipMask: checked
? current.forceClipMask
: false,
clipMaskLayerId: checked
? current.clipMaskLayerId
: undefined,
}
: {
...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 || layer.isSuperLocked === true}
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 || layer.isSuperLocked === true}
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 || layer.isSuperLocked === true}
onCheckedChange={(checked) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "image"
? {
...current,
allowUserUploadOverride: current.isEditable
? checked
: false,
}
: current
)
}
/>
</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.forceClipMask === true}
disabled={!layer.isEditable || layer.isSuperLocked === true}
onCheckedChange={(checked) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "image"
? {
...current,
forceClipMask: current.isEditable
? checked
: false,
clipMaskLayerId:
current.isEditable && checked
? current.clipMaskLayerId
: undefined,
}
: current
)
}
/>
</div>
{layer.forceClipMask ? (
<div className="space-y-2 rounded-xl border p-3 md:col-span-2 xl:col-span-3">
<div className="text-sm font-medium">剪切蒙版目标图层</div>
<Select
value={
layer.clipMaskLayerId ||
SELF_CLIP_MASK_SELECT_VALUE
}
disabled={
!layer.isEditable ||
layer.isSuperLocked === true
}
onValueChange={(value) =>
updateDraftLayer(layer.id, (current) =>
current.slotType === "image"
? {
...current,
clipMaskLayerId:
value === SELF_CLIP_MASK_SELECT_VALUE
? undefined
: value,
}
: current
)
}
>
<SelectTrigger>
<SelectValue placeholder="选择用于裁切的目标图层" />
</SelectTrigger>
<SelectContent>
<SelectItem value={SELF_CLIP_MASK_SELECT_VALUE}>
使用当前替换图层自身透明区域
</SelectItem>
{clipMaskLayerOptions.map((candidate) => (
<SelectItem
key={`${layer.id}-clip-mask-${candidate.id}`}
value={candidate.id}
>
{candidate.layerName} · {candidate.width} x {candidate.height}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
如果 PSD 里目标形状是超级锁图层,请在这里选择那个目标图层;前台只能移动替换图,目标图仍保持锁定不可选中。
</div>
</div>
) : null}
<div className="space-y-2 rounded-xl border p-3">
<div className="text-sm font-medium">填充模式</div>
<Select
value={layer.replaceMode}
disabled={!layer.isEditable || layer.isSuperLocked === true}
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,
styleSource: "custom",
};
}
const uploadedFont = fonts.find((font) => font.id === value);
return uploadedFont
? {
...current,
fontId: uploadedFont.id,
fontFamily: uploadedFont.family,
fontLabel: uploadedFont.name,
styleSource: "custom",
}
: 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),
styleSource: "custom",
}
: 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
),
styleSource: "custom",
}
: 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)
),
styleSource: "custom",
}
: 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,
styleSource: "custom",
}
: 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",
styleSource: "custom",
}
: 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),
styleSource: "custom",
}
: 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),
styleSource: "custom",
}
: 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),
styleSource: "custom",
}
: 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,
styleSource: "custom",
}
: 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>
);
return (
<div className="settings-workspace min-h-screen bg-muted/10">
<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-4 py-3 sm:px-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-foreground">
<Sparkles className="h-4 w-4 text-background" />
</div>
<div>
<h1 className="text-base font-semibold">PSD 模板配置</h1>
<p className="text-xs 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"
>
{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-4 py-2.5 sm:px-5">
<div className="flex flex-wrap items-center gap-3">
<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">
{hasTemplateParsedCanvas(draftTemplate)
? `${draftTemplate.width} x ${draftTemplate.height}`
: "等待解析后显示尺寸"}
</span>
</>
) : null}
</div>
</div>
</div>
<div className="container mx-auto px-4 py-4 sm:px-5">
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">品类标签</CardTitle>
<CardDescription>
管理 PSD 模板可绑定的品类标签。前端会根据用户当前选择的产品品类筛选模板。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="psdCategoryTagDraft">新增标签</Label>
<div className="flex gap-2">
<Input
id="psdCategoryTagDraft"
value={categoryTagDraft}
onChange={(event) => setCategoryTagDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleAddCategoryTag();
}
}}
placeholder="例如:椅子类 / 沙发类 / 餐边柜类"
disabled={isSavingCategoryTags}
/>
<Button
type="button"
variant="outline"
onClick={() => {
void handleAddCategoryTag();
}}
disabled={isSavingCategoryTags || !categoryTagDraft.trim()}
>
添加标签
</Button>
</div>
</div>
<div className="space-y-2">
<Label>当前标签</Label>
<div className="flex flex-wrap gap-2">
<Badge variant="default">{AGENT_PSD_TEMPLATE_ALL_CATEGORY_TAG}</Badge>
{categoryTagConfig.tags.map((tag) => (
<div
key={tag}
className="inline-flex items-center gap-2 rounded-full border bg-background px-3 py-1 text-sm"
>
<span>{tag}</span>
<button
type="button"
className="text-muted-foreground transition hover:text-foreground"
onClick={() => {
void handleRemoveCategoryTag(tag);
}}
disabled={isSavingCategoryTags}
>
删除
</button>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
“适合所有”是固定标签,会让模板在所有品类下都显示;自定义标签删除后,不会再出现在新模板选择项中。
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">上传新模板</CardTitle>
<CardDescription>
支持 PSD / PSB,建议图层命名清晰,便于后台配置图片位和文字位。复杂 PSB
如提示不兼容,请先在 Photoshop 中另存为 PSD 后上传。
</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>
{managedBrandOptions.length > 0 ? (
<Select value={uploadBrandName} onValueChange={setUploadBrandName}>
<SelectTrigger>
<SelectValue placeholder="请选择品牌" />
</SelectTrigger>
<SelectContent>
{managedBrandOptions.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>套版模式</Label>
<Select
value={uploadMode}
onValueChange={(value) => setUploadMode(value === "sku" ? "sku" : "main")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PSD_TEMPLATE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{PSD_TEMPLATE_MODE_OPTIONS.find((option) => option.value === uploadMode)?.description}
</p>
</div>
<div className="space-y-2">
<Label>二级分类</Label>
{uploadBrandCategoryOptions.length > 0 ? (
<Select
value={uploadSecondaryCategory.trim() || EMPTY_CATEGORY_SELECT_VALUE}
onValueChange={(value) =>
setUploadSecondaryCategory(
value === EMPTY_CATEGORY_SELECT_VALUE ? "" : value
)
}
>
<SelectTrigger>
<SelectValue placeholder="请选择模板分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_CATEGORY_SELECT_VALUE}>不设置分类</SelectItem>
{categoryLevelOptions.map((group) => {
const groupOptions = uploadBrandCategoryOptions.filter(
(option) => option.level === group.key
);
if (groupOptions.length === 0) {
return null;
}
return (
<SelectGroup key={group.key}>
<SelectLabel>{group.label}</SelectLabel>
{groupOptions.map((option) => (
<SelectItem key={`${group.key}-${option.value}`} value={option.value}>
{option.value}
</SelectItem>
))}
</SelectGroup>
);
})}
{uploadBrandCategoryOptions.some((option) => option.level === "custom") ? (
<SelectGroup>
<SelectLabel>当前值</SelectLabel>
{uploadBrandCategoryOptions
.filter((option) => option.level === "custom")
.map((option) => (
<SelectItem key={`custom-${option.value}`} value={option.value}>
{option.value}
</SelectItem>
))}
</SelectGroup>
) : null}
</SelectContent>
</Select>
) : (
<Input
id="psdTemplateSecondaryCategory"
value={uploadSecondaryCategory}
onChange={(event) => setUploadSecondaryCategory(event.target.value)}
placeholder="例如:凳子类 / 升降桌类"
/>
)}
<p className="text-xs text-muted-foreground">
会优先读取当前品牌下已配置的分类层级作为选项;开启前台模板二级分类后,会按这里的分类分组展示模板。
</p>
</div>
<div className="space-y-2">
<Label>适用品类标签</Label>
<div className="flex flex-wrap gap-2">
{availableCategoryTagOptions.map((tag) => {
const isSelected = hasTemplateCategoryTag(uploadCategoryTags, tag);
return (
<button
key={tag}
type="button"
onClick={() =>
setUploadCategoryTags((current) =>
toggleTemplateCategoryTag(current, tag)
)
}
className={`rounded-full border px-3 py-1 text-sm transition ${
isSelected
? "border-orange-400 bg-orange-500/10 text-orange-600"
: "border-border bg-background text-muted-foreground hover:border-orange-300/50 hover:text-foreground"
}`}
>
{tag}
</button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
可多选。前端会根据用户当前选择的产品品类筛选模板;勾选“适合所有”后,这个模板会在所有品类下都显示。
</p>
</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)}
disabled={fontUploadFiles.length > 1}
placeholder="例如:思源黑体-中黑"
/>
{fontUploadFiles.length > 1 ? (
<p className="text-xs text-muted-foreground">
批量上传时会自动使用每个文件名作为字体名称,这一项不再单独生效。
</p>
) : null}
</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>所属品牌</Label>
{managedBrandOptions.length > 0 ? (
<Select
value={fontUploadBrandName.trim() || EMPTY_BRAND_SELECT_VALUE}
onValueChange={(value) =>
setFontUploadBrandName(value === EMPTY_BRAND_SELECT_VALUE ? "" : value)
}
>
<SelectTrigger>
<SelectValue placeholder="选择品牌分类(可选)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_BRAND_SELECT_VALUE}>暂不分类</SelectItem>
{managedBrandOptions.map((brand) => (
<SelectItem key={brand} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={fontUploadBrandName}
onChange={(event) => setFontUploadBrandName(event.target.value)}
placeholder="输入品牌分类(可选)"
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="psdFontFile">字体文件</Label>
<Input
ref={fontFileInputRef}
id="psdFontFile"
type="file"
accept=".ttf,.otf,.woff,.woff2"
multiple
onChange={handleFontFileChange}
onClick={(event) => {
event.currentTarget.value = "";
}}
/>
<p className="text-xs text-muted-foreground">
{fontUploadFiles.length > 0
? fontUploadFiles.length === 1
? `已选择:${fontUploadFiles[0]?.name || ""}`
: `已选择 ${fontUploadFiles.length} 个字体文件:${fontUploadFiles
.map((file) => file.name)
.join("、")}`
: "支持一次多选,也支持多次继续添加字体文件后再批量上传"}
</p>
<p className="text-xs text-muted-foreground">
{fontUploadFiles.length > 1
? "批量上传时会使用每个文件名作为字体名称;如果填写了 CSS 字体族,会批量应用到这一批字体。"
: "单个上传时可自定义字体名称和 CSS 字体族。"}
</p>
{fontUploadFiles.length > 0 ? (
<div className="space-y-2 rounded-xl border border-dashed p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-medium text-muted-foreground">
待上传文件队列
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleClearQueuedFontFiles}
>
清空已选
</Button>
</div>
<div className="space-y-2">
{fontUploadFiles.map((file) => (
<div
key={getQueuedFontFileKey(file)}
className="flex items-center justify-between gap-3 rounded-lg border bg-background/60 px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm">{file.name}</div>
<div className="text-[11px] text-muted-foreground">
{(file.size / 1024 / 1024).toFixed(2)} MB
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => handleRemoveQueuedFontFile(file)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
) : null}
</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" />
)}
{fontUploadFiles.length > 1 ? "批量上传到字体库" : "上传到字体库"}
</Button>
<Separator />
<Collapsible open={isFontListExpanded} onOpenChange={setIsFontListExpanded}>
<div className="rounded-2xl border border-dashed p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">已上传字体列表</div>
<div className="mt-1 text-xs text-muted-foreground">
默认收起,按品牌分类查看字体归属。
</div>
</div>
<CollapsibleTrigger asChild>
<Button type="button" variant="outline" size="sm">
{isFontListExpanded ? (
<>
收起列表
<ChevronUp className="ml-2 h-4 w-4" />
</>
) : (
<>
展开列表({fonts.length})
<ChevronDown className="ml-2 h-4 w-4" />
</>
)}
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="mt-4 space-y-4">
{fonts.length === 0 ? (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
还没有上传字体,文字槽位目前只能使用系统基础字体。
</div>
) : (
groupedFonts.map((group) => (
<div key={group.label} className="space-y-3 rounded-2xl border bg-background/60 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">{group.label}</div>
<div className="text-xs text-muted-foreground">{group.items.length} 个字体</div>
</div>
<div className="space-y-3">
{group.items.map((font) => {
const fontBrandDraft =
fontBrandDrafts[font.id] ?? normalizeManagedBrandName(font.brandName);
const isUnassignedFont = !normalizeManagedBrandName(font.brandName);
return (
<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 className="mt-2 text-[11px] text-muted-foreground">
品牌:{normalizeManagedBrandName(font.brandName) || UNASSIGNED_BRAND_LABEL}
</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>
{isUnassignedFont || fontBrandDrafts[font.id] ? (
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
<div className="min-w-0 flex-1">
{managedBrandOptions.length > 0 ? (
<Select
value={fontBrandDraft || EMPTY_BRAND_SELECT_VALUE}
onValueChange={(value) =>
setFontBrandDrafts((current) => ({
...current,
[font.id]:
value === EMPTY_BRAND_SELECT_VALUE ? "" : value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="选择品牌分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_BRAND_SELECT_VALUE}>暂不分类</SelectItem>
{managedBrandOptions.map((brand) => (
<SelectItem key={`${font.id}-${brand}`} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={fontBrandDraft}
onChange={(event) =>
setFontBrandDrafts((current) => ({
...current,
[font.id]: event.target.value,
}))
}
placeholder="输入品牌分类"
/>
)}
</div>
<Button
type="button"
variant="outline"
onClick={() =>
void handleQuickUpdateFontBrand(font, fontBrandDraft)
}
disabled={savingFontBrandId === font.id}
>
{savingFontBrandId === font.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
保存分类
</Button>
</div>
) : null}
</div>
);
})}
</div>
</div>
))
)}
</CollapsibleContent>
</div>
</Collapsible>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">模板列表</CardTitle>
<CardDescription>按品牌筛选模板,选择一个模板继续配置槽位。</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-col gap-3 rounded-2xl border bg-muted/20 p-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">品牌筛选</div>
<div className="text-xs text-muted-foreground">
当前显示 {filteredTemplates.length} / {templates.length} 个模板
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Select value={templateBrandFilter} onValueChange={setTemplateBrandFilter}>
<SelectTrigger className="w-full sm:w-[260px]">
<SelectValue placeholder="选择品牌" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_TEMPLATE_BRANDS_SELECT_VALUE}>全部品牌</SelectItem>
{templateBrandFilterOptions.hasUnassigned ? (
<SelectItem value={EMPTY_BRAND_SELECT_VALUE}>未分类</SelectItem>
) : null}
{templateBrandFilterOptions.brands.map((brand) => (
<SelectItem key={`template-brand-filter-${brand}`} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
{templateBrandFilter !== ALL_TEMPLATE_BRANDS_SELECT_VALUE ? (
<Button
type="button"
variant="outline"
onClick={() => setTemplateBrandFilter(ALL_TEMPLATE_BRANDS_SELECT_VALUE)}
>
清除筛选
</Button>
) : null}
</div>
</div>
{templateListErrorMessage && templates.length > 0 ? (
<div className="flex items-start gap-3 rounded-xl border border-amber-300/30 bg-amber-500/5 p-4 text-sm text-amber-100">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<div>{`${templateListErrorMessage},当前先显示上次成功加载的模板列表。`}</div>
<Button
className="mt-3"
onClick={() => void mutate()}
size="sm"
type="button"
variant="outline"
>
重新加载
</Button>
</div>
</div>
) : null}
{isLoading && templates.length === 0 ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={`psd-template-list-loading-${index}`}
className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground"
>
正在加载 PSD 模板列表...
</div>
))}
</div>
) : templateListErrorMessage ? (
<div className="rounded-xl border border-dashed border-amber-300/30 bg-amber-500/5 p-4 text-sm text-amber-100">
<div>{templateListErrorMessage}</div>
<Button
className="mt-3"
onClick={() => void mutate()}
size="sm"
type="button"
variant="outline"
>
重新加载
</Button>
</div>
) : templates.length === 0 ? (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
还没有 PSD 模板,先上传一个模板开始配置。
</div>
) : filteredTemplates.length === 0 ? (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
当前品牌下没有 PSD 模板,可以切换品牌或清除筛选。
</div>
) : (
groupedTemplates.map((group) => (
<div key={group.label} className="space-y-3 rounded-2xl border bg-background/50 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">{group.label}</div>
<div className="text-xs text-muted-foreground">{group.items.length} 个模板</div>
</div>
<div className="space-y-3">
{group.items.map((template) => {
const isActive = template.id === selectedTemplateId;
const isParsing =
template.parseStatus === "processing" ||
parsingTemplateIds.includes(template.id);
const isUnassignedTemplate = !normalizeManagedBrandName(template.brandName);
const templateBrandDraft =
templateBrandDrafts[template.id] ?? normalizeManagedBrandName(template.brandName);
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 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="flex min-w-0 gap-4">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-2xl border bg-muted/20">
<img
src={template.previewUrl}
alt={template.name}
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{template.name}</div>
<div className="mt-1 text-xs text-muted-foreground">
{hasTemplateParsedCanvas(template)
? `${template.width} x ${template.height} · ${template.layerCount} 图层`
: "待解析完成后生成尺寸和图层"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
品牌:{normalizeManagedBrandName(template.brandName) || UNASSIGNED_BRAND_LABEL}
</div>
<div className="mt-1 text-xs text-muted-foreground">
模式:{getPsdTemplateModeLabel(template.mode)}
</div>
{template.secondaryCategory ? (
<div className="mt-1 text-xs text-muted-foreground">
二级分类:{template.secondaryCategory}
</div>
) : null}
{template.categoryTags?.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{template.categoryTags.map((tag) => (
<Badge key={`${template.id}-${tag}`} variant="secondary">
{tag}
</Badge>
))}
</div>
) : null}
{template.description ? (
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground">
{template.description}
</div>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
<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>
{isUnassignedTemplate ? (
<div
className="mt-3 flex flex-col gap-2 sm:flex-row"
onClick={(event) => event.stopPropagation()}
>
<div className="min-w-0 flex-1">
{managedBrandOptions.length > 0 ? (
<Select
value={templateBrandDraft || EMPTY_BRAND_SELECT_VALUE}
onValueChange={(value) =>
setTemplateBrandDrafts((current) => ({
...current,
[template.id]:
value === EMPTY_BRAND_SELECT_VALUE ? "" : value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="给未分类模板设置品牌" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_BRAND_SELECT_VALUE}>请选择品牌</SelectItem>
{managedBrandOptions.map((brand) => (
<SelectItem key={`${template.id}-${brand}`} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={templateBrandDraft}
onChange={(event) =>
setTemplateBrandDrafts((current) => ({
...current,
[template.id]: event.target.value,
}))
}
placeholder="输入品牌名称"
/>
)}
</div>
<Button
type="button"
variant="outline"
onClick={() =>
void handleQuickUpdateTemplateBrand(template, templateBrandDraft)
}
disabled={savingTemplateBrandId === template.id}
>
{savingTemplateBrandId === template.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
保存品牌分类
</Button>
</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}
{isActive && draftTemplate?.id === template.id ? (
<div
className="mt-4 rounded-2xl border border-orange-200/70 bg-background p-4 shadow-sm"
onClick={(event) => event.stopPropagation()}
>
{templateEditorContent}
</div>
) : null}
</div>
);
})}
</div>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}