import React, { useEffect, useState } from 'react'; import { ScrollArea } from '../../ui/scroll-area'; import { Input } from '../../ui/input'; import { Button } from '../../ui/button'; import { Label } from '../../ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../ui/select'; import { Switch } from '../../ui/switch'; import type { LabelTemplate, LabelElement, LabelType, Unit, Rotation, Border, AppliedLocation, } from '../../../types/labelTemplate'; import type { LocationDto } from '../../../types/location'; import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption'; import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService'; import { Checkbox } from '../../ui/checkbox'; import { Trash2 } from 'lucide-react'; interface PropertiesPanelProps { template: LabelTemplate; selectedElement: LabelElement | null; onTemplateChange: (patch: Partial) => void; onElementChange: (id: string, patch: Partial) => void; onDeleteElement?: (id: string) => void; /** 门店列表:appliedLocation=SPECIFIED 时勾选 */ locations?: LocationDto[]; /** 编辑已有模板时禁止修改 Template Code */ readOnlyTemplateCode?: boolean; } export function PropertiesPanel({ template, selectedElement, onTemplateChange, onElementChange, onDeleteElement, locations = [], readOnlyTemplateCode = false, }: PropertiesPanelProps) { if (selectedElement) { return (
Properties (Element)
onElementChange(selectedElement.id, { x: Number(e.target.value) || 0, }) } className="h-8 text-sm" />
onElementChange(selectedElement.id, { y: Number(e.target.value) || 0, }) } className="h-8 text-sm" />
onElementChange(selectedElement.id, { width: Math.max(1, Number(e.target.value) || 0), }) } className="h-8 text-sm" />
onElementChange(selectedElement.id, { height: Math.max(1, Number(e.target.value) || 0), }) } className="h-8 text-sm" />
onElementChange(selectedElement.id, { elementName: e.target.value, }) } className="h-8 text-sm mt-1" placeholder="e.g. text1" />

Required for save; used as data-entry column header (elementName).

onElementChange(selectedElement.id, { config: { ...selectedElement.config, ...config } }) } /> {onDeleteElement && (
)}
); } return (
Properties (Template)
onTemplateChange({ id: e.target.value.trim() })} className="h-8 text-sm mt-1" placeholder="e.g. TPL_TEST_001" />
onTemplateChange({ name: e.target.value })} className="h-8 text-sm mt-1" />
{template.appliedLocation === "SPECIFIED" && (
{locations.length === 0 ? (

No locations loaded.

) : ( locations.map((loc) => { const checked = (template.appliedLocationIds ?? []).includes(loc.id); return ( ); }) )}
)}
onTemplateChange({ width: Math.max(0.1, Number(e.target.value) || 0) }) } className="h-8 text-sm" />
onTemplateChange({ height: Math.max(0.1, Number(e.target.value) || 0) }) } className="h-8 text-sm" />
onTemplateChange({ showRuler: v })} />
onTemplateChange({ showGrid: v })} />
); } const MULTIPLE_OPTION_NONE = '__none__'; /** 绑定「Multiple Options」页维护的字典:先选字典,再在该字典的值列表中多选 */ function MultipleOptionsDictionaryFields({ cfg, onPatch, }: { cfg: Record; onPatch: (patch: Record) => void; }) { const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { let cancelled = false; setLoading(true); getLabelMultipleOptions({ skipCount: 1, maxResultCount: 500 }) .then((res) => { if (!cancelled) setRows(res.items ?? []); }) .catch(() => { if (!cancelled) setRows([]); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, []); const selectedId = ((cfg.multipleOptionId as string) ?? '').trim(); const selectedVals = Array.isArray(cfg.selectedOptionValues) ? (cfg.selectedOptionValues as string[]) : []; const active = rows.find((r) => r.id === selectedId); const valueList = active?.optionValuesJson ?? []; /** 从服务端拉到的字典列表就绪后,为已绑定 id 的旧模板补上 multipleOptionName,画布才能显示「名称:」前缀 */ useEffect(() => { if (!selectedId || rows.length === 0) return; const row = rows.find((r) => r.id === selectedId); const name = String(row?.optionName ?? '').trim(); if (!row || !name) return; const current = String(cfg.multipleOptionName ?? '').trim(); if (name !== current) { onPatch({ multipleOptionName: name }); } }, [selectedId, rows, cfg.multipleOptionName, onPatch]); const selectValue = selectedId ? selectedId : MULTIPLE_OPTION_NONE; return ( <>

Data comes from the Multiple Options tab (label-multiple-option list).

{active && valueList.length > 0 ? (
{valueList.map((val) => (
{ const set = new Set(selectedVals); if (checked) set.add(val); else set.delete(val); onPatch({ selectedOptionValues: Array.from(set) }); }} /> {val}
))}
) : selectedId ? (

No values in this dictionary or still loading.

) : null} ); } function TextStaticStyleFields({ cfg, update, textAlignDefault, }: { cfg: Record; update: (key: string, value: unknown) => void; textAlignDefault: string; }) { return ( <>
update('text', e.target.value)} className="h-8 text-sm mt-1" />
update('prefix', e.target.value)} className="h-8 text-sm mt-1" />
update('fontSize', Number(e.target.value) || 14)} className="h-8 text-sm mt-1" />
); } /** 读 config(兼容后端 PascalCase、数字以字符串下发) */ function cfgPickStr(cfg: Record, keys: string[], fallback: string): string { for (const k of keys) { const v = cfg[k]; if (v != null && String(v).trim() !== '') return String(v).trim(); } return fallback; } function cfgPickNum(cfg: Record, keys: string[], fallback: number): number { for (const k of keys) { const v = cfg[k]; if (v == null || v === '') continue; const n = typeof v === 'number' ? v : Number(v); if (Number.isFinite(n)) return n; } return fallback; } function ElementConfigFields({ element, onChange, }: { element: LabelElement; onChange: (config: Record) => void; }) { const cfg = element.config as Record; const update = (key: string, value: unknown) => onChange({ [key]: value }); switch (element.type) { case 'TEXT_STATIC': if (cfg.inputType === 'options') { return ( <> ); } return ; case 'TEXT_PRODUCT': case 'TEXT_PRICE': return ; case 'BARCODE': return ( <>
update('data', e.target.value)} className="h-8 text-sm mt-1" />
update('showText', v)} />
); case 'QRCODE': return (
update('data', e.target.value)} className="h-8 text-sm mt-1" />
); case 'IMAGE': return ( <>
update('src', e.target.value)} className="h-8 text-sm mt-1" placeholder="https://... or /picture/..." />
); case 'DATE': { const inputTypeNorm = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase(); const isPrintDate = inputTypeNorm === 'datetime' || inputTypeNorm === 'date'; return ( <>
update('format', e.target.value)} className="h-8 text-sm mt-1" placeholder="YYYY-MM-DD" /> {isPrintDate ? (

Shown as placeholder on the label until the app fills the date at print time.

) : null}
update('offsetDays', Number(e.target.value) || 0)} className="h-8 text-sm mt-1" />
); } case 'TIME': return (
update('format', e.target.value)} className="h-8 text-sm mt-1" placeholder="HH:mm" />
); case 'DURATION': return ( <>
update('format', e.target.value)} className="h-8 text-sm mt-1" placeholder="YYYY-MM-DD" />
update('offsetDays', Number(e.target.value) || 3)} className="h-8 text-sm mt-1" />
); case 'WEIGHT': return ( <>
update('value', Number(e.target.value) || 0)} className="h-8 text-sm mt-1" />
); case 'WEIGHT_PRICE': return ( <>
update('unitPrice', Number(e.target.value) || 0)} className="h-8 text-sm mt-1" />
update('weight', Number(e.target.value) || 0)} className="h-8 text-sm mt-1" />
update('currency', e.target.value)} className="h-8 text-sm mt-1" />
); case 'NUTRITION': return ( <>
update('calories', Number(e.target.value) || 0)} className="h-8 text-sm mt-1" />
update('fat', e.target.value)} className="h-8 text-sm mt-1" />
update('protein', e.target.value)} className="h-8 text-sm mt-1" />
update('carbs', e.target.value)} className="h-8 text-sm mt-1" />
); case 'BLANK': return (
Blank spacer; no configuration needed.
); default: return (
Config for {element.type} (edit in code if needed)
); } }