From ac117425d755285dcf6c5c0a02b8b69afa71bc80 Mon Sep 17 00:00:00 2001 From: iqudoo Date: Mon, 15 Jun 2026 14:46:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 53 ++++----- src/components/ElementFloatPreview.tsx | 24 ++-- src/components/LabelDesigner.tsx | 122 ++++++++++++-------- src/components/PropSidebar.tsx | 93 ++++++++------- src/components/VisibilityRuleForm.tsx | 29 ++++- src/hooks/useElementClipboard.ts | 43 ++++++- src/index.css | 57 +++++++++- src/types.ts | 4 +- src/utils.ts | 150 +++++++++++++++++++++---- 9 files changed, 415 insertions(+), 160 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b7422e1..404bca4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -198,25 +198,35 @@ export default function App() { }; // Modify active template elements - const handleTemplateChange = (updated: LabelTemplate) => { - const updatedList = templates.map(t => t.id === updated.id ? updated : t); - saveTemplatesToStorage(updatedList); - }; + const activeTemplateRef = useRef(activeTemplate); + activeTemplateRef.current = activeTemplate; + + const handleTemplateChange = useCallback((updated: LabelTemplate) => { + setTemplates((prev) => { + const updatedList = prev.map((t) => (t.id === updated.id ? updated : t)); + try { + const stripped = updatedList.map(stripTemplateForStorage); + localStorage.setItem('label_templates_list_v2', JSON.stringify(stripped)); + } catch (e) {} + return updatedList; + }); + }, []); const handleDesignTemplateChange = useCallback( (updated: LabelTemplate) => { + const current = activeTemplateRef.current; if ( !skipDesignHistoryRef.current && workflowStep === 'template-design' && - activeTemplate && - updated.id === activeTemplate.id + current && + updated.id === current.id ) { - designHistory.record(activeTemplate, updated); + designHistory.record(current, updated); } skipDesignHistoryRef.current = false; handleTemplateChange(updated); }, - [workflowStep, activeTemplate, designHistory, templates] + [workflowStep, designHistory, handleTemplateChange] ); const handleDesignUndo = useCallback(() => { @@ -257,6 +267,13 @@ export default function App() { [showToast] ); + const handleDuplicateElementsSuccess = useCallback( + (count: number) => { + showToast(`已创建 ${count} 个副本`, { variant: 'success' }); + }, + [showToast] + ); + const elementClipboard = useElementClipboard({ template: activeTemplate ?? EMPTY_TEMPLATE_PLACEHOLDER, onChange: handleDesignTemplateChange, @@ -264,6 +281,7 @@ export default function App() { onSelectElements: handleSelectElements, onSelectTableCells: setSelectedTableCells, onCopySuccess: handleCopyElementsSuccess, + onDuplicateSuccess: handleDuplicateElementsSuccess, }); useEffect(() => { @@ -393,24 +411,7 @@ export default function App() { name: `${newTmplName}`, width: Math.max(10, Number(newTmplW) || 70), height: Math.max(10, Number(newTmplH) || 40), - elements: [ - { - id: `desc_welcome`, - type: 'text', - name: '主文字内容', - x: 5, - y: 5, - width: Math.max(10, newTmplW - 10), - height: 10, - content: '{变量}', - fontSize: 12, - textAlign: 'center', - fontWeight: 'bold', - barcodeFormat: 'CODE128', - showText: false, - fontFamily: 'sans' - } - ], + elements: [], paper: DEFAULT_PAPER_CONFIG, cutLine: DEFAULT_CUT_LINE_CONFIG, }; diff --git a/src/components/ElementFloatPreview.tsx b/src/components/ElementFloatPreview.tsx index 08d0c30..02c7747 100644 --- a/src/components/ElementFloatPreview.tsx +++ b/src/components/ElementFloatPreview.tsx @@ -4,7 +4,7 @@ import { mmToPx } from '../utils'; import { renderLabelToDataUrl } from '../labelRenderer'; /** 离屏单元素渲染时预留描边空间,避免画布边缘裁切表格线 */ -function getFloatPreviewPaddingMm(element: TemplateElement): number { +export function getFloatPreviewPaddingMm(element: TemplateElement): number { if (element.type !== 'table') return 0; return (element.tableBorderWidth ?? 0.2) / 2 + 0.1; } @@ -74,10 +74,10 @@ export const ElementFloatPreview: React.FC = ({ }; }, [elementVisualKey, element, renderScale, variableDefaults, dataUrlProp]); - const left = mmToPx(element.x, displayZoom); - const top = mmToPx(element.y, displayZoom); - const width = mmToPx(element.width, displayZoom); - const height = mmToPx(element.height, displayZoom); + const padMm = getFloatPreviewPaddingMm(element); + const padPx = mmToPx(padMm, displayZoom); + const boxW = mmToPx(element.width, displayZoom); + const boxH = mmToPx(element.height, displayZoom); if (!dataUrl) return null; @@ -87,18 +87,12 @@ export const ElementFloatPreview: React.FC = ({ alt="" className="absolute pointer-events-none select-none" style={{ - left: `${left}px`, - top: `${top}px`, - width: `${width}px`, - height: `${height}px`, + left: `${-padPx}px`, + top: `${-padPx}px`, + width: `${boxW + padPx * 2}px`, + height: `${boxH + padPx * 2}px`, imageRendering: 'auto', }} - onLoad={(e) => { - const placeholder = e.currentTarget.previousElementSibling as HTMLElement | null; - if (placeholder?.classList.contains('resize-float-placeholder')) { - placeholder.style.display = 'none'; - } - }} /> ); }; diff --git a/src/components/LabelDesigner.tsx b/src/components/LabelDesigner.tsx index b2aa6ea..52bb3e4 100644 --- a/src/components/LabelDesigner.tsx +++ b/src/components/LabelDesigner.tsx @@ -16,7 +16,7 @@ import { } from '../tableUtils'; import { useIsNarrowScreen } from '../hooks/useIsMobile'; import { CanvasLabelImage } from './CanvasLabelImage'; -import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview'; +import { ElementFloatPreview, getFloatPreviewPaddingMm, renderElementFloatPreview } from './ElementFloatPreview'; import { renderLabelToDataUrl } from '../labelRenderer'; import { useAppDialog } from './AppDialog'; import { MobileMainToolButtons, type MobileToolButtonItem } from './MobileMainToolButtons'; @@ -318,16 +318,19 @@ export const LabelDesigner: React.FC = ({ selectedIdsRef.current = selectedElementIds; const templateElementsRef = useRef(template.elements); templateElementsRef.current = template.elements; + const templateRef = useRef(template); + templateRef.current = template; const addImageElement = useCallback( (src: string = '') => { + const current = templateRef.current; const id = `image_${Date.now()}`; - const n = template.elements.length + 1; + const n = current.elements.length + 1; const newElement: TemplateElement = { id, type: 'image', name: `图片 ${n}`, - ...centerElementOnLabel(template.width, template.height, 30, 30), + ...centerElementOnLabel(current.width, current.height, 30, 30), width: 30, height: 30, content: src, @@ -342,20 +345,21 @@ export const LabelDesigner: React.FC = ({ imageFit: 'contain', }; onChange({ - ...template, - elements: [...template.elements, newElement], + ...current, + elements: [...current.elements, newElement], }); onSelectElements([id]); setActiveTool('move'); }, - [template, onChange, onSelectElements] + [onChange, onSelectElements] ); const addElement = useCallback( (type: 'text' | 'barcode' | 'qrcode' | 'table') => { + const current = templateRef.current; let newElement: TemplateElement; const id = `${type}_${Date.now()}`; - const n = template.elements.length + 1; + const n = current.elements.length + 1; if (type === 'table') { newElement = createDefaultTableElement(id, n); @@ -421,24 +425,25 @@ export const LabelDesigner: React.FC = ({ } const centered = centerElementOnLabel( - template.width, - template.height, + current.width, + current.height, newElement.width, newElement.height ); newElement = { ...newElement, ...centered }; onChange({ - ...template, - elements: [...template.elements, newElement], + ...current, + elements: [...current.elements, newElement], }); onSelectElements([id]); setActiveTool('move'); }, - [template, onChange, onSelectElements] + [onChange, onSelectElements] ); const deleteSelectedElements = useCallback(async () => { + const current = templateRef.current; const ids = selectedElementIds.length > 0 ? selectedElementIds @@ -446,14 +451,14 @@ export const LabelDesigner: React.FC = ({ ? [selectedElementId] : []; const deletable = ids.filter((id) => { - const el = template.elements.find((item) => item.id === id); + const el = current.elements.find((item) => item.id === id); return el && !el.locked; }); if (deletable.length === 0) return; const message = deletable.length === 1 - ? `确定删除「${template.elements.find((item) => item.id === deletable[0])?.name ?? '未命名'}」吗?此操作无法撤销。` + ? `确定删除「${current.elements.find((item) => item.id === deletable[0])?.name ?? '未命名'}」吗?此操作无法撤销。` : `确定删除选中的 ${deletable.length} 个元素吗?此操作无法撤销。`; const confirmed = await showConfirm(message, { @@ -464,10 +469,10 @@ export const LabelDesigner: React.FC = ({ if (!confirmed) return; const idSet = new Set(deletable); - const remaining = template.elements.filter((el) => !idSet.has(el.id)); - onChange({ ...template, elements: remaining }); + const remaining = current.elements.filter((el) => !idSet.has(el.id)); + onChange({ ...templateRef.current, elements: remaining }); onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id))); - }, [selectedElementIds, selectedElementId, template, onChange, onSelectElements, showConfirm]); + }, [selectedElementIds, selectedElementId, onChange, onSelectElements, showConfirm]); const handleToolClick = (tool: ActiveTool) => { if (tool === 'move') { @@ -505,7 +510,7 @@ export const LabelDesigner: React.FC = ({ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { e.preventDefault(); setActiveTool('move'); - onSelectElements(template.elements.filter((el) => !el.locked).map((el) => el.id)); + onSelectElements(templateRef.current.elements.filter((el) => !el.locked).map((el) => el.id)); return; } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') { @@ -541,9 +546,10 @@ export const LabelDesigner: React.FC = ({ const idSet = new Set(moveIds); e.preventDefault(); + const current = templateRef.current; onChange({ - ...template, - elements: template.elements.map((el) => { + ...current, + elements: current.elements.map((el) => { if (!idSet.has(el.id) || el.locked) return el; return { ...el, @@ -1036,7 +1042,7 @@ export const LabelDesigner: React.FC = ({ const shouldCommit = dragState.type === 'resize' || dragMovedRef.current; if (shouldCommit) { - onChange({ ...template, elements: localElementsRef.current }); + onChange({ ...templateRef.current, elements: localElementsRef.current }); } } localElementsRef.current = null; @@ -1474,8 +1480,8 @@ export const LabelDesigner: React.FC = ({ onClick: handleImageToolClick, }, ], - // eslint-disable-next-line react-hooks/exhaustive-deps -- tools 为静态配置,随 activeTool 更新即可 - [activeTool] + // eslint-disable-next-line react-hooks/exhaustive-deps -- tools 为静态配置 + [activeTool, handleToolClick, handleImageToolClick] ); const mainToolButtons = ( @@ -1582,15 +1588,22 @@ export const LabelDesigner: React.FC = ({ willChange: 'transform', }} > - {dragFloatElements.map((el) => ( + {dragFloatElements.map((el) => { + const padMm = getFloatPreviewPaddingMm(el); + const padPx = mmToPx(padMm, displayZoom); + const boxW = mmToPx(el.width, displayZoom); + const boxH = mmToPx(el.height, displayZoom); + return (
= ({ } }} alt="" - className="absolute inset-0 w-full h-full pointer-events-none select-none opacity-0" + className="absolute pointer-events-none select-none opacity-0" + style={{ + left: `${-padPx}px`, + top: `${-padPx}px`, + width: `${boxW + padPx * 2}px`, + height: `${boxH + padPx * 2}px`, + }} onLoad={(e) => { const img = e.currentTarget; img.style.opacity = '1'; @@ -1624,7 +1643,8 @@ export const LabelDesigner: React.FC = ({ }} />
- ))} + ); + })}
)} {resizeFloatElements && resizeFloatElements.length > 0 && ( @@ -1632,27 +1652,34 @@ export const LabelDesigner: React.FC = ({ className="absolute inset-0 pointer-events-none" style={{ overflow: 'visible' }} > - {resizeFloatElements.map((el) => ( - + {resizeFloatElements.map((el) => { + const left = mmToPx(el.x, displayZoom); + const top = mmToPx(el.y, displayZoom); + const width = mmToPx(el.width, displayZoom); + const height = mmToPx(el.height, displayZoom); + return (
- - - ))} + > + +
+
+ ); + })}
)} , @@ -1825,6 +1852,9 @@ export const LabelDesigner: React.FC = ({ if (isBeingDragged) { return null; } + if (isBeingResized && interactionCanvasReady && resizingPreviewElement) { + return null; + } const overlayElement = isBeingResized && resizingPreviewElement ? resizingPreviewElement : element; @@ -1884,6 +1914,10 @@ export const LabelDesigner: React.FC = ({ )} + {isBeingResized && ( +
+ )} + {showResizeHandles && ( <>
= ({ elementClipboard, }) => { const { showConfirm } = useAppDialog(); + const templateRef = useRef(template); + templateRef.current = template; const isNarrowScreen = useIsNarrowScreen(); const selectedElementId = selectedElementIds[0] || null; const selectedElem = template.elements.find((el) => el.id === selectedElementId); @@ -278,9 +280,10 @@ export const PropSidebar: React.FC = ({ const maxLabelH = Math.max(10, paperH - paper.marginTop - paper.marginBottom); const handleElemChange = (updatedElem: TemplateElement) => { + const current = templateRef.current; onChangeTemplate({ - ...template, - elements: template.elements.map((el) => (el.id === updatedElem.id ? updatedElem : el)), + ...current, + elements: current.elements.map((el) => (el.id === updatedElem.id ? updatedElem : el)), }); }; @@ -292,6 +295,11 @@ export const PropSidebar: React.FC = ({ } }; + const handleContentChange = (content: string) => { + if (!selectedElem || selectedElem.type === 'table') return; + handleElemChange(withContentAndSyncedName(selectedElem, content)); + }; + const nudgeableElementIds = useMemo(() => { const idSet = new Set(selectedElementIds); return template.elements @@ -424,7 +432,7 @@ export const PropSidebar: React.FC = ({ : selectedElem.content.trim() ? `${selectedElem.content}${selectedElem.content.endsWith(' ') ? '' : ' '}${token}` : token; - handleElemChange({ ...selectedElem, content }); + handleContentChange(content); }; const handleAddTemplateVariable = () => { @@ -565,6 +573,9 @@ export const PropSidebar: React.FC = ({ }; const toggleLockLayer = (idx: number) => { + const target = template.elements[idx]; + if (!target) return; + const willLock = !target.locked; const list = template.elements.map((el, i) => { if (i === idx) { return { ...el, locked: !el.locked }; @@ -572,6 +583,12 @@ export const PropSidebar: React.FC = ({ return el; }); onChangeTemplate({ ...template, elements: list }); + if (willLock && onSelectElements) { + onSelectElements(selectedElementIds.filter((id) => id !== target.id)); + if (target.type === 'table') { + onSelectTableCells?.([]); + } + } }; const deleteLayer = async (id: string) => { @@ -589,8 +606,8 @@ export const PropSidebar: React.FC = ({ if (!confirmed) return; onChangeTemplate({ - ...template, - elements: template.elements.filter((item) => item.id !== id), + ...templateRef.current, + elements: templateRef.current.elements.filter((item) => item.id !== id), }); if (onSelectElements && selectedElementIds.includes(id)) { onSelectElements(selectedElementIds.filter((selectedId) => selectedId !== id)); @@ -621,9 +638,10 @@ export const PropSidebar: React.FC = ({ }); if (!confirmed) return; + const current = templateRef.current; const idSet = new Set(deletableSelectedIds); - const remaining = template.elements.filter((el) => !idSet.has(el.id)); - onChangeTemplate({ ...template, elements: remaining }); + const remaining = current.elements.filter((el) => !idSet.has(el.id)); + onChangeTemplate({ ...current, elements: remaining }); onSelectElements?.(selectedElementIds.filter((id) => remaining.some((el) => el.id === id))); }; @@ -656,8 +674,7 @@ export const PropSidebar: React.FC = ({ const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); - reader.onload = () => - handleElemChange({ ...selectedElem, content: reader.result as string }); + reader.onload = () => handleContentChange(reader.result as string); reader.readAsDataURL(file); e.target.value = ''; }} @@ -672,7 +689,7 @@ export const PropSidebar: React.FC = ({
handleElemChange({ ...selectedElem, content: val })} + onCommit={(val) => handleContentChange(val)} rows={selectedElem.type === 'image' ? 3 : 2} className="ps-field resize-none" placeholder={ @@ -1123,7 +1140,13 @@ export const PropSidebar: React.FC = ({ handleElemChange({ ...selectedElem, name: val || '未命名元素' })} + onCommit={(val) => + handleElemChange({ + ...selectedElem, + name: val || '未命名元素', + nameCustomized: true, + }) + } className="ps-field mt-2" />
@@ -2146,12 +2169,12 @@ export const PropSidebar: React.FC = ({ }`} >
- - {elem.type === 'text' && } - {elem.type === 'barcode' && } - {elem.type === 'qrcode' && } - {elem.type === 'image' && } - {elem.type === 'table' && } + + {elem.type === 'text' && } + {elem.type === 'barcode' && } + {elem.type === 'qrcode' && } + {elem.type === 'image' && } + {elem.type === 'table' && } {elem.name} @@ -2159,7 +2182,7 @@ export const PropSidebar: React.FC = ({
= ({ onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'up'); }} disabled={actualIdx === template.elements.length - 1} title="上移" - className="p-0.5 hover:bg-[#555] disabled:opacity-20 cursor-pointer text-[#aaa]" > - + )} @@ -2190,18 +2211,18 @@ export const PropSidebar: React.FC = ({ type="button" onClick={(e) => { e.stopPropagation(); toggleLockLayer(actualIdx); }} title={isLocked ? '解锁' : '锁定'} - className={`p-0.5 cursor-pointer ${isLocked ? 'text-amber-400' : 'text-[#666] hover:text-[#ccc]'}`} + className={isLocked ? 'text-amber-400' : 'text-[#666] hover:text-[#ccc]'} > - {isLocked ? : } + {isLocked ? : } {!isLocked && ( )}
@@ -2316,25 +2337,13 @@ export const PropSidebar: React.FC = ({ ))}
- {elementClipboard?.canPaste && ( - - )} {elementClipboard?.canCopy && ( + ))} +
+ )} +

+ 支持字面量或 {`{变量名}`};导出时按数据行解析变量后再比较 +

)} diff --git a/src/hooks/useElementClipboard.ts b/src/hooks/useElementClipboard.ts index 0b205f6..8bf6996 100644 --- a/src/hooks/useElementClipboard.ts +++ b/src/hooks/useElementClipboard.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import type { LabelTemplate, TemplateElement } from '../types'; import type { TableCellCoord } from '../tableUtils'; import { cloneTemplateElementsForPaste } from '../utils'; @@ -21,11 +21,14 @@ export interface UseElementClipboardOptions { onSelectElements: (ids: string[]) => void; onSelectTableCells?: (cells: TableCellCoord[]) => void; onCopySuccess?: (count: number) => void; + onDuplicateSuccess?: (count: number) => void; } export interface ElementClipboardActions { copySelectedElements: () => void; pasteClipboardElements: () => void; + /** 复制并立即粘贴(移动端快捷复制) */ + copyAndPasteSelectedElements: () => void; canCopy: boolean; canPaste: boolean; clipboardCount: number; @@ -38,7 +41,10 @@ export function useElementClipboard({ onSelectElements, onSelectTableCells, onCopySuccess, + onDuplicateSuccess, }: UseElementClipboardOptions): ElementClipboardActions { + const templateRef = useRef(template); + templateRef.current = template; const [clipboardCount, setClipboardCount] = useState(getClipboardCount); const syncClipboardCount = useCallback((elements: TemplateElement[] | null) => { @@ -51,30 +57,55 @@ export function useElementClipboard({ const copySelectedElements = useCallback(() => { if (selectedElementIds.length === 0) return; + const current = templateRef.current; const idSet = new Set(selectedElementIds); - const toCopy = template.elements.filter((el) => idSet.has(el.id)); + const toCopy = current.elements.filter((el) => idSet.has(el.id)); if (toCopy.length === 0) return; const cloned = JSON.parse(JSON.stringify(toCopy)) as TemplateElement[]; syncClipboardCount(cloned); onCopySuccess?.(toCopy.length); - }, [selectedElementIds, template.elements, syncClipboardCount, onCopySuccess]); + }, [selectedElementIds, syncClipboardCount, onCopySuccess]); const pasteClipboardElements = useCallback(() => { const source = globalElementClipboard.elements; if (!source || source.length === 0) return; + const current = templateRef.current; const pasted = cloneTemplateElementsForPaste(source, 1); onChange({ - ...template, - elements: [...template.elements, ...pasted], + ...current, + elements: [...current.elements, ...pasted], }); onSelectElements(pasted.map((el) => el.id)); onSelectTableCells?.([]); syncClipboardCount(null); - }, [template, onChange, onSelectElements, onSelectTableCells, syncClipboardCount]); + }, [onChange, onSelectElements, onSelectTableCells, syncClipboardCount]); + + const copyAndPasteSelectedElements = useCallback(() => { + if (selectedElementIds.length === 0) return; + const current = templateRef.current; + const idSet = new Set(selectedElementIds); + const toCopy = current.elements.filter((el) => idSet.has(el.id)); + if (toCopy.length === 0) return; + const pasted = cloneTemplateElementsForPaste(toCopy, 1); + onChange({ + ...current, + elements: [...current.elements, ...pasted], + }); + onSelectElements(pasted.map((el) => el.id)); + onSelectTableCells?.([]); + onDuplicateSuccess?.(toCopy.length); + }, [ + selectedElementIds, + onChange, + onSelectElements, + onSelectTableCells, + onDuplicateSuccess, + ]); return { copySelectedElements, pasteClipboardElements, + copyAndPasteSelectedElements, canCopy, canPaste, clipboardCount, diff --git a/src/index.css b/src/index.css index 4093487..c1a1909 100644 --- a/src/index.css +++ b/src/index.css @@ -756,8 +756,10 @@ html.app-dark-shell body { display: flex; align-items: center; justify-content: space-between; - padding: 4px 8px; - font-size: 11px; + gap: 8px; + min-height: 40px; + padding: 6px 10px; + font-size: 12px; cursor: pointer; user-select: none; border-bottom: 1px solid #222; @@ -783,6 +785,51 @@ html.app-dark-shell body { background: transparent; } +.ps-layer-item-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 16px; + height: 16px; + color: #666; +} + +.ps-layer-item-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.ps-layer-item-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + background: transparent; + border-radius: 2px; + color: #aaa; + cursor: pointer; +} + +.ps-layer-item-actions button:hover:not(:disabled) { + background: #555; +} + +.ps-layer-item-actions button:disabled { + opacity: 0.2; + cursor: default; +} + +.ps-layer-item-actions button svg { + width: 14px; + height: 14px; +} + .ps-marquee-select { position: absolute; border: 1px solid #31a8ff; @@ -797,6 +844,12 @@ html.app-dark-shell body { outline-offset: 0; } +.ps-resize-outline { + outline: 1px dashed var(--color-ps-accent); + outline-offset: 0; + box-shadow: inset 0 0 0 1px rgba(49, 168, 255, 0.2); +} + .ps-unselected-dim-layer { position: absolute; inset: 0; diff --git a/src/types.ts b/src/types.ts index 52ce2cf..f40f0dd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,7 +28,7 @@ export interface VisibilityRule { target?: VisibilityRuleTarget; variable: string; operator: VisibilityOperator; - /** gt / lt / eq / neq / contains / not_contains 时的比较目标值 */ + /** gt / lt / eq / neq / contains / not_contains 时的比较目标值,支持 {变量名} */ value?: string; /** 为 false 时跳过该条件;默认启用 */ enabled?: boolean; @@ -91,6 +91,8 @@ export interface TemplateElement { id: string; type: 'text' | 'barcode' | 'qrcode' | 'image' | 'table'; name: string; + /** 用户是否手动修改过图层名称;为 true 时不再根据内容自动同步名称 */ + nameCustomized?: boolean; x: number; // in mm y: number; // in mm width: number; // in mm diff --git a/src/utils.ts b/src/utils.ts index 652c7c6..cb1b1e3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -45,6 +45,70 @@ export function centerElementOnLabel( }; } +const AUTO_ELEMENT_NAME_RE = /^(文本|条形码|二维码|图片|表格) \d+$/; +const MAX_LAYER_NAME_FROM_CONTENT = 40; + +/** 是否为新建时的默认图层名(含粘贴副本后缀) */ +export function isAutoGeneratedElementName(name: string): boolean { + const trimmed = name.trim(); + if (!trimmed || trimmed === '未命名元素') return true; + if (AUTO_ELEMENT_NAME_RE.test(trimmed)) return true; + if (trimmed.endsWith(' 副本')) { + const base = trimmed.slice(0, -3).trim(); + if (AUTO_ELEMENT_NAME_RE.test(base) || base === '未命名元素') return true; + } + return false; +} + +/** 是否应根据内容自动同步图层名称 */ +export function shouldSyncElementNameFromContent(elem: TemplateElement): boolean { + if (elem.nameCustomized) return false; + return isAutoGeneratedElementName(elem.name); +} + +/** 从元素内容推导图层显示名称 */ +export function deriveElementNameFromContent( + content: string, + type: TemplateElement['type'] +): string { + const trimmed = content.trim(); + if (!trimmed) return '未命名元素'; + + if (type === 'image') { + if (trimmed.startsWith('data:')) return '图片'; + const withoutVars = trimmed.replace(/\{[^}]+\}/g, '').trim(); + if (withoutVars && /^https?:\/\//i.test(withoutVars)) { + try { + const path = withoutVars.split(/[?#]/)[0]; + const segment = path.split('/').filter(Boolean).pop(); + if (segment) { + return decodeURIComponent(segment).slice(0, MAX_LAYER_NAME_FROM_CONTENT); + } + } catch { + /* ignore */ + } + } + } + + const singleLine = trimmed.split(/\r?\n/)[0].trim(); + if (!singleLine) return '未命名元素'; + if (singleLine.startsWith('data:')) return type === 'image' ? '图片' : '未命名元素'; + return singleLine.slice(0, MAX_LAYER_NAME_FROM_CONTENT); +} + +/** 更新内容并在需要时同步图层名称 */ +export function withContentAndSyncedName( + elem: TemplateElement, + content: string +): TemplateElement { + const updated = { ...elem, content }; + if (!shouldSyncElementNameFromContent(elem)) return updated; + return { + ...updated, + name: deriveElementNameFromContent(content, elem.type), + }; +} + /** 深拷贝元素并生成新 id,用于复制/粘贴 */ export function cloneTemplateElementsForPaste( elements: TemplateElement[], @@ -251,8 +315,8 @@ export function getExportRangeIndices( export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = { enabled: true, style: 'dashed', - width: 0.1, - color: '#cccccc', + width: 0.2, + color: '#333333', }; /** 裁切线有效线宽 (mm),预览与 PDF 共用 */ @@ -469,8 +533,8 @@ export const DEFAULT_PAPER_CONFIG: PaperConfig = { marginBottom: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM, marginLeft: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM, marginRight: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM, - columnGap: 2, - rowGap: 2, + columnGap: 0, + rowGap: 0, flow: 'left-right-top-bottom', }; @@ -571,6 +635,24 @@ export function resolveElementBackgroundFill( return color; } +/** 从显示/过滤条件中收集引用的变量名 */ +function collectVariablesFromVisibilityRule(rule: VisibilityRule, vars: Set) { + const name = (rule.variable ?? '').trim(); + if (name) vars.add(name); + if (rule.value) { + for (const v of extractVariablesFromContent(rule.value)) { + vars.add(v); + } + } +} + +function collectVariablesFromVisibilityRules(rules: VisibilityRule[] | undefined, vars: Set) { + if (!rules) return; + for (const rule of rules) { + collectVariablesFromVisibilityRule(rule, vars); + } +} + /** 从元素内容(含表格单元格)提取变量名 */ export function extractVariablesFromElement(elem: TemplateElement): string[] { const vars = new Set(); @@ -593,32 +675,36 @@ export function extractVariablesFromElement(elem: TemplateElement): string[] { export function extractTemplateVariables(template: { variableList?: string[]; elements: TemplateElement[]; + dataFilterRules?: VisibilityRule[]; + exportImageFilterRules?: VisibilityRule[]; }): string[] { const vars = new Set(template.variableList ?? []); for (const elem of template.elements) { for (const v of extractVariablesFromElement(elem)) { vars.add(v); } - for (const rule of resolveVisibilityRules(elem)) { - const name = (rule.variable ?? '').trim(); - if (name) vars.add(name); - } + collectVariablesFromVisibilityRules(resolveVisibilityRules(elem), vars); } + collectVariablesFromVisibilityRules(template.dataFilterRules, vars); + collectVariablesFromVisibilityRules(template.exportImageFilterRules, vars); return Array.from(vars).sort((a, b) => a.localeCompare(b, 'zh-CN')); } /** 被元素内容或显示条件引用的变量 */ -export function getUsedTemplateVariables(template: { elements: TemplateElement[] }): string[] { +export function getUsedTemplateVariables(template: { + elements: TemplateElement[]; + dataFilterRules?: VisibilityRule[]; + exportImageFilterRules?: VisibilityRule[]; +}): string[] { const vars = new Set(); for (const elem of template.elements) { for (const v of extractVariablesFromElement(elem)) { vars.add(v); } - for (const rule of resolveVisibilityRules(elem)) { - const name = (rule.variable ?? '').trim(); - if (name) vars.add(name); - } + collectVariablesFromVisibilityRules(resolveVisibilityRules(elem), vars); } + collectVariablesFromVisibilityRules(template.dataFilterRules, vars); + collectVariablesFromVisibilityRules(template.exportImageFilterRules, vars); return Array.from(vars); } @@ -781,6 +867,20 @@ function getVisibilityRuleRawValue( return getVariableRawValue(rule.variable, row, defaults, mode); } +/** 解析条件目标值(支持 {变量名}) */ +export function resolveVisibilityRuleCompareTarget( + targetTemplate: string | undefined, + row: RecordRow | null, + defaults: Record | null | undefined, + mode: ContentResolveMode, + rowIndex: number = 0 +): string { + const trimmed = (targetTemplate ?? '').trim(); + if (!trimmed) return ''; + if (!trimmed.includes('{')) return trimmed; + return resolveContent(trimmed, row, rowIndex, defaults, { mode }).trim(); +} + /** 单条显示条件是否满足 */ export function evaluateVisibilityRule( rule: VisibilityRule, @@ -792,6 +892,13 @@ export function evaluateVisibilityRule( ): boolean { const raw = getVisibilityRuleRawValue(rule, elem, row, rowIndex, defaults, mode); const isEmpty = raw === undefined; + const compareTarget = resolveVisibilityRuleCompareTarget( + rule.value, + row, + defaults, + mode, + rowIndex + ); switch (rule.operator) { case 'empty': @@ -802,23 +909,20 @@ export function evaluateVisibilityRule( case 'lt': case 'eq': { if (isEmpty) return false; - return compareVariableToTarget(raw!, (rule.value ?? '').trim(), rule.operator); + return compareVariableToTarget(raw!, compareTarget, rule.operator); } case 'neq': { - const target = (rule.value ?? '').trim(); - if (isEmpty) return target !== ''; - return compareVariableToTarget(raw!, target, 'neq'); + if (isEmpty) return compareTarget !== ''; + return compareVariableToTarget(raw!, compareTarget, 'neq'); } case 'contains': { - const target = (rule.value ?? '').trim(); - if (!target || isEmpty) return false; - return raw!.includes(target); + if (!compareTarget || isEmpty) return false; + return raw!.includes(compareTarget); } case 'not_contains': { - const target = (rule.value ?? '').trim(); - if (!target) return true; + if (!compareTarget) return true; if (isEmpty) return true; - return !raw!.includes(target); + return !raw!.includes(compareTarget); } default: return true;