import React, { useCallback, useEffect, 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 { LabelTemplate, LabelElement } from '../../../types/labelTemplate'; import { createDefaultTemplate, createDefaultElement, defaultValueSourceTypeForElement, } from '../../../types/labelTemplate'; import type { LocationDto } from '../../../types/location'; import { getLocations } from '../../../services/locationService'; 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 [locations, setLocations] = useState([]); useEffect(() => { let cancelled = false; (async () => { try { const res = await getLocations({ skipCount: 0, maxResultCount: 500 }); if (!cancelled) setLocations(res.items ?? []); } catch { if (!cancelled) setLocations([]); } })(); return () => { cancelled = true; }; }, []); const selectedElement = template.elements.find((el) => el.id === selectedId) ?? null; const addElement = useCallback(( type: Parameters[0], configOverride?: Partial> ) => { // 计算画布中心位置(像素) const unitToPx = (value: number, unit: 'cm' | 'inch'): number => { return unit === 'cm' ? value * 37.8 : value * 96; }; const canvasWidthPx = unitToPx(template.width, template.unit); const canvasHeightPx = unitToPx(template.height, template.unit); // 创建默认元素以获取其尺寸 const tempEl = createDefaultElement(type, 0, 0); // 对齐到网格 const GRID_SIZE = 8; const snapToGrid = (value: number): number => { return Math.round(value / GRID_SIZE) * GRID_SIZE; }; // 计算元素中心对齐到画布中心的位置 let centerX = (canvasWidthPx - tempEl.width) / 2; let centerY = (canvasHeightPx - tempEl.height) / 2; // 检查是否与现有元素重叠,如果重叠则尝试偏移 const checkOverlap = (x: number, y: number, width: number, height: number): boolean => { return template.elements.some((el) => { const elRight = el.x + el.width; const elBottom = el.y + el.height; const newRight = x + width; const newBottom = y + height; return !(x >= elRight || newRight <= el.x || y >= elBottom || newBottom <= el.y); }); }; // 如果中心位置重叠,尝试在周围寻找空位 if (checkOverlap(centerX, centerY, tempEl.width, tempEl.height)) { const offset = GRID_SIZE * 2; let found = false; // 尝试右下方 for (let tryY = centerY; tryY < canvasHeightPx - tempEl.height && !found; tryY += offset) { for (let tryX = centerX; tryX < canvasWidthPx - tempEl.width && !found; tryX += offset) { if (!checkOverlap(tryX, tryY, tempEl.width, tempEl.height)) { centerX = tryX; centerY = tryY; found = true; break; } } } // 如果还没找到,尝试左上方 if (!found) { for (let tryY = centerY; tryY >= 0 && !found; tryY -= offset) { for (let tryX = centerX; tryX >= 0 && !found; tryX -= offset) { if (!checkOverlap(tryX, tryY, tempEl.width, tempEl.height)) { centerX = tryX; centerY = tryY; found = true; break; } } } } } const el = createDefaultElement(type, Math.max(0, snapToGrid(centerX)), Math.max(0, snapToGrid(centerY))); if (configOverride && Object.keys(configOverride).length > 0) { el.config = { ...el.config, ...configOverride }; } setTemplate((prev) => ({ ...prev, elements: [...prev.elements, el], })); setSelectedId(el.id); }, [template.width, template.height, template.unit, template.elements]); 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; } // 转换 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: template.elements.map((el, index) => ({ id: el.id, type: el.type, x: el.x, y: el.y, width: el.width, height: el.height, rotation: el.rotation, border: el.border, zIndex: el.zIndex ?? index + 1, orderNum: el.orderNum ?? index + 1, valueSourceType: el.valueSourceType ?? defaultValueSourceTypeForElement(el.type), isRequiredInput: el.isRequiredInput ?? false, config: el.config, })), 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 blob = new Blob([JSON.stringify(template, 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)} /> 标签预览
); }