"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 { const response = await fetch(url, { cache: "no-store" }); const payload = (await response .json() .catch(() => null)) as Partial | 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 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( "/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( "/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(null); const [draftTemplate, setDraftTemplate] = useState(null); const [layerKeyword, setLayerKeyword] = useState(""); const [uploadName, setUploadName] = useState(""); const [uploadBrandName, setUploadBrandName] = useState(""); const [uploadMode, setUploadMode] = useState("main"); const [uploadSecondaryCategory, setUploadSecondaryCategory] = useState(""); const [uploadCategoryTags, setUploadCategoryTags] = useState([]); const [uploadDescription, setUploadDescription] = useState(""); const [uploadFile, setUploadFile] = useState(null); const [fontUploadName, setFontUploadName] = useState(""); const [fontFamily, setFontFamily] = useState(""); const [fontUploadBrandName, setFontUploadBrandName] = useState(""); const [fontUploadFiles, setFontUploadFiles] = useState([]); const fontFileInputRef = useRef(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(null); const [isUploadingFont, setIsUploadingFont] = useState(false); const [deletingFontId, setDeletingFontId] = useState(null); const [savingTemplateBrandId, setSavingTemplateBrandId] = useState(null); const [savingFontBrandId, setSavingFontBrandId] = useState(null); const [categoryTagDraft, setCategoryTagDraft] = useState(""); const [isSavingCategoryTags, setIsSavingCategoryTags] = useState(false); const [isFontListExpanded, setIsFontListExpanded] = useState(false); const [templateBrandDrafts, setTemplateBrandDrafts] = useState>({}); const [fontBrandDrafts, setFontBrandDrafts] = useState>({}); const [parsingTemplateIds, setParsingTemplateIds] = useState([]); 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 groupedTemplates = useMemo(() => { const grouped = new Map(); templates.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, })); }, [templates]); const groupedFonts = useMemo(() => { const grouped = new Map(); 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) { 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) { 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((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 = (
{draftTemplate ? ( <> 模板基础信息 启用前请至少配置一个可编辑槽位,文字槽位建议明确字体和字号。
{hasTemplateParsedCanvas(draftTemplate) ? ( {draftTemplate.name} ) : (
{getTemplateParseLabel(draftTemplate)}
先完成 PSD 解析,再配置图层和可编辑槽位
)}
{getTemplateParseLabel(draftTemplate)}
{draftTemplate.parseStage || (draftTemplate.parseStatus === "completed" ? "解析完成,可开始配置槽位" : hasTemplateParsedCanvas(draftTemplate) ? "快速解析已完成,后台正在补全图层缩略图、源图和模板底图" : "上传完成后需要手动解析,适合批量上传多个 PSD 再逐个处理")}
{draftTemplate.parseStatus === "processing" ? (
{draftTemplate.parseStage || "正在解析 PSD..."} {getTemplateParseProgress(draftTemplate)}%
) : null} {draftTemplate.parseError ? (
{draftTemplate.parseError}
) : null}
画布尺寸
{hasTemplateParsedCanvas(draftTemplate) ? `${draftTemplate.width} x ${draftTemplate.height}` : "待解析"}
当前槽位
{hasTemplateParsedCanvas(draftTemplate) ? `${countEditableLayers(draftTemplate)} / ${draftTemplate.layers.length}` : "待解析"}
setDraftTemplate((current) => current ? { ...current, name: event.target.value } : current ) } />
{selectableDraftBrandOptions.length > 0 ? ( ) : ( setDraftTemplate((current) => current ? { ...current, brandName: event.target.value } : current ) } placeholder="请输入品牌名称" /> )}
{draftBrandCategoryOptions.length > 0 ? ( ) : ( setDraftTemplate((current) => current ? { ...current, secondaryCategory: event.target.value } : current ) } placeholder="例如:凳子类 / 升降桌类" /> )}
{draftTemplateCategoryTagOptions.map((tag) => { const isSelected = hasTemplateCategoryTag( draftTemplate.categoryTags, tag ); return ( ); })}

可多选。勾选“适合所有”后,模板会在所有品类下都显示;未设置标签的旧模板仍会保持可见,避免影响现有使用。