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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../ui/select'; import type { ElementLibraryCategory, LabelTemplate, LabelElement, PrintOrientation } from '../../../types/labelTemplate'; import { buildLabelTemplateScopePayload, allocateElementName, canonicalElementType, composeElementTypeForPersist, composeLibraryCategoryForPersist, createDefaultTemplate, createDefaultElement, applyPaletteLabelDefaultText, labelElementsToApiPayload, normalizePrintOrientation, PRESET_LABEL_SIZES, resolvedLibraryCategoryForPersist, resolvedTypeAddForPersist, resolvedValueSourceTypeForSave, stripLabelConfigPrefixes, valueSourceTypeForLibraryCategory, } from '../../../types/labelTemplate'; import { ElementsPanel } from './ElementsPanel'; import { LabelCanvas, LabelPreviewOnly, clampLabelElementBox, mergeLabelElementLivePatch, mergeTemplateElementsLivePatch, type LabelElementLivePatch } from './LabelCanvas'; import type { PreviewRulerDisplayUnit } from '@/utils/previewRulerUnits'; import { PropertiesPanel } from './PropertiesPanel'; import { createLabelTemplate, getLabelTemplate, getLabelTemplates, updateLabelTemplate } from '../../../services/labelTemplateService'; import { sanitizeNutritionElementsForTemplateEditor } from '../../../lib/nutritionManualEntry'; import { getLocations } from '../../../services/locationService'; import { getGroups } from '../../../services/groupService'; import { getPartners } from '../../../services/partnerService'; import { skipCountForPage } from '../../../lib/paginationQuery'; import { hydrateLabelTemplateScopeFromDto, locationsScopedForTemplateScope, regionOptionsForPartners, } from '../../../lib/categoryScopeForm'; import { CategoryScopeFields } from '../../shared/category-scope-fields'; import { useCategoryScopeAuth } from '../../../hooks/useCategoryScopeAuth'; import { TemplateFieldGroup, templateFieldInputClass, templateFieldSelectTriggerClass, templateUnitShortLabel, } from './template-editor-field-group'; 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, elements: sanitizeNutritionElementsForTemplateEditor(initialTemplate.elements), }; } const next = createDefaultTemplate(templateId ?? undefined); return { ...next, id: buildCopiedTemplateId(next.id || "template"), }; }); const [selectedId, setSelectedId] = useState(null); const [liveElementPatch, setLiveElementPatch] = useState(null); const liveElementPatchRef = useRef(null); liveElementPatchRef.current = liveElementPatch; const [scale, setScale] = useState(DEFAULT_SCALE); const [previewOpen, setPreviewOpen] = useState(false); const [previewRulerUnit, setPreviewRulerUnit] = useState('cm'); 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 [scopePartnerIds, setScopePartnerIds] = useState([]); const [scopeRegionIds, setScopeRegionIds] = useState([]); const [scopeLocationIds, setScopeLocationIds] = useState([]); const scopeHydrateKeyRef = useRef(''); const selectedElement = useMemo(() => { const el = template.elements.find((item) => item.id === selectedId) ?? null; if (!el) return null; return mergeLabelElementLivePatch(el, liveElementPatch); }, [template.elements, selectedId, liveElementPatch]); const previewTemplate = useMemo( () => ({ ...template, elements: mergeTemplateElementsLivePatch(template.elements, liveElementPatch), }), [template, liveElementPatch], ); const printOrientation = template.printOrientation ?? 'vertical'; 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 partnerKey = (template.partnerIds ?? []).join(','); const regionKey = (template.regionIds ?? []).join(','); const locKey = (template.appliedLocationIds ?? []).join(','); const key = [ template.id, template.appliedPartnerType ?? '', template.appliedRegionType ?? '', template.appliedLocation, partnerKey, regionKey, locKey, ].join(':'); if (scopeHydrateKeyRef.current === key) return; scopeHydrateKeyRef.current = key; const scope = hydrateLabelTemplateScopeFromDto( { appliedPartnerType: template.appliedPartnerType, appliedRegionType: template.appliedRegionType, appliedLocation: template.appliedLocation, partnerIds: template.partnerIds, companyIds: template.companyIds, regionIds: template.regionIds, groupIds: template.groupIds, locationIds: template.appliedLocationIds, appliedLocationIds: template.appliedLocationIds, }, locationCatalog, filterPartners, filterGroups, ); setScopePartnerIds(scope.partnerIds); setScopeRegionIds(scope.regionIds); setScopeLocationIds(scope.locationIds); }, [ scopeCatalogReady, template.id, template.appliedPartnerType, template.appliedRegionType, template.appliedLocation, template.partnerIds, template.companyIds, template.regionIds, template.groupIds, 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 ?? apiTemplate.locationIds ?? [])], appliedPartnerType: apiTemplate.appliedPartnerType as any, appliedRegionType: apiTemplate.appliedRegionType as any, partnerIds: [...(apiTemplate.partnerIds ?? apiTemplate.companyIds ?? [])], companyIds: [...(apiTemplate.companyIds ?? apiTemplate.partnerIds ?? [])], regionIds: [ ...new Set( [...(apiTemplate.regionIds ?? []), ...(apiTemplate.groupIds ?? [])] .map((x) => String(x).trim()) .filter(Boolean), ), ], groupIds: [ ...new Set( [...(apiTemplate.regionIds ?? []), ...(apiTemplate.groupIds ?? [])] .map((x) => String(x).trim()) .filter(Boolean), ), ], showRuler: apiTemplate.showRuler ?? true, showGrid: apiTemplate.showGrid ?? true, border: apiTemplate.border ?? 'none', printOrientation: normalizePrintOrientation( apiTemplate.printOrientation ?? (apiTemplate as Record).PrintOrientation, ), elements: sanitizeNutritionElementsForTemplateEditor( (apiTemplate.elements ?? []).map((raw, idx) => { const el = raw as LabelElement; const en = (el.elementName ?? '').trim(); return { ...el, elementName: en || `element${idx + 1}` }; }), ), }); setTemplate(copied); setLiveElementPatch(null); 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 = template.border ?? 'none'; /** 配置区单行 flex */ const configRowClass = "flex w-full min-w-0 flex-wrap items-center gap-2"; 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); const orientation = prev.printOrientation ?? 'vertical'; 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, undefined, orientation, ); return { ...merged, x: c.x, y: c.y, width: c.w, height: c.h }; }), }; }); }, []); const flushLiveElementPatch = useCallback(() => { const patch = liveElementPatchRef.current; if (!patch?.id) return; const { id, x, y, width, height } = patch; const geom: Partial = {}; if (x !== undefined) geom.x = x; if (y !== undefined) geom.y = y; if (width !== undefined) geom.width = width; if (height !== undefined) geom.height = height; if (Object.keys(geom).length > 0) { updateElement(id, geom); } setLiveElementPatch(null); }, [updateElement]); const handleSelectElement = useCallback((id: string | null) => { flushLiveElementPatch(); setSelectedId(id); }, [flushLiveElementPatch]); 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 (paletteItemLabel.trim().toLowerCase() === "label id") { const bottomY = Math.max(0, canvasHeightPx - el.height - GRID_SIZE); el = { ...el, y: snapToGrid(bottomY) }; } 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 }; } el.config = applyPaletteLabelDefaultText( type, el.config as Record, paletteItemLabel, 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] }; }); handleSelectElement(addedId); }, [template.width, template.height, template.unit, handleSelectElement]); const deleteElement = useCallback((id: string) => { setTemplate((prev) => ({ ...prev, elements: prev.elements.filter((el) => el.id !== id), })); handleSelectElement(null); }, [handleSelectElement]); const handleTemplateChange = useCallback((patch: Partial) => { setTemplate((prev) => ({ ...prev, ...patch })); }, []); const handlePrintOrientationChange = useCallback((orientation: PrintOrientation) => { flushLiveElementPatch(); handleTemplateChange({ printOrientation: orientation }); }, [flushLiveElementPatch, handleTemplateChange]); 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 effectivePartnerIds = scopeAuth.requireCompanySelection ? scopePartnerIds : [scopeAuth.fixedPartnerId.trim()].filter(Boolean); const availablePartnerIds = scopeAuth.requireCompanySelection ? filterPartners.map((p) => p.id).filter(Boolean) : effectivePartnerIds; const availableRegionIds = regionOptionsForPartners( filterGroups, effectivePartnerIds.length > 0 ? effectivePartnerIds : availablePartnerIds, ).map((o) => o.value); const scopedLocations = locationsScopedForTemplateScope( locationCatalog, filterPartners, filterGroups, effectivePartnerIds, scopeRegionIds, ); const availableLocationIds = scopedLocations.map((l) => l.id); const scopePayload = buildLabelTemplateScopePayload({ selectedPartnerIds: effectivePartnerIds, selectedRegionIds: scopeRegionIds, selectedLocationIds: scopeLocationIds, availablePartnerIds, availableRegionIds, availableLocationIds, }); if (!scopePayload.ok) { toast.error("Validation failed", { description: scopePayload.message }); return; } const apiInput = { id: code, name: template.name, labelType: template.labelType, unit: template.unit, width: template.width, height: template.height, showRuler: true, showGrid: template.showGrid ?? true, border: template.border ?? 'none', printOrientation: template.printOrientation ?? 'vertical', state: true, elements: labelElementsToApiPayload( sanitizeNutritionElementsForTemplateEditor(template.elements), ), ...scopePayload.body, }; 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, filterGroups, scopeAuth.requireCompanySelection, scopeAuth.fixedPartnerId, scopePartnerIds, scopeRegionIds, 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 (
{/* 顶栏:仅导航与保存 */}
{template.name}
{/* 模板配置区:统一 input-group 样式 */}
handleTemplateChange({ name: e.target.value })} className={templateFieldInputClass} /> handleTemplateChange({ width: Math.max(0.1, Number(e.target.value) || 0), showRuler: true }) } className={`${templateFieldInputClass} px-1 text-center`} /> handleTemplateChange({ height: Math.max(0.1, Number(e.target.value) || 0), showRuler: true }) } className={`${templateFieldInputClass} px-1 text-center`} /> {!templateId ? ( ) : ( )}
{!scopeCatalogReady ? ( Loading company, region and location… ) : ( {}} selectedPartnerIds={scopePartnerIds} onPartnerIdsChange={setScopePartnerIds} selectedRegionNames={[]} onRegionChange={() => {}} selectedRegionIds={scopeRegionIds} onRegionIdsChange={setScopeRegionIds} selectedLocationIds={scopeLocationIds} onLocationChange={setScopeLocationIds} requireCompanySelection={scopeAuth.requireCompanySelection} fixedPartnerId={scopeAuth.fixedPartnerId} /> )}
{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 previewRulerUnit={previewRulerUnit} onPreviewRulerUnitChange={setPreviewRulerUnit} liveElementPatch={liveElementPatch} onLiveElementPatchChange={setLiveElementPatch} printOrientation={printOrientation} onPrintOrientationChange={handlePrintOrientationChange} />

Print preview

Label preview
); }