import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '../../ui/button'; import { ArrowLeft, Save, Download } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '../../ui/dialog'; import { Input } from '../../ui/input'; import { Label } from '../../ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../ui/select'; import type { ElementLibraryCategory, LabelTemplate, LabelElement } from '../../../types/labelTemplate'; import { allocateElementName, canonicalElementType, composeElementTypeForPersist, composeLibraryCategoryForPersist, createDefaultTemplate, createDefaultElement, labelElementsToApiPayload, PRESET_LABEL_SIZES, resolvedLibraryCategoryForPersist, resolvedTypeAddForPersist, resolvedValueSourceTypeForSave, stripLabelConfigPrefixes, valueSourceTypeForLibraryCategory, } from '../../../types/labelTemplate'; import { ElementsPanel } from './ElementsPanel'; import { LabelCanvas, LabelPreviewOnly, clampLabelElementBox } from './LabelCanvas'; import { PropertiesPanel } from './PropertiesPanel'; import { createLabelTemplate, getLabelTemplate, getLabelTemplates, updateLabelTemplate } from '../../../services/labelTemplateService'; import { getLocations } from '../../../services/locationService'; import { getGroups } from '../../../services/groupService'; import { getPartners } from '../../../services/partnerService'; import { skipCountForPage } from '../../../lib/paginationQuery'; import { buildSpecifiedLocationPayload, effectiveScopePartnerId, hydrateCategoryScopeFromLocationIds, scopePartnerValidationMessage, } from '../../../lib/categoryScopeForm'; import { CategoryScopeFields } from '../../shared/category-scope-fields'; import { useCategoryScopeAuth } from '../../../hooks/useCategoryScopeAuth'; import type { LocationDto } from '../../../types/location'; import type { GroupListItem } from '../../../types/group'; import type { PartnerListItem } from '../../../types/partner'; import { toast } from 'sonner'; const MIN_SCALE = 0.5; const MAX_SCALE = 2; const SCALE_STEP = 0.25; const DEFAULT_SCALE = 1.0; function buildCopiedTemplateId(sourceId: string): string { const seed = Math.random().toString(36).slice(2, 8); return `tpl_${seed}_${Date.now().toString(36)}`; } function cloneStarterTemplate(source: LabelTemplate): LabelTemplate { return { ...source, id: buildCopiedTemplateId(source.id), name: `${(source.name || "Unnamed template").trim()} Copy`, elements: source.elements.map((el) => ({ ...el, config: { ...(el.config ?? {}) }, })), }; } interface LabelTemplateEditorProps { /** null = 新建,string = 编辑该 id */ templateId: string | null; initialTemplate: LabelTemplate | null; onClose: () => void; onSaved: () => void; } export function LabelTemplateEditor({ templateId, initialTemplate, onClose, onSaved, }: LabelTemplateEditorProps) { const scopeAuth = useCategoryScopeAuth(); const [template, setTemplate] = useState(() => { if (initialTemplate) return { ...initialTemplate }; const next = createDefaultTemplate(templateId ?? undefined); return { ...next, id: buildCopiedTemplateId(next.id || "template"), }; }); const [selectedId, setSelectedId] = useState(null); const [scale, setScale] = useState(DEFAULT_SCALE); const [previewOpen, setPreviewOpen] = useState(false); const [canvasBorder, setCanvasBorder] = useState<'none' | 'line' | 'dotted'>('none'); const [canvasRotation, setCanvasRotation] = useState<'horizontal' | 'vertical'>('horizontal'); const [starterOptions, setStarterOptions] = useState>([]); const [selectedStarterCode, setSelectedStarterCode] = useState(''); const [loadingStarter, setLoadingStarter] = useState(false); const [locationCatalog, setLocationCatalog] = useState([]); const [filterGroups, setFilterGroups] = useState([]); const [filterPartners, setFilterPartners] = useState([]); const [scopeCatalogReady, setScopeCatalogReady] = useState(false); const [scopePartnerId, setScopePartnerId] = useState(''); const [scopeRegionNames, setScopeRegionNames] = useState([]); const [scopeLocationIds, setScopeLocationIds] = useState([]); const scopeHydrateKeyRef = useRef(''); const selectedElement = template.elements.find((el) => el.id === selectedId) ?? null; useEffect(() => { let cancelled = false; (async () => { try { const out: LocationDto[] = []; let locPage = 1; const locSize = 500; for (;;) { const res = await getLocations({ skipCount: skipCountForPage(locPage), maxResultCount: locSize, }); out.push(...(res.items ?? [])); if (!res.items || res.items.length < locSize) break; locPage += 1; if (locPage > 200) break; } const [grpRes, partnerRes] = await Promise.all([ getGroups({ skipCount: 1, maxResultCount: 500 }), getPartners({ skipCount: 1, maxResultCount: 500, state: true }), ]); if (cancelled) return; setLocationCatalog(out); setFilterGroups(grpRes.items ?? []); setFilterPartners(partnerRes.items ?? []); setScopeCatalogReady(true); } catch { if (!cancelled) { setLocationCatalog([]); setFilterGroups([]); setFilterPartners([]); setScopeCatalogReady(false); } } })(); return () => { cancelled = true; }; }, []); useEffect(() => { if (!scopeCatalogReady) return; const ids = (template.appliedLocationIds ?? []).map((x) => String(x).trim()).filter(Boolean); const key = `${template.id}:${template.appliedLocation}:${ids.join(',')}`; if (scopeHydrateKeyRef.current === key) return; scopeHydrateKeyRef.current = key; if (template.appliedLocation === 'SPECIFIED' && ids.length > 0) { const scope = hydrateCategoryScopeFromLocationIds( ids, locationCatalog, filterPartners, filterGroups, ); setScopePartnerId(scope.partnerId); setScopeRegionNames(scope.regionNames); setScopeLocationIds(scope.locationIds); } else { setScopePartnerId(''); setScopeRegionNames([]); setScopeLocationIds([]); } }, [ scopeCatalogReady, template.id, template.appliedLocation, template.appliedLocationIds, locationCatalog, filterPartners, filterGroups, ]); useEffect(() => { if (templateId) return; let cancelled = false; (async () => { try { const res = await getLabelTemplates({ skipCount: 1, maxResultCount: 200 }); if (cancelled) return; const options = (res.items ?? []) .map((x) => { const code = (x.templateCode ?? x.id ?? '').trim(); const name = (x.templateName ?? x.name ?? code).trim(); return code ? { code, name } : null; }) .filter((x): x is { code: string; name: string } => !!x); setStarterOptions(options); if (!selectedStarterCode && options.length > 0) { setSelectedStarterCode(options[0].code); } } catch { if (!cancelled) setStarterOptions([]); } })(); return () => { cancelled = true; }; }, [templateId, selectedStarterCode]); useEffect(() => { if (templateId || !selectedStarterCode) return; let cancelled = false; (async () => { setLoadingStarter(true); try { const apiTemplate = await getLabelTemplate(selectedStarterCode); if (cancelled) return; const copied = cloneStarterTemplate({ id: apiTemplate.id, name: (apiTemplate.name ?? apiTemplate.templateName ?? '').trim() || 'Unnamed template', labelType: (apiTemplate.labelType as any) ?? 'PRICE', unit: (apiTemplate.unit as any) ?? 'cm', width: Number(apiTemplate.width ?? 6), height: Number(apiTemplate.height ?? 4), appliedLocation: apiTemplate.appliedLocation === 'SPECIFIED' ? 'SPECIFIED' : 'ALL', appliedLocationIds: [...(apiTemplate.appliedLocationIds ?? [])], showRuler: true, showGrid: apiTemplate.showGrid ?? true, elements: (apiTemplate.elements ?? []).map((raw, idx) => { const el = raw as LabelElement; const en = (el.elementName ?? '').trim(); return { ...el, elementName: en || `element${idx + 1}` }; }), }); setTemplate(copied); setSelectedId(null); } catch (e: any) { if (cancelled) return; toast.error("Failed to copy starter template.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { if (!cancelled) setLoadingStarter(false); } })(); return () => { cancelled = true; }; }, [templateId, selectedStarterCode]); /** 纸张尺寸或单位变化时,将已有控件限制在安全区内 */ useEffect(() => { setTemplate((prev) => { const unitToPx = (value: number, unit: "cm" | "inch"): number => unit === "cm" ? value * 37.8 : value * 96; const baseW = unitToPx(prev.width, prev.unit); const baseH = unitToPx(prev.height, prev.unit); let changed = false; const elements = prev.elements.map((el) => { const c = clampLabelElementBox(el.x, el.y, el.width, el.height, baseW, baseH); if (el.x !== c.x || el.y !== c.y || el.width !== c.w || el.height !== c.h) { changed = true; return { ...el, x: c.x, y: c.y, width: c.w, height: c.h }; } return el; }); if (!changed) return prev; return { ...prev, elements }; }); }, [template.width, template.height, template.unit, template.id]); const templateBorderValue = canvasBorder; /** 顶部表单:紧凑间距(!h-7 覆盖 SelectTrigger / Input 默认高度) */ const topCellClass = "space-y-1"; const topFieldClass = "!h-7 px-2 py-1 text-xs leading-tight bg-white"; const topSelectTriggerClass = "!h-7 gap-1 px-2 py-0 text-xs leading-tight bg-white [&_svg]:!size-3 *:data-[slot=select-value]:line-clamp-1"; const topLabelClass = "text-[11px] font-semibold leading-tight text-[#1d3c8f]"; const topRowClass = "flex w-full min-w-0 flex-nowrap items-end gap-2"; /** flex 行内固定 20% 宽,须 flex-[0_0_20%],单独 w-[20%] 在 flex 里常被内容宽度覆盖 */ const topNameCellClass = "flex-[0_0_20%] w-1/5 min-w-0 shrink-0"; const rulerControlRowClass = "flex h-7 flex-nowrap items-center gap-1.5"; const rulerMarkClass = "flex h-7 w-4 shrink-0 items-center justify-center text-[11px] font-semibold leading-none text-[#1d3c8f]"; 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)), }; const box = clampLabelElementBox(el.x, el.y, el.width, el.height, canvasWidthPx, canvasHeightPx); el = { ...el, x: box.x, y: box.y, width: box.w, height: box.h }; 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) => { const unitToPx = (value: number, unit: "cm" | "inch"): number => unit === "cm" ? value * 37.8 : value * 96; const baseW = unitToPx(prev.width, prev.unit); const baseH = unitToPx(prev.height, prev.unit); return { ...prev, elements: prev.elements.map((el) => { if (el.id !== id) return el; const merged = { ...el, ...patch }; const geomTouched = patch.x !== undefined || patch.y !== undefined || patch.width !== undefined || patch.height !== undefined; if (!geomTouched) return merged; const c = clampLabelElementBox(merged.x, merged.y, merged.width, merged.height, baseW, baseH); return { ...merged, x: c.x, y: c.y, width: c.w, height: c.h }; }), }; }); }, []); 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; } 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; } const effectivePartner = effectiveScopePartnerId( scopeAuth.requireCompanySelection, scopePartnerId, scopeAuth.fixedPartnerId, ); const partnerErr = scopePartnerValidationMessage( scopeAuth.requireCompanySelection, effectivePartner, ); if (partnerErr) { toast.error("Validation failed", { description: partnerErr }); return; } const locPayload = buildSpecifiedLocationPayload( locationCatalog, filterPartners, effectivePartner, scopeRegionNames, scopeLocationIds, ); if (!locPayload.ok) { toast.error("Validation failed", { description: locPayload.message }); return; } // 转换 LabelTemplate 到 API 需要的格式(对齐 LabelTemplateCreateInputVo) const apiInput = { id: code, name: template.name, labelType: template.labelType, unit: template.unit, width: template.width, height: template.height, appliedLocation: "SPECIFIED" as const, showRuler: true, showGrid: template.showGrid ?? true, state: true, elements: labelElementsToApiPayload(template.elements), appliedLocationIds: locPayload.locationIds, }; 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, locationCatalog, filterPartners, scopeAuth.requireCompanySelection, scopeAuth.fixedPartnerId, scopePartnerId, scopeRegionNames, scopeLocationIds, ]); 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}
{/* Template level editor (single block / three areas) */}
{/* 第一排:名称 → Ruler → 公司/区域/门店(flex 单行) */}
handleTemplateChange({ name: e.target.value })} className={`w-full ${topFieldClass}`} />
W handleTemplateChange({ width: Math.max(0.1, Number(e.target.value) || 0), showRuler: true }) } className={`w-[90px] shrink-0 ${topFieldClass}`} placeholder="Width" /> H handleTemplateChange({ height: Math.max(0.1, Number(e.target.value) || 0), showRuler: true }) } className={`w-[90px] shrink-0 ${topFieldClass}`} placeholder="Height" />
{!scopeCatalogReady ? ( Loading company, region and location… ) : ( )}
{/* 第二排:Starter Template | Border | Rotation */}
{!templateId ? ( ) : ( )}
{loadingStarter ? (
Copying starter template…
) : null}
{/* 三列:行布局内联;左侧整列 overflow-y:auto,保证橙色 Print input 可滚到 */}
setScale((s) => Math.min(MAX_SCALE, s + SCALE_STEP))} onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, s - SCALE_STEP))} onResetZoom={() => setScale(DEFAULT_SCALE)} onPreview={() => setPreviewOpen(true)} hideToolbarPresetSize />
Label preview
); }