优化修复

This commit is contained in:
iqudoo
2026-06-15 14:46:30 +08:00
parent 8c9b53b1b6
commit ac117425d7
9 changed files with 415 additions and 160 deletions

View File

@@ -198,25 +198,35 @@ export default function App() {
}; };
// Modify active template elements // Modify active template elements
const handleTemplateChange = (updated: LabelTemplate) => { const activeTemplateRef = useRef(activeTemplate);
const updatedList = templates.map(t => t.id === updated.id ? updated : t); activeTemplateRef.current = activeTemplate;
saveTemplatesToStorage(updatedList);
}; 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( const handleDesignTemplateChange = useCallback(
(updated: LabelTemplate) => { (updated: LabelTemplate) => {
const current = activeTemplateRef.current;
if ( if (
!skipDesignHistoryRef.current && !skipDesignHistoryRef.current &&
workflowStep === 'template-design' && workflowStep === 'template-design' &&
activeTemplate && current &&
updated.id === activeTemplate.id updated.id === current.id
) { ) {
designHistory.record(activeTemplate, updated); designHistory.record(current, updated);
} }
skipDesignHistoryRef.current = false; skipDesignHistoryRef.current = false;
handleTemplateChange(updated); handleTemplateChange(updated);
}, },
[workflowStep, activeTemplate, designHistory, templates] [workflowStep, designHistory, handleTemplateChange]
); );
const handleDesignUndo = useCallback(() => { const handleDesignUndo = useCallback(() => {
@@ -257,6 +267,13 @@ export default function App() {
[showToast] [showToast]
); );
const handleDuplicateElementsSuccess = useCallback(
(count: number) => {
showToast(`已创建 ${count} 个副本`, { variant: 'success' });
},
[showToast]
);
const elementClipboard = useElementClipboard({ const elementClipboard = useElementClipboard({
template: activeTemplate ?? EMPTY_TEMPLATE_PLACEHOLDER, template: activeTemplate ?? EMPTY_TEMPLATE_PLACEHOLDER,
onChange: handleDesignTemplateChange, onChange: handleDesignTemplateChange,
@@ -264,6 +281,7 @@ export default function App() {
onSelectElements: handleSelectElements, onSelectElements: handleSelectElements,
onSelectTableCells: setSelectedTableCells, onSelectTableCells: setSelectedTableCells,
onCopySuccess: handleCopyElementsSuccess, onCopySuccess: handleCopyElementsSuccess,
onDuplicateSuccess: handleDuplicateElementsSuccess,
}); });
useEffect(() => { useEffect(() => {
@@ -393,24 +411,7 @@ export default function App() {
name: `${newTmplName}`, name: `${newTmplName}`,
width: Math.max(10, Number(newTmplW) || 70), width: Math.max(10, Number(newTmplW) || 70),
height: Math.max(10, Number(newTmplH) || 40), height: Math.max(10, Number(newTmplH) || 40),
elements: [ 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'
}
],
paper: DEFAULT_PAPER_CONFIG, paper: DEFAULT_PAPER_CONFIG,
cutLine: DEFAULT_CUT_LINE_CONFIG, cutLine: DEFAULT_CUT_LINE_CONFIG,
}; };

View File

@@ -4,7 +4,7 @@ import { mmToPx } from '../utils';
import { renderLabelToDataUrl } from '../labelRenderer'; import { renderLabelToDataUrl } from '../labelRenderer';
/** 离屏单元素渲染时预留描边空间,避免画布边缘裁切表格线 */ /** 离屏单元素渲染时预留描边空间,避免画布边缘裁切表格线 */
function getFloatPreviewPaddingMm(element: TemplateElement): number { export function getFloatPreviewPaddingMm(element: TemplateElement): number {
if (element.type !== 'table') return 0; if (element.type !== 'table') return 0;
return (element.tableBorderWidth ?? 0.2) / 2 + 0.1; return (element.tableBorderWidth ?? 0.2) / 2 + 0.1;
} }
@@ -74,10 +74,10 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
}; };
}, [elementVisualKey, element, renderScale, variableDefaults, dataUrlProp]); }, [elementVisualKey, element, renderScale, variableDefaults, dataUrlProp]);
const left = mmToPx(element.x, displayZoom); const padMm = getFloatPreviewPaddingMm(element);
const top = mmToPx(element.y, displayZoom); const padPx = mmToPx(padMm, displayZoom);
const width = mmToPx(element.width, displayZoom); const boxW = mmToPx(element.width, displayZoom);
const height = mmToPx(element.height, displayZoom); const boxH = mmToPx(element.height, displayZoom);
if (!dataUrl) return null; if (!dataUrl) return null;
@@ -87,18 +87,12 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
alt="" alt=""
className="absolute pointer-events-none select-none" className="absolute pointer-events-none select-none"
style={{ style={{
left: `${left}px`, left: `${-padPx}px`,
top: `${top}px`, top: `${-padPx}px`,
width: `${width}px`, width: `${boxW + padPx * 2}px`,
height: `${height}px`, height: `${boxH + padPx * 2}px`,
imageRendering: 'auto', imageRendering: 'auto',
}} }}
onLoad={(e) => {
const placeholder = e.currentTarget.previousElementSibling as HTMLElement | null;
if (placeholder?.classList.contains('resize-float-placeholder')) {
placeholder.style.display = 'none';
}
}}
/> />
); );
}; };

View File

@@ -16,7 +16,7 @@ import {
} from '../tableUtils'; } from '../tableUtils';
import { useIsNarrowScreen } from '../hooks/useIsMobile'; import { useIsNarrowScreen } from '../hooks/useIsMobile';
import { CanvasLabelImage } from './CanvasLabelImage'; import { CanvasLabelImage } from './CanvasLabelImage';
import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview'; import { ElementFloatPreview, getFloatPreviewPaddingMm, renderElementFloatPreview } from './ElementFloatPreview';
import { renderLabelToDataUrl } from '../labelRenderer'; import { renderLabelToDataUrl } from '../labelRenderer';
import { useAppDialog } from './AppDialog'; import { useAppDialog } from './AppDialog';
import { MobileMainToolButtons, type MobileToolButtonItem } from './MobileMainToolButtons'; import { MobileMainToolButtons, type MobileToolButtonItem } from './MobileMainToolButtons';
@@ -318,16 +318,19 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
selectedIdsRef.current = selectedElementIds; selectedIdsRef.current = selectedElementIds;
const templateElementsRef = useRef(template.elements); const templateElementsRef = useRef(template.elements);
templateElementsRef.current = template.elements; templateElementsRef.current = template.elements;
const templateRef = useRef(template);
templateRef.current = template;
const addImageElement = useCallback( const addImageElement = useCallback(
(src: string = '') => { (src: string = '') => {
const current = templateRef.current;
const id = `image_${Date.now()}`; const id = `image_${Date.now()}`;
const n = template.elements.length + 1; const n = current.elements.length + 1;
const newElement: TemplateElement = { const newElement: TemplateElement = {
id, id,
type: 'image', type: 'image',
name: `图片 ${n}`, name: `图片 ${n}`,
...centerElementOnLabel(template.width, template.height, 30, 30), ...centerElementOnLabel(current.width, current.height, 30, 30),
width: 30, width: 30,
height: 30, height: 30,
content: src, content: src,
@@ -342,20 +345,21 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
imageFit: 'contain', imageFit: 'contain',
}; };
onChange({ onChange({
...template, ...current,
elements: [...template.elements, newElement], elements: [...current.elements, newElement],
}); });
onSelectElements([id]); onSelectElements([id]);
setActiveTool('move'); setActiveTool('move');
}, },
[template, onChange, onSelectElements] [onChange, onSelectElements]
); );
const addElement = useCallback( const addElement = useCallback(
(type: 'text' | 'barcode' | 'qrcode' | 'table') => { (type: 'text' | 'barcode' | 'qrcode' | 'table') => {
const current = templateRef.current;
let newElement: TemplateElement; let newElement: TemplateElement;
const id = `${type}_${Date.now()}`; const id = `${type}_${Date.now()}`;
const n = template.elements.length + 1; const n = current.elements.length + 1;
if (type === 'table') { if (type === 'table') {
newElement = createDefaultTableElement(id, n); newElement = createDefaultTableElement(id, n);
@@ -421,24 +425,25 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
} }
const centered = centerElementOnLabel( const centered = centerElementOnLabel(
template.width, current.width,
template.height, current.height,
newElement.width, newElement.width,
newElement.height newElement.height
); );
newElement = { ...newElement, ...centered }; newElement = { ...newElement, ...centered };
onChange({ onChange({
...template, ...current,
elements: [...template.elements, newElement], elements: [...current.elements, newElement],
}); });
onSelectElements([id]); onSelectElements([id]);
setActiveTool('move'); setActiveTool('move');
}, },
[template, onChange, onSelectElements] [onChange, onSelectElements]
); );
const deleteSelectedElements = useCallback(async () => { const deleteSelectedElements = useCallback(async () => {
const current = templateRef.current;
const ids = const ids =
selectedElementIds.length > 0 selectedElementIds.length > 0
? selectedElementIds ? selectedElementIds
@@ -446,14 +451,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
? [selectedElementId] ? [selectedElementId]
: []; : [];
const deletable = ids.filter((id) => { 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; return el && !el.locked;
}); });
if (deletable.length === 0) return; if (deletable.length === 0) return;
const message = const message =
deletable.length === 1 deletable.length === 1
? `确定删除「${template.elements.find((item) => item.id === deletable[0])?.name ?? '未命名'}」吗?此操作无法撤销。` ? `确定删除「${current.elements.find((item) => item.id === deletable[0])?.name ?? '未命名'}」吗?此操作无法撤销。`
: `确定删除选中的 ${deletable.length} 个元素吗?此操作无法撤销。`; : `确定删除选中的 ${deletable.length} 个元素吗?此操作无法撤销。`;
const confirmed = await showConfirm(message, { const confirmed = await showConfirm(message, {
@@ -464,10 +469,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (!confirmed) return; if (!confirmed) return;
const idSet = new Set(deletable); const idSet = new Set(deletable);
const remaining = template.elements.filter((el) => !idSet.has(el.id)); const remaining = current.elements.filter((el) => !idSet.has(el.id));
onChange({ ...template, elements: remaining }); onChange({ ...templateRef.current, elements: remaining });
onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id))); 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) => { const handleToolClick = (tool: ActiveTool) => {
if (tool === 'move') { if (tool === 'move') {
@@ -505,7 +510,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
e.preventDefault(); e.preventDefault();
setActiveTool('move'); 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; return;
} }
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
@@ -541,9 +546,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const idSet = new Set(moveIds); const idSet = new Set(moveIds);
e.preventDefault(); e.preventDefault();
const current = templateRef.current;
onChange({ onChange({
...template, ...current,
elements: template.elements.map((el) => { elements: current.elements.map((el) => {
if (!idSet.has(el.id) || el.locked) return el; if (!idSet.has(el.id) || el.locked) return el;
return { return {
...el, ...el,
@@ -1036,7 +1042,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const shouldCommit = const shouldCommit =
dragState.type === 'resize' || dragMovedRef.current; dragState.type === 'resize' || dragMovedRef.current;
if (shouldCommit) { if (shouldCommit) {
onChange({ ...template, elements: localElementsRef.current }); onChange({ ...templateRef.current, elements: localElementsRef.current });
} }
} }
localElementsRef.current = null; localElementsRef.current = null;
@@ -1474,8 +1480,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
onClick: handleImageToolClick, onClick: handleImageToolClick,
}, },
], ],
// eslint-disable-next-line react-hooks/exhaustive-deps -- tools 为静态配置,随 activeTool 更新即可 // eslint-disable-next-line react-hooks/exhaustive-deps -- tools 为静态配置
[activeTool] [activeTool, handleToolClick, handleImageToolClick]
); );
const mainToolButtons = ( const mainToolButtons = (
@@ -1582,15 +1588,22 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
willChange: 'transform', 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 (
<div <div
key={el.id} key={el.id}
className="absolute pointer-events-none" className="absolute pointer-events-none"
style={{ style={{
left: `${mmToPx(el.x, displayZoom)}px`, left: `${mmToPx(el.x, displayZoom)}px`,
top: `${mmToPx(el.y, displayZoom)}px`, top: `${mmToPx(el.y, displayZoom)}px`,
width: `${mmToPx(el.width, displayZoom)}px`, width: `${boxW}px`,
height: `${mmToPx(el.height, displayZoom)}px`, height: `${boxH}px`,
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
transformOrigin: 'center center',
}} }}
> >
<div <div
@@ -1615,7 +1628,13 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
} }
}} }}
alt="" 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) => { onLoad={(e) => {
const img = e.currentTarget; const img = e.currentTarget;
img.style.opacity = '1'; img.style.opacity = '1';
@@ -1624,7 +1643,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}} }}
/> />
</div> </div>
))} );
})}
</div> </div>
)} )}
{resizeFloatElements && resizeFloatElements.length > 0 && ( {resizeFloatElements && resizeFloatElements.length > 0 && (
@@ -1632,27 +1652,34 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
className="absolute inset-0 pointer-events-none" className="absolute inset-0 pointer-events-none"
style={{ overflow: 'visible' }} style={{ overflow: 'visible' }}
> >
{resizeFloatElements.map((el) => ( {resizeFloatElements.map((el) => {
<React.Fragment key={el.id}> 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 (
<div <div
className="absolute pointer-events-none border border-dashed border-[#31a8ff]/40 resize-float-placeholder" key={el.id}
className="absolute pointer-events-none"
style={{ style={{
left: `${mmToPx(el.x, displayZoom)}px`, left: `${left}px`,
top: `${mmToPx(el.y, displayZoom)}px`, top: `${top}px`,
width: `${mmToPx(el.width, displayZoom)}px`, width: `${width}px`,
height: `${mmToPx(el.height, displayZoom)}px`, height: `${height}px`,
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined, transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
transformOrigin: 'center center', transformOrigin: 'center center',
}} }}
/> >
<ElementFloatPreview <ElementFloatPreview
element={el} element={el}
renderScale={canvasRenderScale} renderScale={canvasRenderScale}
displayZoom={displayZoom} displayZoom={displayZoom}
variableDefaults={previewDefaults} variableDefaults={previewDefaults}
/> />
</React.Fragment> <div className="absolute inset-0 ps-resize-outline z-[2]" aria-hidden />
))} </div>
);
})}
</div> </div>
)} )}
</div>, </div>,
@@ -1825,6 +1852,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (isBeingDragged) { if (isBeingDragged) {
return null; return null;
} }
if (isBeingResized && interactionCanvasReady && resizingPreviewElement) {
return null;
}
const overlayElement = const overlayElement =
isBeingResized && resizingPreviewElement ? resizingPreviewElement : element; isBeingResized && resizingPreviewElement ? resizingPreviewElement : element;
@@ -1884,6 +1914,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
</div> </div>
)} )}
{isBeingResized && (
<div className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]" aria-hidden />
)}
{showResizeHandles && ( {showResizeHandles && (
<> <>
<div <div

View File

@@ -8,6 +8,7 @@ import {
removeUnusedTemplateVariable, removeUnusedTemplateVariable,
resolveVisibilityRules, resolveVisibilityRules,
toggleVisibilityRuleEnabled, toggleVisibilityRuleEnabled,
withContentAndSyncedName,
} from '../utils'; } from '../utils';
import { VisibilityRuleDialog } from './VisibilityRuleDialog'; import { VisibilityRuleDialog } from './VisibilityRuleDialog';
import { VisibilityRuleSummary } from './VisibilityRuleSummary'; import { VisibilityRuleSummary } from './VisibilityRuleSummary';
@@ -55,7 +56,6 @@ import {
Layers, Layers,
Trash2, Trash2,
Copy, Copy,
ClipboardPaste,
AlignHorizontalJustifyStart, AlignHorizontalJustifyStart,
AlignHorizontalJustifyCenter, AlignHorizontalJustifyCenter,
AlignHorizontalJustifyEnd, AlignHorizontalJustifyEnd,
@@ -209,6 +209,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
elementClipboard, elementClipboard,
}) => { }) => {
const { showConfirm } = useAppDialog(); const { showConfirm } = useAppDialog();
const templateRef = useRef(template);
templateRef.current = template;
const isNarrowScreen = useIsNarrowScreen(); const isNarrowScreen = useIsNarrowScreen();
const selectedElementId = selectedElementIds[0] || null; const selectedElementId = selectedElementIds[0] || null;
const selectedElem = template.elements.find((el) => el.id === selectedElementId); const selectedElem = template.elements.find((el) => el.id === selectedElementId);
@@ -278,9 +280,10 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
const maxLabelH = Math.max(10, paperH - paper.marginTop - paper.marginBottom); const maxLabelH = Math.max(10, paperH - paper.marginTop - paper.marginBottom);
const handleElemChange = (updatedElem: TemplateElement) => { const handleElemChange = (updatedElem: TemplateElement) => {
const current = templateRef.current;
onChangeTemplate({ onChangeTemplate({
...template, ...current,
elements: template.elements.map((el) => (el.id === updatedElem.id ? updatedElem : el)), elements: current.elements.map((el) => (el.id === updatedElem.id ? updatedElem : el)),
}); });
}; };
@@ -292,6 +295,11 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
} }
}; };
const handleContentChange = (content: string) => {
if (!selectedElem || selectedElem.type === 'table') return;
handleElemChange(withContentAndSyncedName(selectedElem, content));
};
const nudgeableElementIds = useMemo(() => { const nudgeableElementIds = useMemo(() => {
const idSet = new Set(selectedElementIds); const idSet = new Set(selectedElementIds);
return template.elements return template.elements
@@ -424,7 +432,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
: selectedElem.content.trim() : selectedElem.content.trim()
? `${selectedElem.content}${selectedElem.content.endsWith(' ') ? '' : ' '}${token}` ? `${selectedElem.content}${selectedElem.content.endsWith(' ') ? '' : ' '}${token}`
: token; : token;
handleElemChange({ ...selectedElem, content }); handleContentChange(content);
}; };
const handleAddTemplateVariable = () => { const handleAddTemplateVariable = () => {
@@ -565,6 +573,9 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
}; };
const toggleLockLayer = (idx: number) => { const toggleLockLayer = (idx: number) => {
const target = template.elements[idx];
if (!target) return;
const willLock = !target.locked;
const list = template.elements.map((el, i) => { const list = template.elements.map((el, i) => {
if (i === idx) { if (i === idx) {
return { ...el, locked: !el.locked }; return { ...el, locked: !el.locked };
@@ -572,6 +583,12 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
return el; return el;
}); });
onChangeTemplate({ ...template, elements: list }); onChangeTemplate({ ...template, elements: list });
if (willLock && onSelectElements) {
onSelectElements(selectedElementIds.filter((id) => id !== target.id));
if (target.type === 'table') {
onSelectTableCells?.([]);
}
}
}; };
const deleteLayer = async (id: string) => { const deleteLayer = async (id: string) => {
@@ -589,8 +606,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
if (!confirmed) return; if (!confirmed) return;
onChangeTemplate({ onChangeTemplate({
...template, ...templateRef.current,
elements: template.elements.filter((item) => item.id !== id), elements: templateRef.current.elements.filter((item) => item.id !== id),
}); });
if (onSelectElements && selectedElementIds.includes(id)) { if (onSelectElements && selectedElementIds.includes(id)) {
onSelectElements(selectedElementIds.filter((selectedId) => selectedId !== id)); onSelectElements(selectedElementIds.filter((selectedId) => selectedId !== id));
@@ -621,9 +638,10 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
}); });
if (!confirmed) return; if (!confirmed) return;
const current = templateRef.current;
const idSet = new Set(deletableSelectedIds); const idSet = new Set(deletableSelectedIds);
const remaining = template.elements.filter((el) => !idSet.has(el.id)); const remaining = current.elements.filter((el) => !idSet.has(el.id));
onChangeTemplate({ ...template, elements: remaining }); onChangeTemplate({ ...current, elements: remaining });
onSelectElements?.(selectedElementIds.filter((id) => remaining.some((el) => el.id === id))); onSelectElements?.(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
}; };
@@ -656,8 +674,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => reader.onload = () => handleContentChange(reader.result as string);
handleElemChange({ ...selectedElem, content: reader.result as string });
reader.readAsDataURL(file); reader.readAsDataURL(file);
e.target.value = ''; e.target.value = '';
}} }}
@@ -672,7 +689,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<button <button
type="button" type="button"
disabled={!selectedElem.content} disabled={!selectedElem.content}
onClick={() => handleElemChange({ ...selectedElem, content: '' })} onClick={() => handleContentChange('')}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0" className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
> >
@@ -680,7 +697,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div> </div>
<PropTextarea <PropTextarea
value={selectedElem.content} value={selectedElem.content}
onCommit={(val) => handleElemChange({ ...selectedElem, content: val })} onCommit={(val) => handleContentChange(val)}
rows={selectedElem.type === 'image' ? 3 : 2} rows={selectedElem.type === 'image' ? 3 : 2}
className="ps-field resize-none" className="ps-field resize-none"
placeholder={ placeholder={
@@ -1123,7 +1140,13 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<PropInput <PropInput
type="text" type="text"
value={selectedElem.name} value={selectedElem.name}
onCommit={(val) => handleElemChange({ ...selectedElem, name: val || '未命名元素' })} onCommit={(val) =>
handleElemChange({
...selectedElem,
name: val || '未命名元素',
nameCustomized: true,
})
}
className="ps-field mt-2" className="ps-field mt-2"
/> />
</div> </div>
@@ -2146,12 +2169,12 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
}`} }`}
> >
<div className="flex items-center gap-2 min-w-0 flex-1 pointer-events-none"> <div className="flex items-center gap-2 min-w-0 flex-1 pointer-events-none">
<span className="shrink-0 w-3.5 flex items-center justify-center"> <span className="ps-layer-item-icon">
{elem.type === 'text' && <Type className="w-3 h-3 text-[#666]" />} {elem.type === 'text' && <Type />}
{elem.type === 'barcode' && <Barcode className="w-3 h-3 text-[#666]" />} {elem.type === 'barcode' && <Barcode />}
{elem.type === 'qrcode' && <QrCode className="w-3 h-3 text-[#666]" />} {elem.type === 'qrcode' && <QrCode />}
{elem.type === 'image' && <ImageIcon className="w-3 h-3 text-[#666]" />} {elem.type === 'image' && <ImageIcon />}
{elem.type === 'table' && <Table2 className="w-3 h-3 text-[#666]" />} {elem.type === 'table' && <Table2 />}
</span> </span>
<span className="truncate" title={elem.name}> <span className="truncate" title={elem.name}>
{elem.name} {elem.name}
@@ -2159,7 +2182,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div> </div>
<div <div
className={`flex items-center gap-0.5 shrink-0 pointer-events-auto ${isNarrowScreen || variant === 'mobile' || isLocked className={`ps-layer-item-actions pointer-events-auto ${isNarrowScreen || variant === 'mobile' || isLocked
? 'opacity-100' ? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 transition-opacity' : 'opacity-0 group-hover:opacity-100 transition-opacity'
}`} }`}
@@ -2171,18 +2194,16 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'up'); }} onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'up'); }}
disabled={actualIdx === template.elements.length - 1} disabled={actualIdx === template.elements.length - 1}
title="上移" title="上移"
className="p-0.5 hover:bg-[#555] disabled:opacity-20 cursor-pointer text-[#aaa]"
> >
<ArrowUp className="w-3 h-3" /> <ArrowUp />
</button> </button>
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'down'); }} onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'down'); }}
disabled={actualIdx === 0} disabled={actualIdx === 0}
title="下移" title="下移"
className="p-0.5 hover:bg-[#555] disabled:opacity-20 cursor-pointer text-[#aaa]"
> >
<ArrowDown className="w-3 h-3" /> <ArrowDown />
</button> </button>
</> </>
)} )}
@@ -2190,18 +2211,18 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
type="button" type="button"
onClick={(e) => { e.stopPropagation(); toggleLockLayer(actualIdx); }} onClick={(e) => { e.stopPropagation(); toggleLockLayer(actualIdx); }}
title={isLocked ? '解锁' : '锁定'} 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 ? <Lock className="w-3 h-3" /> : <Unlock className="w-3 h-3" />} {isLocked ? <Lock /> : <Unlock />}
</button> </button>
{!isLocked && ( {!isLocked && (
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); void deleteLayer(elem.id); }} onClick={(e) => { e.stopPropagation(); void deleteLayer(elem.id); }}
title="删除" title="删除"
className="p-0.5 hover:text-red-400 text-[#666] cursor-pointer" className="hover:text-red-400 text-[#666]"
> >
<Trash2 className="w-3 h-3" /> <Trash2 />
</button> </button>
)} )}
</div> </div>
@@ -2316,25 +2337,13 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</button> </button>
))} ))}
<div className="mobile-design-props-nav-spacer" aria-hidden /> <div className="mobile-design-props-nav-spacer" aria-hidden />
{elementClipboard?.canPaste && (
<button
type="button"
onClick={() => elementClipboard.pasteClipboardElements()}
className="mobile-design-props-nav-btn"
aria-label="粘贴元素"
title={`粘贴 ${elementClipboard.clipboardCount} 个元素`}
>
<ClipboardPaste className="w-4 h-4" />
<span></span>
</button>
)}
{elementClipboard?.canCopy && ( {elementClipboard?.canCopy && (
<button <button
type="button" type="button"
onClick={() => elementClipboard.copySelectedElements()} onClick={() => elementClipboard.copyAndPasteSelectedElements()}
className="mobile-design-props-nav-btn" className="mobile-design-props-nav-btn"
aria-label="复制选中元素" aria-label="复制选中元素"
title="复制选中元素" title="复制选中元素并创建副本"
> >
<Copy className="w-4 h-4" /> <Copy className="w-4 h-4" />
<span></span> <span></span>

View File

@@ -113,9 +113,36 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
type="text" type="text"
value={rule.value ?? ''} value={rule.value ?? ''}
onChange={(e) => onChange({ value: e.target.value })} onChange={(e) => onChange({ value: e.target.value })}
placeholder="比较目标值" placeholder="比较目标值,可使用 {变量名}"
className="ps-field font-mono text-[11px] w-full mt-0.5" className="ps-field font-mono text-[11px] w-full mt-0.5"
/> />
{variableOptions.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{variableOptions.map((v) => (
<button
key={v}
type="button"
onClick={() => {
const token = `{${v}}`;
const current = rule.value ?? '';
onChange({
value: current.includes(token)
? current
: current
? `${current}${current.endsWith(' ') ? '' : ' '}${token}`
: token,
});
}}
className="ps-btn text-[9px] font-mono"
>
{`{${v}}`}
</button>
))}
</div>
)}
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
{`{变量名}`}
</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import type { LabelTemplate, TemplateElement } from '../types'; import type { LabelTemplate, TemplateElement } from '../types';
import type { TableCellCoord } from '../tableUtils'; import type { TableCellCoord } from '../tableUtils';
import { cloneTemplateElementsForPaste } from '../utils'; import { cloneTemplateElementsForPaste } from '../utils';
@@ -21,11 +21,14 @@ export interface UseElementClipboardOptions {
onSelectElements: (ids: string[]) => void; onSelectElements: (ids: string[]) => void;
onSelectTableCells?: (cells: TableCellCoord[]) => void; onSelectTableCells?: (cells: TableCellCoord[]) => void;
onCopySuccess?: (count: number) => void; onCopySuccess?: (count: number) => void;
onDuplicateSuccess?: (count: number) => void;
} }
export interface ElementClipboardActions { export interface ElementClipboardActions {
copySelectedElements: () => void; copySelectedElements: () => void;
pasteClipboardElements: () => void; pasteClipboardElements: () => void;
/** 复制并立即粘贴(移动端快捷复制) */
copyAndPasteSelectedElements: () => void;
canCopy: boolean; canCopy: boolean;
canPaste: boolean; canPaste: boolean;
clipboardCount: number; clipboardCount: number;
@@ -38,7 +41,10 @@ export function useElementClipboard({
onSelectElements, onSelectElements,
onSelectTableCells, onSelectTableCells,
onCopySuccess, onCopySuccess,
onDuplicateSuccess,
}: UseElementClipboardOptions): ElementClipboardActions { }: UseElementClipboardOptions): ElementClipboardActions {
const templateRef = useRef(template);
templateRef.current = template;
const [clipboardCount, setClipboardCount] = useState(getClipboardCount); const [clipboardCount, setClipboardCount] = useState(getClipboardCount);
const syncClipboardCount = useCallback((elements: TemplateElement[] | null) => { const syncClipboardCount = useCallback((elements: TemplateElement[] | null) => {
@@ -51,30 +57,55 @@ export function useElementClipboard({
const copySelectedElements = useCallback(() => { const copySelectedElements = useCallback(() => {
if (selectedElementIds.length === 0) return; if (selectedElementIds.length === 0) return;
const current = templateRef.current;
const idSet = new Set(selectedElementIds); 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; if (toCopy.length === 0) return;
const cloned = JSON.parse(JSON.stringify(toCopy)) as TemplateElement[]; const cloned = JSON.parse(JSON.stringify(toCopy)) as TemplateElement[];
syncClipboardCount(cloned); syncClipboardCount(cloned);
onCopySuccess?.(toCopy.length); onCopySuccess?.(toCopy.length);
}, [selectedElementIds, template.elements, syncClipboardCount, onCopySuccess]); }, [selectedElementIds, syncClipboardCount, onCopySuccess]);
const pasteClipboardElements = useCallback(() => { const pasteClipboardElements = useCallback(() => {
const source = globalElementClipboard.elements; const source = globalElementClipboard.elements;
if (!source || source.length === 0) return; if (!source || source.length === 0) return;
const current = templateRef.current;
const pasted = cloneTemplateElementsForPaste(source, 1); const pasted = cloneTemplateElementsForPaste(source, 1);
onChange({ onChange({
...template, ...current,
elements: [...template.elements, ...pasted], elements: [...current.elements, ...pasted],
}); });
onSelectElements(pasted.map((el) => el.id)); onSelectElements(pasted.map((el) => el.id));
onSelectTableCells?.([]); onSelectTableCells?.([]);
syncClipboardCount(null); 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 { return {
copySelectedElements, copySelectedElements,
pasteClipboardElements, pasteClipboardElements,
copyAndPasteSelectedElements,
canCopy, canCopy,
canPaste, canPaste,
clipboardCount, clipboardCount,

View File

@@ -756,8 +756,10 @@ html.app-dark-shell body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 4px 8px; gap: 8px;
font-size: 11px; min-height: 40px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
border-bottom: 1px solid #222; border-bottom: 1px solid #222;
@@ -783,6 +785,51 @@ html.app-dark-shell body {
background: transparent; 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 { .ps-marquee-select {
position: absolute; position: absolute;
border: 1px solid #31a8ff; border: 1px solid #31a8ff;
@@ -797,6 +844,12 @@ html.app-dark-shell body {
outline-offset: 0; 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 { .ps-unselected-dim-layer {
position: absolute; position: absolute;
inset: 0; inset: 0;

View File

@@ -28,7 +28,7 @@ export interface VisibilityRule {
target?: VisibilityRuleTarget; target?: VisibilityRuleTarget;
variable: string; variable: string;
operator: VisibilityOperator; operator: VisibilityOperator;
/** gt / lt / eq / neq / contains / not_contains 时的比较目标值 */ /** gt / lt / eq / neq / contains / not_contains 时的比较目标值,支持 {变量名} */
value?: string; value?: string;
/** 为 false 时跳过该条件;默认启用 */ /** 为 false 时跳过该条件;默认启用 */
enabled?: boolean; enabled?: boolean;
@@ -91,6 +91,8 @@ export interface TemplateElement {
id: string; id: string;
type: 'text' | 'barcode' | 'qrcode' | 'image' | 'table'; type: 'text' | 'barcode' | 'qrcode' | 'image' | 'table';
name: string; name: string;
/** 用户是否手动修改过图层名称;为 true 时不再根据内容自动同步名称 */
nameCustomized?: boolean;
x: number; // in mm x: number; // in mm
y: number; // in mm y: number; // in mm
width: number; // in mm width: number; // in mm

View File

@@ -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用于复制/粘贴 */ /** 深拷贝元素并生成新 id用于复制/粘贴 */
export function cloneTemplateElementsForPaste( export function cloneTemplateElementsForPaste(
elements: TemplateElement[], elements: TemplateElement[],
@@ -251,8 +315,8 @@ export function getExportRangeIndices(
export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = { export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = {
enabled: true, enabled: true,
style: 'dashed', style: 'dashed',
width: 0.1, width: 0.2,
color: '#cccccc', color: '#333333',
}; };
/** 裁切线有效线宽 (mm),预览与 PDF 共用 */ /** 裁切线有效线宽 (mm),预览与 PDF 共用 */
@@ -469,8 +533,8 @@ export const DEFAULT_PAPER_CONFIG: PaperConfig = {
marginBottom: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM, marginBottom: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
marginLeft: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM, marginLeft: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
marginRight: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM, marginRight: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM,
columnGap: 2, columnGap: 0,
rowGap: 2, rowGap: 0,
flow: 'left-right-top-bottom', flow: 'left-right-top-bottom',
}; };
@@ -571,6 +635,24 @@ export function resolveElementBackgroundFill(
return color; return color;
} }
/** 从显示/过滤条件中收集引用的变量名 */
function collectVariablesFromVisibilityRule(rule: VisibilityRule, vars: Set<string>) {
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<string>) {
if (!rules) return;
for (const rule of rules) {
collectVariablesFromVisibilityRule(rule, vars);
}
}
/** 从元素内容(含表格单元格)提取变量名 */ /** 从元素内容(含表格单元格)提取变量名 */
export function extractVariablesFromElement(elem: TemplateElement): string[] { export function extractVariablesFromElement(elem: TemplateElement): string[] {
const vars = new Set<string>(); const vars = new Set<string>();
@@ -593,32 +675,36 @@ export function extractVariablesFromElement(elem: TemplateElement): string[] {
export function extractTemplateVariables(template: { export function extractTemplateVariables(template: {
variableList?: string[]; variableList?: string[];
elements: TemplateElement[]; elements: TemplateElement[];
dataFilterRules?: VisibilityRule[];
exportImageFilterRules?: VisibilityRule[];
}): string[] { }): string[] {
const vars = new Set<string>(template.variableList ?? []); const vars = new Set<string>(template.variableList ?? []);
for (const elem of template.elements) { for (const elem of template.elements) {
for (const v of extractVariablesFromElement(elem)) { for (const v of extractVariablesFromElement(elem)) {
vars.add(v); vars.add(v);
} }
for (const rule of resolveVisibilityRules(elem)) { collectVariablesFromVisibilityRules(resolveVisibilityRules(elem), vars);
const name = (rule.variable ?? '').trim();
if (name) vars.add(name);
}
} }
collectVariablesFromVisibilityRules(template.dataFilterRules, vars);
collectVariablesFromVisibilityRules(template.exportImageFilterRules, vars);
return Array.from(vars).sort((a, b) => a.localeCompare(b, 'zh-CN')); 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<string>(); const vars = new Set<string>();
for (const elem of template.elements) { for (const elem of template.elements) {
for (const v of extractVariablesFromElement(elem)) { for (const v of extractVariablesFromElement(elem)) {
vars.add(v); vars.add(v);
} }
for (const rule of resolveVisibilityRules(elem)) { collectVariablesFromVisibilityRules(resolveVisibilityRules(elem), vars);
const name = (rule.variable ?? '').trim();
if (name) vars.add(name);
}
} }
collectVariablesFromVisibilityRules(template.dataFilterRules, vars);
collectVariablesFromVisibilityRules(template.exportImageFilterRules, vars);
return Array.from(vars); return Array.from(vars);
} }
@@ -781,6 +867,20 @@ function getVisibilityRuleRawValue(
return getVariableRawValue(rule.variable, row, defaults, mode); return getVariableRawValue(rule.variable, row, defaults, mode);
} }
/** 解析条件目标值(支持 {变量名} */
export function resolveVisibilityRuleCompareTarget(
targetTemplate: string | undefined,
row: RecordRow | null,
defaults: Record<string, string> | 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( export function evaluateVisibilityRule(
rule: VisibilityRule, rule: VisibilityRule,
@@ -792,6 +892,13 @@ export function evaluateVisibilityRule(
): boolean { ): boolean {
const raw = getVisibilityRuleRawValue(rule, elem, row, rowIndex, defaults, mode); const raw = getVisibilityRuleRawValue(rule, elem, row, rowIndex, defaults, mode);
const isEmpty = raw === undefined; const isEmpty = raw === undefined;
const compareTarget = resolveVisibilityRuleCompareTarget(
rule.value,
row,
defaults,
mode,
rowIndex
);
switch (rule.operator) { switch (rule.operator) {
case 'empty': case 'empty':
@@ -802,23 +909,20 @@ export function evaluateVisibilityRule(
case 'lt': case 'lt':
case 'eq': { case 'eq': {
if (isEmpty) return false; if (isEmpty) return false;
return compareVariableToTarget(raw!, (rule.value ?? '').trim(), rule.operator); return compareVariableToTarget(raw!, compareTarget, rule.operator);
} }
case 'neq': { case 'neq': {
const target = (rule.value ?? '').trim(); if (isEmpty) return compareTarget !== '';
if (isEmpty) return target !== ''; return compareVariableToTarget(raw!, compareTarget, 'neq');
return compareVariableToTarget(raw!, target, 'neq');
} }
case 'contains': { case 'contains': {
const target = (rule.value ?? '').trim(); if (!compareTarget || isEmpty) return false;
if (!target || isEmpty) return false; return raw!.includes(compareTarget);
return raw!.includes(target);
} }
case 'not_contains': { case 'not_contains': {
const target = (rule.value ?? '').trim(); if (!compareTarget) return true;
if (!target) return true;
if (isEmpty) return true; if (isEmpty) return true;
return !raw!.includes(target); return !raw!.includes(compareTarget);
} }
default: default:
return true; return true;