import React, { useCallback, useState } from 'react'; import { Button } from '../../ui/button'; import { ArrowLeft, Save, Download } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '../../ui/dialog'; import type { ElementLibraryCategory, LabelTemplate, LabelElement } from '../../../types/labelTemplate'; import { allocateElementName, canonicalElementType, composeElementTypeForPersist, composeLibraryCategoryForPersist, createDefaultTemplate, createDefaultElement, labelElementsToApiPayload, resolvedLibraryCategoryForPersist, resolvedTypeAddForPersist, resolvedValueSourceTypeForSave, stripLabelConfigPrefixes, valueSourceTypeForLibraryCategory, } from '../../../types/labelTemplate'; import { ElementsPanel } from './ElementsPanel'; import { LabelCanvas, LabelPreviewOnly } from './LabelCanvas'; import { PropertiesPanel } from './PropertiesPanel'; import { createLabelTemplate, updateLabelTemplate } from '../../../services/labelTemplateService'; import { toast } from 'sonner'; const MIN_SCALE = 0.5; const MAX_SCALE = 2; const SCALE_STEP = 0.25; const DEFAULT_SCALE = 1.0; interface LabelTemplateEditorProps { /** null = 新建,string = 编辑该 id */ templateId: string | null; initialTemplate: LabelTemplate | null; onClose: () => void; onSaved: () => void; } export function LabelTemplateEditor({ templateId, initialTemplate, onClose, onSaved, }: LabelTemplateEditorProps) { const [template, setTemplate] = useState(() => { if (initialTemplate) return { ...initialTemplate }; return createDefaultTemplate(templateId ?? undefined); }); const [selectedId, setSelectedId] = useState(null); const [scale, setScale] = useState(DEFAULT_SCALE); const [previewOpen, setPreviewOpen] = useState(false); const selectedElement = template.elements.find((el) => el.id === selectedId) ?? null; const addElement = useCallback(( type: Parameters[0], configOverride: Partial> | undefined, libraryCategory: ElementLibraryCategory, paletteItemLabel: string, ) => { let addedId = ""; setTemplate((prev) => { const unitToPx = (value: number, unit: "cm" | "inch"): number => unit === "cm" ? value * 37.8 : value * 96; const canvasWidthPx = unitToPx(prev.width, prev.unit); const canvasHeightPx = unitToPx(prev.height, prev.unit); let el = createDefaultElement(type, 0, 0); const GRID_SIZE = 8; const snapToGrid = (value: number): number => Math.round(value / GRID_SIZE) * GRID_SIZE; let centerX = (canvasWidthPx - el.width) / 2; let centerY = (canvasHeightPx - el.height) / 2; const checkOverlap = (x: number, y: number, width: number, height: number): boolean => prev.elements.some((o) => { const elRight = o.x + o.width; const elBottom = o.y + o.height; const newRight = x + width; const newBottom = y + height; return !(x >= elRight || newRight <= o.x || y >= elBottom || newBottom <= o.y); }); if (checkOverlap(centerX, centerY, el.width, el.height)) { const offset = GRID_SIZE * 2; let found = false; for (let tryY = centerY; tryY < canvasHeightPx - el.height && !found; tryY += offset) { for (let tryX = centerX; tryX < canvasWidthPx - el.width && !found; tryX += offset) { if (!checkOverlap(tryX, tryY, el.width, el.height)) { centerX = tryX; centerY = tryY; found = true; } } } if (!found) { for (let tryY = centerY; tryY >= 0 && !found; tryY -= offset) { for (let tryX = centerX; tryX >= 0 && !found; tryX -= offset) { if (!checkOverlap(tryX, tryY, el.width, el.height)) { centerX = tryX; centerY = tryY; found = true; } } } } } el = { ...el, x: Math.max(0, snapToGrid(centerX)), y: Math.max(0, snapToGrid(centerY)), }; if (configOverride && Object.keys(configOverride).length > 0) { el.config = { ...el.config, ...configOverride }; } const elementName = allocateElementName(paletteItemLabel, prev.elements); const vst = valueSourceTypeForLibraryCategory(libraryCategory); el = { ...el, type: el.type, typeAdd: composeElementTypeForPersist(libraryCategory, paletteItemLabel), libraryCategory: composeLibraryCategoryForPersist(libraryCategory, paletteItemLabel), valueSourceType: vst, elementName, }; addedId = el.id; return { ...prev, elements: [...prev.elements, el] }; }); setSelectedId(addedId); }, [template.width, template.height, template.unit]); const updateElement = useCallback((id: string, patch: Partial) => { setTemplate((prev) => ({ ...prev, elements: prev.elements.map((el) => el.id === id ? { ...el, ...patch } : el ), })); }, []); const deleteElement = useCallback((id: string) => { setTemplate((prev) => ({ ...prev, elements: prev.elements.filter((el) => el.id !== id), })); setSelectedId(null); }, []); const handleTemplateChange = useCallback((patch: Partial) => { setTemplate((prev) => ({ ...prev, ...patch })); }, []); const handleSave = useCallback(async () => { try { const code = (template.id ?? "").trim(); if (!code) { toast.error("Template code is required.", { description: "Please enter a template code (e.g. TPL_TEST_001).", }); return; } if (template.appliedLocation === "SPECIFIED" && !(template.appliedLocationIds?.length ?? 0)) { toast.error("Locations required.", { description: "When using specified locations, select at least one location.", }); return; } const emptyName = template.elements.find( (el) => !(el.elementName ?? "").trim(), ); if (emptyName) { toast.error("Component name required.", { description: "Each element must have a non-empty element name.", }); return; } const optionsWithoutDictionary = template.elements.find((el) => { if (el.type !== "TEXT_STATIC") return false; const cfg = el.config as Record; if (String(cfg?.inputType ?? "").toLowerCase() !== "options") return false; const mid = String(cfg?.multipleOptionId ?? cfg?.MultipleOptionId ?? "").trim(); return !mid; }); if (optionsWithoutDictionary) { toast.error("Option dictionary required.", { description: "Each Multiple Options element must have an Option dictionary selected in the properties panel.", }); return; } // 转换 LabelTemplate 到 API 需要的格式(对齐 LabelTemplateCreateInputVo) const apiInput = { id: code, name: template.name, labelType: template.labelType, unit: template.unit, width: template.width, height: template.height, appliedLocation: template.appliedLocation, showRuler: template.showRuler, showGrid: template.showGrid ?? true, state: true, elements: labelElementsToApiPayload(template.elements), appliedLocationIds: template.appliedLocation === "ALL" ? [] : (template.appliedLocationIds ?? []), }; if (templateId) { // 编辑模式:使用 TemplateCode 作为 id await updateLabelTemplate(code, apiInput); toast.success("Template updated.", { description: "The template has been updated successfully.", }); } else { // 新建模式 await createLabelTemplate(apiInput); toast.success("Template created.", { description: "The template has been created successfully.", }); } onSaved(); onClose(); } catch (e: any) { toast.error("Failed to save template.", { description: e?.message ? String(e.message) : "Please try again.", }); } }, [template, templateId, onSaved, onClose]); const handleExport = useCallback(() => { const payload: LabelTemplate = { ...template, elements: template.elements.map((el) => ({ ...el, type: canonicalElementType(el.type), typeAdd: resolvedTypeAddForPersist(el), elementName: (el.elementName ?? "").trim(), valueSourceType: resolvedValueSourceTypeForSave(el), libraryCategory: resolvedLibraryCategoryForPersist(el), config: stripLabelConfigPrefixes((el.config ?? {}) as Record), })), }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json', }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `label-template-${template.id}.json`; a.click(); URL.revokeObjectURL(url); }, [template]); return (
{/* Toolbar */}
{template.name}
{/* Three columns */}
setScale((s) => Math.min(MAX_SCALE, s + SCALE_STEP))} onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, s - SCALE_STEP))} onPreview={() => setPreviewOpen(true)} /> Label preview
); }