3389 lines
162 KiB
TypeScript
3389 lines
162 KiB
TypeScript
"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>
|
||
);
|
||
}
|