Compare commits

...

17 Commits

Author SHA1 Message Date
iqudoo
33d3e5537c 优化 2026-06-23 15:26:51 +08:00
iqudoo
1b04e34ff0 打印 2026-06-23 15:17:29 +08:00
iqudoo
3a50e7e33b fix 2026-06-16 23:41:27 +08:00
iqudoo
43900a8b52 完美的 2026-06-15 17:53:51 +08:00
iqudoo
25fa186d02 优化蒙版 2026-06-15 17:43:01 +08:00
iqudoo
455946c51c 蒙版 2026-06-15 16:59:37 +08:00
iqudoo
fcd587bd50 优化 2026-06-15 16:21:23 +08:00
iqudoo
1d506e0c71 fix 2026-06-15 14:55:54 +08:00
iqudoo
ac117425d7 优化修复 2026-06-15 14:46:30 +08:00
iqudoo
8c9b53b1b6 优化 2026-06-14 18:02:18 +08:00
iqudoo
b4c2cf07d0 修复 2026-06-14 14:03:45 +08:00
iqudoo
2bd89c517c 完美 2026-06-14 12:07:49 +08:00
iqudoo
4955b1f2e2 优化布局和交互 2026-06-14 12:03:05 +08:00
iqudoo
55867ae12d 修复 2026-06-14 10:37:57 +08:00
iqudoo
846061ab48 fix 2026-06-14 10:25:15 +08:00
iqudoo
ccb5b119de 数据 2026-06-14 04:12:21 +08:00
iqudoo
1e03b7750a 导出 2026-06-14 03:59:45 +08:00
43 changed files with 5470 additions and 815 deletions

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" /> <link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#323232" />
<title>标签设计生成器</title> <title>标签设计生成器</title>
</head> </head>
<body> <body>

100
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"express": "^4.21.2", "express": "^4.21.2",
"jsbarcode": "^3.12.3", "jsbarcode": "^3.12.3",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jszip": "^3.10.1",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -2082,6 +2083,12 @@
"url": "https://opencollective.com/core-js" "url": "https://opencollective.com/core-js"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/crc-32": { "node_modules/crc-32": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -2836,6 +2843,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -2866,6 +2879,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2937,6 +2956,24 @@
"html2canvas": "^1.0.0-rc.5" "html2canvas": "^1.0.0-rc.5"
} }
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/jwa": { "node_modules/jwa": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -2958,6 +2995,15 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -3614,6 +3660,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/protobufjs": { "node_modules/protobufjs": {
"version": "7.6.3", "version": "7.6.3",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz",
@@ -3747,6 +3799,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/regenerator-runtime": { "node_modules/regenerator-runtime": {
"version": "0.13.11", "version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
@@ -3933,6 +4006,12 @@
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -4051,6 +4130,21 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -4696,6 +4790,12 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",

View File

@@ -18,6 +18,7 @@
"express": "^4.21.2", "express": "^4.21.2",
"jsbarcode": "^3.12.3", "jsbarcode": "^3.12.3",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"jszip": "^3.10.1",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",

View File

@@ -1,5 +1,5 @@
import React, { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; import React, { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { LabelTemplate, PaperConfig, ImportData, RecordRow, ExportRangeConfig, CutLineConfig } from './types'; import { LabelTemplate, PaperConfig, ImportData, RecordRow, ExportRangeConfig, CutLineConfig, ElementVisibilityMode, VisibilityRule } from './types';
import { import {
stripTemplateForStorage, stripTemplateForStorage,
normalizeTemplate, normalizeTemplate,
@@ -11,6 +11,7 @@ import {
extractTemplateVariables, extractTemplateVariables,
buildAutoVariableMapping, buildAutoVariableMapping,
applyRowVariableMapping, applyRowVariableMapping,
filterLabelsForImageExport,
type PrintableLabel, type PrintableLabel,
} from './utils'; } from './utils';
import { LabelDesigner } from './components/LabelDesigner'; import { LabelDesigner } from './components/LabelDesigner';
@@ -20,9 +21,14 @@ import { MobileExportView } from './components/MobileExportView';
import { MobileDesignView } from './components/MobileDesignView'; import { MobileDesignView } from './components/MobileDesignView';
import type { TableCellCoord } from './tableUtils'; import type { TableCellCoord } from './tableUtils';
import { LayoutPreview } from './components/PreviewGrid'; import { LayoutPreview } from './components/PreviewGrid';
import { LabelLayoutSizeCalculator } from './components/LabelLayoutSizeCalculator';
import { CanvasLabelImage } from './components/CanvasLabelImage'; import { CanvasLabelImage } from './components/CanvasLabelImage';
import { renderLabelToDataUrl } from './labelRenderer'; import { renderLabelToDataUrl, resolveReferenceBackgroundForOutput, resolveReferenceBackgroundForPreview } from './labelRenderer';
import { exportLabelsPdfAsync, exportLabelsPdfBlobAsync, type PdfExportProgress } from './pdfExport'; import { exportLabelsPdfAsync, exportLabelsPdfBlobAsync, type PdfExportProgress } from './pdfExport';
import {
exportLabelsImagesZipAsync,
type LabelImagesExportProgress,
} from './labelImagesExport';
import { import {
getAvailablePrinters, getAvailablePrinters,
saveSelectedPrinterId, saveSelectedPrinterId,
@@ -30,6 +36,7 @@ import {
} from './labelPrint'; } from './labelPrint';
import { useIsMobile } from './hooks/useIsMobile'; import { useIsMobile } from './hooks/useIsMobile';
import { useUndoRedo } from './hooks/useUndoRedo'; import { useUndoRedo } from './hooks/useUndoRedo';
import { useElementClipboard } from './hooks/useElementClipboard';
import { useAppDialog } from './components/AppDialog'; import { useAppDialog } from './components/AppDialog';
import { AnchoredMenu } from './components/AnchoredMenu'; import { AnchoredMenu } from './components/AnchoredMenu';
import { import {
@@ -73,7 +80,7 @@ function fitTemplatePreviewSize(widthMm: number, heightMm: number) {
} }
export default function App() { export default function App() {
const { showAlert, showConfirm } = useAppDialog(); const { showAlert, showConfirm, showToast } = useAppDialog();
// --- 1. Templates Storage & Initial States --- // --- 1. Templates Storage & Initial States ---
const [templates, setTemplates] = useState<LabelTemplate[]>(() => { const [templates, setTemplates] = useState<LabelTemplate[]>(() => {
@@ -142,6 +149,7 @@ export default function App() {
const [pdfGenerating, setPdfGenerating] = useState(false); const [pdfGenerating, setPdfGenerating] = useState(false);
const [printing, setPrinting] = useState(false); const [printing, setPrinting] = useState(false);
const [imagesZipGenerating, setImagesZipGenerating] = useState(false);
const [pdfProgress, setPdfProgress] = useState<PdfExportProgress>({ const [pdfProgress, setPdfProgress] = useState<PdfExportProgress>({
done: 0, done: 0,
total: 0, total: 0,
@@ -149,6 +157,11 @@ export default function App() {
totalPages: 0, totalPages: 0,
phase: 'render', phase: 'render',
}); });
const [imagesZipProgress, setImagesZipProgress] = useState<LabelImagesExportProgress>({
done: 0,
total: 0,
phase: 'render',
});
// --- 5. Selection and Active item states --- // --- 5. Selection and Active item states ---
const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]); const [selectedElementIds, setSelectedElementIds] = useState<string[]>([]);
@@ -157,8 +170,9 @@ export default function App() {
// Modal / Inline input values for template creations // Modal / Inline input values for template creations
const [showAddTmplForm, setShowAddTmplForm] = useState<boolean>(false); const [showAddTmplForm, setShowAddTmplForm] = useState<boolean>(false);
const [newTmplName, setNewTmplName] = useState<string>(''); const [newTmplName, setNewTmplName] = useState<string>('');
const [newTmplW, setNewTmplW] = useState<number>(75); const [newTmplW, setNewTmplW] = useState('75');
const [newTmplH, setNewTmplH] = useState<number>(45); const [newTmplH, setNewTmplH] = useState('45');
const [newTmplPaper, setNewTmplPaper] = useState<PaperConfig | null>(null);
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null); const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
const [editTmplName, setEditTmplName] = useState<string>(''); const [editTmplName, setEditTmplName] = useState<string>('');
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null); const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
@@ -186,25 +200,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(() => {
@@ -238,6 +262,30 @@ export default function App() {
[activeTemplate] [activeTemplate]
); );
const handleCopyElementsSuccess = useCallback(
(count: number) => {
showToast(`已复制 ${count} 个元素`, { variant: 'success' });
},
[showToast]
);
const handleDuplicateElementsSuccess = useCallback(
(count: number) => {
showToast(`已创建 ${count} 个副本`, { variant: 'success' });
},
[showToast]
);
const elementClipboard = useElementClipboard({
template: activeTemplate ?? EMPTY_TEMPLATE_PLACEHOLDER,
onChange: handleDesignTemplateChange,
selectedElementIds,
onSelectElements: handleSelectElements,
onSelectTableCells: setSelectedTableCells,
onCopySuccess: handleCopyElementsSuccess,
onDuplicateSuccess: handleDuplicateElementsSuccess,
});
useEffect(() => { useEffect(() => {
if (workflowStep !== 'template-design' || !activeTemplate?.id) return; if (workflowStep !== 'template-design' || !activeTemplate?.id) return;
const needReset = const needReset =
@@ -359,31 +407,24 @@ export default function App() {
await showAlert('请先输入合法的模板名', { variant: 'warning' }); await showAlert('请先输入合法的模板名', { variant: 'warning' });
return; return;
} }
const width = Number(newTmplW);
const height = Number(newTmplH);
if (!newTmplW.trim() || Number.isNaN(width) || width < 10) {
await showAlert('请输入合法的标签宽度(>10 mm', { variant: 'warning' });
return;
}
if (!newTmplH.trim() || Number.isNaN(height) || height < 10) {
await showAlert('请输入合法的标签高度(>10 mm', { variant: 'warning' });
return;
}
const newId = `custom_tmpl_${Date.now()}`; const newId = `custom_tmpl_${Date.now()}`;
const newTmpl: LabelTemplate = { const newTmpl: LabelTemplate = {
id: newId, id: newId,
name: `${newTmplName}`, name: `${newTmplName}`,
width: Math.max(10, Number(newTmplW) || 70), width,
height: Math.max(10, Number(newTmplH) || 40), height,
elements: [ elements: [],
{ paper: newTmplPaper ?? DEFAULT_PAPER_CONFIG,
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,
cutLine: DEFAULT_CUT_LINE_CONFIG, cutLine: DEFAULT_CUT_LINE_CONFIG,
}; };
@@ -391,6 +432,7 @@ export default function App() {
saveTemplatesToStorage(list); saveTemplatesToStorage(list);
handleSelectTemplate(newId); handleSelectTemplate(newId);
setNewTmplName(''); setNewTmplName('');
setNewTmplPaper(null);
setShowAddTmplForm(false); setShowAddTmplForm(false);
setWorkflowStep('template-design'); setWorkflowStep('template-design');
}; };
@@ -398,8 +440,9 @@ export default function App() {
const closeAddTemplateDialog = () => { const closeAddTemplateDialog = () => {
setShowAddTmplForm(false); setShowAddTmplForm(false);
setNewTmplName(''); setNewTmplName('');
setNewTmplW(75); setNewTmplW('75');
setNewTmplH(45); setNewTmplH('45');
setNewTmplPaper(null);
}; };
const openEditTemplateDialog = (tmpl: LabelTemplate) => { const openEditTemplateDialog = (tmpl: LabelTemplate) => {
@@ -533,7 +576,14 @@ export default function App() {
); );
const layoutResult = useMemo( const layoutResult = useMemo(
() => buildPrintablePages(mappedRows, paper, workingTemplate, draftExportRange), () =>
buildPrintablePages(
mappedRows,
paper,
workingTemplate,
draftExportRange,
workingTemplate.variableDefaults ?? null
),
[mappedRows, paper, workingTemplate, draftExportRange] [mappedRows, paper, workingTemplate, draftExportRange]
); );
@@ -567,7 +617,14 @@ export default function App() {
const appliedExportRange = appliedExportData?.exportRange ?? DEFAULT_EXPORT_RANGE; const appliedExportRange = appliedExportData?.exportRange ?? DEFAULT_EXPORT_RANGE;
const appliedLayoutResult = useMemo( const appliedLayoutResult = useMemo(
() => buildPrintablePages(appliedMappedRows, paper, workingTemplate, appliedExportRange), () =>
buildPrintablePages(
appliedMappedRows,
paper,
workingTemplate,
appliedExportRange,
workingTemplate.variableDefaults ?? null
),
[appliedMappedRows, paper, workingTemplate, appliedExportRange] [appliedMappedRows, paper, workingTemplate, appliedExportRange]
); );
@@ -678,7 +735,7 @@ export default function App() {
if (previewSnapshot !== null && appliedExportData !== null) { if (previewSnapshot !== null && appliedExportData !== null) {
setPreviewLayoutStale(true); setPreviewLayoutStale(true);
} }
}, [workingTemplate.elements, workingTemplate.width, workingTemplate.height]); }, [workingTemplate.elements, workingTemplate.width, workingTemplate.height, workingTemplate.dataFilterRules]);
useEffect(() => { useEffect(() => {
if (workflowStep !== 'print-view') return; if (workflowStep !== 'print-view') return;
@@ -688,7 +745,8 @@ export default function App() {
appliedMappedRows, appliedMappedRows,
paper, paper,
workingTemplate, workingTemplate,
appliedExportData.exportRange appliedExportData.exportRange,
workingTemplate.variableDefaults ?? null
); );
setPreviewSnapshot({ setPreviewSnapshot({
@@ -708,16 +766,70 @@ export default function App() {
appliedMappedRows, appliedMappedRows,
workingTemplate.width, workingTemplate.width,
workingTemplate.height, workingTemplate.height,
workingTemplate.dataFilterRules,
]); ]);
const pdfExportImage = useMemo(() => { const pdfExportImage = useMemo(() => {
const hasCode = workingTemplate.elements.some( const printMode = workingTemplate.paper?.printColorMode ?? 'cmyk';
(e) => e.type === 'barcode' || e.type === 'qrcode' return {
); imageFormat: 'png' as const,
return hasCode jsPdfFormat: 'PNG' as const,
? { imageFormat: 'png' as const, jsPdfFormat: 'PNG' as const, scale: 10 } scale: printMode === 'cmyk' ? 14 : 12,
: { imageFormat: 'jpeg' as const, jsPdfFormat: 'JPEG' as const, scale: 10 }; jpegQuality: 0.95,
}, [workingTemplate.elements]); };
}, [workingTemplate.paper?.printColorMode]);
const handleExportImageFileNamePatternChange = (pattern: string) => {
if (!activeTemplate) return;
handleTemplateChange({
...activeTemplate,
exportImageFileNamePattern: pattern.trim() || undefined,
});
};
const handleExportImageFilterModeChange = (mode: ElementVisibilityMode) => {
if (!activeTemplate) return;
handleTemplateChange({
...activeTemplate,
exportImageFilterMode: mode === 'always' ? undefined : 'conditional',
});
};
const handleExportImageFilterRulesChange = (rules: VisibilityRule[]) => {
if (!activeTemplate) return;
handleTemplateChange({
...activeTemplate,
exportImageFilterMode: 'conditional',
exportImageFilterRules: rules,
});
};
const handleDataFilterRulesChange = (rules: VisibilityRule[]) => {
if (!activeTemplate) return;
handleTemplateChange({
...activeTemplate,
dataFilterRules: rules.length > 0 ? rules : undefined,
});
};
const renderAppliedLabelToDataUrl = useCallback(
(label: PrintableLabel) =>
renderLabelToDataUrl({
widthMm: activeTemplate!.width,
heightMm: activeTemplate!.height,
elements: activeTemplate!.elements,
rowData: label.row,
rowIndex: label.sourceIndex,
showBorder: false,
mode: 'output',
scale: pdfExportImage.scale,
imageFormat: pdfExportImage.imageFormat,
jpegQuality: pdfExportImage.jpegQuality,
printColorMode: activeTemplate!.paper?.printColorMode ?? 'cmyk',
...resolveReferenceBackgroundForOutput(activeTemplate!),
}),
[activeTemplate, pdfExportImage]
);
const exportPdf = async () => { const exportPdf = async () => {
if (!activeTemplate) return; if (!activeTemplate) return;
@@ -754,19 +866,7 @@ export default function App() {
cutLine, cutLine,
filename: `${safeName}_${exportTotalPages}页.pdf`, filename: `${safeName}_${exportTotalPages}页.pdf`,
imageFormat: pdfExportImage.jsPdfFormat, imageFormat: pdfExportImage.jsPdfFormat,
renderLabel: (label) => renderLabel: renderAppliedLabelToDataUrl,
renderLabelToDataUrl({
widthMm: activeTemplate.width,
heightMm: activeTemplate.height,
elements: activeTemplate.elements,
rowData: label.row,
rowIndex: label.sourceIndex,
showBorder: false,
mode: 'output',
scale: pdfExportImage.scale,
imageFormat: pdfExportImage.imageFormat,
jpegQuality: 0.9,
}),
onProgress: (progress) => { onProgress: (progress) => {
const now = Date.now(); const now = Date.now();
const isDone = progress.done >= progress.total || progress.phase === 'save'; const isDone = progress.done >= progress.total || progress.phase === 'save';
@@ -784,6 +884,70 @@ export default function App() {
} }
}; };
const exportLabelsImagesZip = async () => {
if (!activeTemplate) return;
if (!appliedExportData) {
await showAlert('请先应用数据后再导出图片。', { variant: 'warning' });
return;
}
if (appliedLayoutResult.selectedCount === 0) {
await showAlert('当前数据范围内没有可排版的标签,请调整范围设置。', { variant: 'warning' });
return;
}
const exportableLabels = filterLabelsForImageExport(
appliedLayoutResult.pages,
activeTemplate,
activeTemplate.variableDefaults ?? null
);
if (exportableLabels.length === 0) {
await showAlert('没有符合导出过滤条件的标签。', { variant: 'warning' });
return;
}
const labelTotal = exportableLabels.length;
const safeName =
activeTemplate.name.replace(/[^\w\u4e00-\u9fa5-]+/g, '_').slice(0, 40) || 'labels';
const progressLastUpdate = { current: 0 };
setImagesZipGenerating(true);
setImagesZipProgress({
done: 0,
total: labelTotal,
phase: 'render',
});
try {
await exportLabelsImagesZipAsync({
printablePages: appliedLayoutResult.pages,
filename: `${safeName}_${labelTotal}枚_${Date.now()}.zip`,
imageExtension: pdfExportImage.imageFormat === 'jpeg' ? 'jpg' : 'png',
fileNamePattern: activeTemplate.exportImageFileNamePattern,
variableDefaults: activeTemplate.variableDefaults ?? null,
exportFilter: {
exportImageFilterMode: activeTemplate.exportImageFilterMode,
exportImageFilterRules: activeTemplate.exportImageFilterRules,
},
renderLabel: renderAppliedLabelToDataUrl,
onProgress: (progress) => {
const now = Date.now();
const isDone =
progress.phase === 'save' ||
(progress.phase === 'render' && progress.done >= progress.total);
if (isDone || now - progressLastUpdate.current >= 150) {
progressLastUpdate.current = now;
setImagesZipProgress(progress);
}
},
});
} catch (e) {
console.error('Images zip export failed', e);
await showAlert('图片导出失败,请重试。', { variant: 'error' });
} finally {
setImagesZipGenerating(false);
}
};
const printLabels = async (printerId: string) => { const printLabels = async (printerId: string) => {
if (!activeTemplate) return; if (!activeTemplate) return;
if (!appliedExportData) { if (!appliedExportData) {
@@ -807,19 +971,7 @@ export default function App() {
gridCols: appliedLayoutResult.gridCols, gridCols: appliedLayoutResult.gridCols,
cutLine, cutLine,
imageFormat: pdfExportImage.jsPdfFormat, imageFormat: pdfExportImage.jsPdfFormat,
renderLabel: (label) => renderLabel: renderAppliedLabelToDataUrl,
renderLabelToDataUrl({
widthMm: activeTemplate.width,
heightMm: activeTemplate.height,
elements: activeTemplate.elements,
rowData: label.row,
rowIndex: label.sourceIndex,
showBorder: false,
mode: 'output',
scale: pdfExportImage.scale,
imageFormat: pdfExportImage.imageFormat,
jpegQuality: 0.9,
}),
}); });
const mode = await submitLabelsPrint(blob, printerId, printers); const mode = await submitLabelsPrint(blob, printerId, printers);
@@ -834,23 +986,24 @@ export default function App() {
} }
}; };
const printBlockedReason = const isDarkWorkspace =
!appliedExportData workflowStep === 'template-design' || workflowStep === 'print-view';
? '请先应用数据' const isMobileDarkShell = isMobile && isDarkWorkspace;
: appliedLayoutResult.selectedCount === 0
? '当前数据范围内无标签' useEffect(() => {
: previewLayoutStale document.documentElement.classList.toggle('app-dark-shell', isMobileDarkShell);
? '排版已变更,请重新绘制预览' return () => {
: null; document.documentElement.classList.remove('app-dark-shell');
};
}, [isMobileDarkShell]);
return ( return (
<div <div
className={`flex flex-col font-sans h-dvh max-h-dvh overflow-hidden ${workflowStep === 'template-design' || (workflowStep === 'print-view' && !isMobile) className={`flex flex-col font-sans h-dvh min-h-dvh max-h-dvh overflow-hidden ${
? 'bg-[#323232] text-[#e8e8e8]' isDarkWorkspace
: workflowStep === 'print-view' && isMobile
? 'bg-[#323232] text-[#e8e8e8]' ? 'bg-[#323232] text-[#e8e8e8]'
: 'bg-slate-50 text-slate-800' : 'bg-slate-50 text-slate-800'
}`} } ${isMobileDarkShell ? 'pb-[env(safe-area-inset-bottom)]' : ''}`}
> >
{/* {/*
========================================= =========================================
@@ -1084,6 +1237,7 @@ export default function App() {
rowIndex={0} rowIndex={0}
showBorder={true} showBorder={true}
variableDefaults={tmpl.variableDefaults ?? null} variableDefaults={tmpl.variableDefaults ?? null}
{...resolveReferenceBackgroundForPreview(tmpl)}
/> />
</div> </div>
</div> </div>
@@ -1223,6 +1377,7 @@ export default function App() {
onRedo={handleDesignRedo} onRedo={handleDesignRedo}
onBack={() => setWorkflowStep('template-select')} onBack={() => setWorkflowStep('template-select')}
onExport={() => openPrintView()} onExport={() => openPrintView()}
elementClipboard={elementClipboard}
/> />
)} )}
@@ -1265,7 +1420,7 @@ export default function App() {
</button> </button>
</div> </div>
<span className="hidden md:inline text-[10px] text-[#666]"> <span className="hidden md:inline text-[10px] text-[#666]">
Shift/Ctrl+ Shift/Ctrl+ · Ctrl+C/V
</span> </span>
<button <button
onClick={() => openPrintView()} onClick={() => openPrintView()}
@@ -1285,6 +1440,7 @@ export default function App() {
onSelectElements={handleSelectElements} onSelectElements={handleSelectElements}
selectedTableCells={selectedTableCells} selectedTableCells={selectedTableCells}
onSelectTableCells={setSelectedTableCells} onSelectTableCells={setSelectedTableCells}
elementClipboard={elementClipboard}
/> />
<PropSidebar <PropSidebar
template={activeTemplate} template={activeTemplate}
@@ -1296,6 +1452,7 @@ export default function App() {
activeTab="element" activeTab="element"
onChangeTab={() => { }} onChangeTab={() => { }}
paper={paper} paper={paper}
elementClipboard={elementClipboard}
/> />
</div> </div>
</div> </div>
@@ -1307,6 +1464,7 @@ export default function App() {
{workflowStep === 'print-view' && isMobile && hasTemplates && activeTemplate && ( {workflowStep === 'print-view' && isMobile && hasTemplates && activeTemplate && (
<MobileExportView <MobileExportView
template={activeTemplate} template={activeTemplate}
onChangeTemplate={handleTemplateChange}
templateName={activeTemplate.name ?? '标签'} templateName={activeTemplate.name ?? '标签'}
templateVariables={templateVariables} templateVariables={templateVariables}
importData={importData} importData={importData}
@@ -1320,6 +1478,7 @@ export default function App() {
rows={mappedRows} rows={mappedRows}
draftExportRange={draftExportRange} draftExportRange={draftExportRange}
onChangeDraftExportRange={setDraftExportRange} onChangeDraftExportRange={setDraftExportRange}
onChangeDataFilterRules={handleDataFilterRulesChange}
cutLine={cutLine} cutLine={cutLine}
onChangeCutLine={handleCutLineChange} onChangeCutLine={handleCutLineChange}
printablePages={previewSnapshot?.pages ?? []} printablePages={previewSnapshot?.pages ?? []}
@@ -1335,6 +1494,7 @@ export default function App() {
activeRowIndex={activeRowIndex} activeRowIndex={activeRowIndex}
onSelectRowIndex={setActiveRowIndex} onSelectRowIndex={setActiveRowIndex}
pdfGenerating={pdfGenerating} pdfGenerating={pdfGenerating}
imagesZipGenerating={imagesZipGenerating}
printing={printing} printing={printing}
onBack={() => setWorkflowStep('template-select')} onBack={() => setWorkflowStep('template-select')}
onExportPdf={exportPdf} onExportPdf={exportPdf}
@@ -1344,9 +1504,14 @@ export default function App() {
appliedLayoutResult.selectedCount === 0 || appliedLayoutResult.selectedCount === 0 ||
previewLayoutStale || previewLayoutStale ||
printing || printing ||
pdfGenerating pdfGenerating ||
imagesZipGenerating
} }
printDisabledReason={printBlockedReason} onExportImages={exportLabelsImagesZip}
imagesExporting={imagesZipGenerating}
onChangeExportImageFileNamePattern={handleExportImageFileNamePatternChange}
onChangeExportImageFilterMode={handleExportImageFilterModeChange}
onChangeExportImageFilterRules={handleExportImageFilterRulesChange}
/> />
)} )}
@@ -1382,9 +1547,12 @@ export default function App() {
type="button" type="button"
onClick={exportPdf} onClick={exportPdf}
disabled={ disabled={
!appliedExportData || appliedLayoutResult.selectedCount === 0 || pdfGenerating !appliedExportData ||
appliedLayoutResult.selectedCount === 0 ||
pdfGenerating ||
imagesZipGenerating
} }
className={`inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold transition cursor-pointer ${appliedExportData && appliedLayoutResult.selectedCount > 0 && !pdfGenerating className={`inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold transition cursor-pointer ${appliedExportData && appliedLayoutResult.selectedCount > 0 && !pdfGenerating && !imagesZipGenerating
? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white' ? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white'
: 'bg-[#444] text-[#666] cursor-not-allowed' : 'bg-[#444] text-[#666] cursor-not-allowed'
}`} }`}
@@ -1428,9 +1596,11 @@ export default function App() {
paper={paper} paper={paper}
onChangePaper={handlePaperChange} onChangePaper={handlePaperChange}
template={activeTemplate} template={activeTemplate}
onChangeTemplate={handleTemplateChange}
rows={mappedRows} rows={mappedRows}
draftExportRange={draftExportRange} draftExportRange={draftExportRange}
onChangeDraftExportRange={setDraftExportRange} onChangeDraftExportRange={setDraftExportRange}
onChangeDataFilterRules={handleDataFilterRulesChange}
cutLine={cutLine} cutLine={cutLine}
onChangeCutLine={handleCutLineChange} onChangeCutLine={handleCutLineChange}
previewReady={previewSnapshot !== null} previewReady={previewSnapshot !== null}
@@ -1442,14 +1612,22 @@ export default function App() {
draftSelectedCount={selectedCount} draftSelectedCount={selectedCount}
printing={printing} printing={printing}
onPrint={printLabels} onPrint={printLabels}
onExportImages={exportLabelsImagesZip}
imagesExporting={imagesZipGenerating}
exportImageFileNamePattern={activeTemplate.exportImageFileNamePattern ?? ''}
onChangeExportImageFileNamePattern={handleExportImageFileNamePatternChange}
exportImageFilterMode={activeTemplate.exportImageFilterMode ?? 'always'}
exportImageFilterRules={activeTemplate.exportImageFilterRules ?? []}
onChangeExportImageFilterMode={handleExportImageFilterModeChange}
onChangeExportImageFilterRules={handleExportImageFilterRulesChange}
printDisabled={ printDisabled={
!appliedExportData || !appliedExportData ||
appliedLayoutResult.selectedCount === 0 || appliedLayoutResult.selectedCount === 0 ||
previewLayoutStale || previewLayoutStale ||
printing || printing ||
pdfGenerating pdfGenerating ||
imagesZipGenerating
} }
printDisabledReason={printBlockedReason}
/> />
</div> </div>
</div> </div>
@@ -1459,7 +1637,6 @@ export default function App() {
{editingTemplateId && ( {editingTemplateId && (
<div <div
className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4" className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
onClick={closeEditTemplateDialog}
role="presentation" role="presentation"
> >
<div <div
@@ -1507,7 +1684,6 @@ export default function App() {
{showAddTmplForm && ( {showAddTmplForm && (
<div <div
className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4" className="fixed inset-0 z-[9000] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
onClick={closeAddTemplateDialog}
role="presentation" role="presentation"
> >
<div <div
@@ -1548,18 +1724,25 @@ export default function App() {
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:border-indigo-400 focus:outline-none" className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:border-indigo-400 focus:outline-none"
/> />
</div> </div>
<LabelLayoutSizeCalculator
onCalculated={({ width, height, paper }) => {
setNewTmplW(String(width));
setNewTmplH(String(height));
setNewTmplPaper(paper);
}}
/>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-[11px] font-semibold text-gray-500 mb-1.5"> <label className="block text-[11px] font-semibold text-gray-500 mb-1.5">
(mm) (mm)
</label> </label>
<input <input
type="number" type="text"
inputMode="decimal"
required required
min="10" placeholder="75"
max="150"
value={newTmplW} value={newTmplW}
onChange={(e) => setNewTmplW(Number(e.target.value))} onChange={(e) => setNewTmplW(e.target.value)}
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none" className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none"
/> />
</div> </div>
@@ -1568,12 +1751,12 @@ export default function App() {
(mm) (mm)
</label> </label>
<input <input
type="number" type="text"
inputMode="decimal"
required required
min="10" placeholder="45"
max="150"
value={newTmplH} value={newTmplH}
onChange={(e) => setNewTmplH(Number(e.target.value))} onChange={(e) => setNewTmplH(e.target.value)}
className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none" className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none"
/> />
</div> </div>
@@ -1598,27 +1781,56 @@ export default function App() {
</div> </div>
)} )}
{pdfGenerating && ( {(pdfGenerating || imagesZipGenerating) && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-950/70 backdrop-blur-sm"> <div className="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-950/70 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl px-8 py-6 flex flex-col items-center gap-4 min-w-[280px]"> <div className="bg-white rounded-2xl shadow-2xl px-8 py-6 flex flex-col items-center gap-4 min-w-[280px]">
<Loader2 className="w-10 h-10 text-indigo-600 animate-spin" /> <Loader2 className="w-10 h-10 text-indigo-600 animate-spin" />
<div className="text-center"> <div className="text-center">
<p className="text-sm font-bold text-gray-900"> {pdfGenerating ? (
{pdfProgress.phase === 'save' ? '正在保存 PDF 文件…' : '正在生成 PDF'} <>
</p> <p className="text-sm font-bold text-gray-900">
<p className="text-xs text-gray-500 mt-1"> {pdfProgress.phase === 'save' ? '正在保存 PDF 文件…' : '正在生成 PDF'}
{pdfProgress.phase === 'save' </p>
? '即将完成,请稍候' <p className="text-xs text-gray-500 mt-1">
: `${pdfProgress.page} / ${pdfProgress.totalPages} 页 · ${pdfProgress.done} / ${pdfProgress.total} 张标签`} {pdfProgress.phase === 'save'
</p> ? '即将完成,请稍候'
: `${pdfProgress.page} / ${pdfProgress.totalPages} 页 · ${pdfProgress.done} / ${pdfProgress.total} 张标签`}
</p>
</>
) : (
<>
<p className="text-sm font-bold text-gray-900">
{imagesZipProgress.phase === 'save'
? '正在下载 ZIP 文件…'
: imagesZipProgress.phase === 'zip'
? '正在打包压缩…'
: '正在生成标签图片'}
</p>
<p className="text-xs text-gray-500 mt-1">
{imagesZipProgress.phase === 'save'
? '即将完成,请稍候'
: imagesZipProgress.phase === 'zip'
? `压缩进度 ${Math.round(imagesZipProgress.zipPercent ?? 0)}%`
: `${imagesZipProgress.done} / ${imagesZipProgress.total}`}
</p>
</>
)}
</div> </div>
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden"> <div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div <div
className="h-full bg-indigo-600 transition-all duration-200" className="h-full bg-indigo-600 transition-all duration-200"
style={{ style={{
width: pdfProgress.total width: pdfGenerating
? `${(pdfProgress.done / pdfProgress.total) * 100}%` ? pdfProgress.total
: '0%', ? `${(pdfProgress.done / pdfProgress.total) * 100}%`
: '0%'
: imagesZipProgress.phase === 'zip'
? `${imagesZipProgress.zipPercent ?? 0}%`
: imagesZipProgress.phase === 'save'
? '100%'
: imagesZipProgress.total
? `${(imagesZipProgress.done / imagesZipProgress.total) * 100}%`
: '0%',
}} }}
/> />
</div> </div>

258
src/colorUtils.ts Normal file
View File

@@ -0,0 +1,258 @@
import type { PrintColorMode, RecordRow, TemplateElement } from './types';
import { resolveContent, type ContentResolveMode } from './utils';
export interface RenderColorContext {
printColorMode?: PrintColorMode;
row: RecordRow | null;
variableDefaults?: Record<string, string> | null;
mode?: ContentResolveMode;
rowIndex?: number;
}
export function colorValueUsesVariable(value?: string): boolean {
return !!value?.includes('{');
}
export function isTransparentColorValue(value?: string): boolean {
if (!value) return true;
const trimmed = value.trim().toLowerCase();
return trimmed === '' || trimmed === 'transparent';
}
function clampByte(n: number): number {
return Math.max(0, Math.min(255, Math.round(n)));
}
function parseHexColor(color: string): { r: number; g: number; b: number; a: number } | null {
const trimmed = color.trim();
if (!trimmed.startsWith('#')) return null;
let hex = trimmed.slice(1);
if (hex.length === 3) {
hex = hex.split('').map((c) => c + c).join('');
}
if (hex.length === 6) {
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
a: 1,
};
}
if (hex.length === 8) {
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
a: parseInt(hex.slice(6, 8), 16) / 255,
};
}
return null;
}
function parseRgbColor(color: string): { r: number; g: number; b: number; a: number } | null {
const rgba = color.match(
/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i
);
if (!rgba) return null;
return {
r: Math.round(Number(rgba[1])),
g: Math.round(Number(rgba[2])),
b: Math.round(Number(rgba[3])),
a: rgba[4] !== undefined ? Math.min(1, Math.max(0, Number(rgba[4]))) : 1,
};
}
export function parseCssColor(color: string): { r: number; g: number; b: number; a: number } | null {
const trimmed = color.trim();
if (!trimmed || trimmed.toLowerCase() === 'transparent') return null;
return parseHexColor(trimmed) ?? parseRgbColor(trimmed);
}
function formatRgba(r: number, g: number, b: number, a = 1): string {
if (a >= 1) return `rgb(${r},${g},${b})`;
return `rgba(${r},${g},${b},${Math.round(a * 1000) / 1000})`;
}
function rgbToCmyk(r: number, g: number, b: number) {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const k = 1 - Math.max(rn, gn, bn);
if (k >= 1) return { c: 0, m: 0, y: 0, k: 1 };
const c = (1 - rn - k) / (1 - k);
const m = (1 - gn - k) / (1 - k);
const y = (1 - bn - k) / (1 - k);
return { c, m, y, k };
}
function cmykToRgb(c: number, m: number, y: number, k: number) {
return {
r: 255 * (1 - c) * (1 - k),
g: 255 * (1 - m) * (1 - k),
b: 255 * (1 - y) * (1 - k),
};
}
/** 灰成分替换GCR中性色更多走 K 通道,印刷更稳定 */
function applyGrayComponentReplacement(c: number, m: number, y: number, k: number) {
const gray = Math.min(c, m, y);
if (gray <= 0.01) return { c, m, y, k };
const gcr = gray * 0.85;
return {
c: c - gcr,
m: m - gcr,
y: y - gcr,
k: Math.min(1, k + gcr),
};
}
/** 总墨量限制TAC典型 280300% */
function limitTotalInk(c: number, m: number, y: number, k: number, maxInk = 2.8) {
const total = c + m + y + k;
if (total <= maxInk || total <= 0) return { c, m, y, k };
const scale = maxInk / total;
return { c: c * scale, m: m * scale, y: y * scale, k: k * scale };
}
/** 网点扩大 / 纸张吸墨:印刷比屏幕略深 */
function applyDotGain(r: number, g: number, b: number, amount: number): [number, number, number] {
const factor = 1 - amount;
return [clampByte(r * factor), clampByte(g * factor), clampByte(b * factor)];
}
/** 轻度去饱和,逼近油墨叠色效果 */
function applyDesaturation(r: number, g: number, b: number, saturation: number): [number, number, number] {
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
return [
clampByte(lum + (r - lum) * saturation),
clampByte(lum + (g - lum) * saturation),
clampByte(lum + (b - lum) * saturation),
];
}
function transformRgbPixel(r: number, g: number, b: number, mode: PrintColorMode): [number, number, number] {
if (mode === 'grayscale') {
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
return [lum, lum, lum];
}
let nr = r;
let ng = g;
let nb = b;
if (mode === 'cmyk') {
let { c, m, y, k } = rgbToCmyk(nr, ng, nb);
({ c, m, y, k } = applyGrayComponentReplacement(c, m, y, k));
({ c, m, y, k } = limitTotalInk(c, m, y, k));
const rgb = cmykToRgb(c, m, y, k);
nr = rgb.r;
ng = rgb.g;
nb = rgb.b;
[nr, ng, nb] = applyDotGain(nr, ng, nb, 0.07);
return [nr, ng, nb];
}
// rgb轻度印刷补偿设计预览与导出一致缩小与实机色差
[nr, ng, nb] = applyDesaturation(nr, ng, nb, 0.9);
[nr, ng, nb] = applyDotGain(nr, ng, nb, 0.04);
return [nr, ng, nb];
}
export function applyPrintColorModeToImageData(
imageData: ImageData,
mode: PrintColorMode = 'rgb'
): void {
const { data } = imageData;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha === 0) continue;
const [nr, ng, nb] = transformRgbPixel(data[i], data[i + 1], data[i + 2], mode);
data[i] = nr;
data[i + 1] = ng;
data[i + 2] = nb;
}
}
export function applyPrintColorModeToCanvas(
canvas: HTMLCanvasElement,
mode: PrintColorMode = 'rgb'
): void {
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
applyPrintColorModeToImageData(imageData, mode);
ctx.putImageData(imageData, 0, 0);
}
export function applyPrintColorMode(color: string, mode: PrintColorMode = 'rgb'): string {
if (isTransparentColorValue(color)) return color;
const parsed = parseCssColor(color);
if (!parsed) return color;
const [r, g, b] = transformRgbPixel(parsed.r, parsed.g, parsed.b, mode);
return formatRgba(r, g, b, parsed.a);
}
/** 解析颜色字面量或 {变量} */
export function resolveRenderableColor(
raw: string | undefined,
ctx: RenderColorContext,
fallback?: string
): string | undefined {
const source = (raw ?? fallback ?? '').trim();
if (!source) return undefined;
if (isTransparentColorValue(source)) return 'transparent';
let resolved = source;
if (colorValueUsesVariable(source)) {
resolved = resolveContent(
source,
ctx.row,
ctx.rowIndex ?? 0,
ctx.variableDefaults,
{ mode: ctx.mode ?? 'design' }
).trim();
}
if (!resolved || isTransparentColorValue(resolved)) return 'transparent';
return resolved;
}
/** 为渲染解析元素上的颜色字段(含变量) */
export function resolveElementColorsForRender(
elem: TemplateElement,
ctx: RenderColorContext
): TemplateElement {
const textColor = resolveRenderableColor(elem.textColor, ctx, '#111827');
const backgroundColor = resolveRenderableColor(elem.backgroundColor, ctx);
const borderColor = resolveRenderableColor(elem.borderColor, ctx, '#111827');
const tableBorderColor = resolveRenderableColor(elem.tableBorderColor, ctx, '#374151');
const patchBorderSide = (value?: string) => {
if (!value?.trim()) return value;
return resolveRenderableColor(value, ctx, borderColor ?? '#111827');
};
return {
...elem,
textColor,
backgroundColor,
borderColor,
tableBorderColor,
borderColorTop: patchBorderSide(elem.borderColorTop),
borderColorRight: patchBorderSide(elem.borderColorRight),
borderColorBottom: patchBorderSide(elem.borderColorBottom),
borderColorLeft: patchBorderSide(elem.borderColorLeft),
};
}
/** 颜色选择器可用的预览色(变量色时回退默认色) */
export function colorPickerPreviewValue(value: string | undefined, fallback: string): string {
if (!value || isTransparentColorValue(value) || colorValueUsesVariable(value)) {
return fallback;
}
const parsed = parseCssColor(value);
if (!parsed) return fallback;
if (parsed.a < 1) return fallback;
const hex = (n: number) => n.toString(16).padStart(2, '0');
return `#${hex(parsed.r)}${hex(parsed.g)}${hex(parsed.b)}`;
}

View File

@@ -33,6 +33,7 @@ interface DialogRequest {
interface AppDialogContextValue { interface AppDialogContextValue {
showAlert: (message: string, options?: AlertOptions) => Promise<void>; showAlert: (message: string, options?: AlertOptions) => Promise<void>;
showConfirm: (message: string, options?: ConfirmOptions) => Promise<boolean>; showConfirm: (message: string, options?: ConfirmOptions) => Promise<boolean>;
showToast: (message: string, options?: { variant?: AppDialogVariant; duration?: number }) => void;
} }
const AppDialogContext = createContext<AppDialogContextValue | null>(null); const AppDialogContext = createContext<AppDialogContextValue | null>(null);
@@ -147,8 +148,40 @@ function AppDialogModal({
); );
} }
function AppToast({
message,
variant = 'success',
onDismiss,
}: {
message: string;
variant: AppDialogVariant;
onDismiss: () => void;
}) {
const meta = VARIANT_META[variant];
useEffect(() => {
const timer = window.setTimeout(onDismiss, 2000);
return () => window.clearTimeout(timer);
}, [onDismiss]);
return (
<div
className="fixed left-1/2 bottom-[max(1.25rem,env(safe-area-inset-bottom))] z-[10001] -translate-x-1/2 pointer-events-none"
role="status"
aria-live="polite"
>
<div className="flex items-center gap-2 px-4 py-2.5 rounded-xl shadow-lg border border-black/10 bg-[#2a2a2a] text-[#f0f0f0] text-[12px] font-medium max-w-[min(90vw,20rem)]">
<span className="shrink-0">{meta.icon}</span>
<span className="truncate">{message}</span>
</div>
</div>
);
}
export function AppDialogProvider({ children }: { children: React.ReactNode }) { export function AppDialogProvider({ children }: { children: React.ReactNode }) {
const [dialog, setDialog] = useState<DialogRequest | null>(null); const [dialog, setDialog] = useState<DialogRequest | null>(null);
const [toast, setToast] = useState<{ message: string; variant: AppDialogVariant } | null>(null);
const toastTimerRef = useRef<number | null>(null);
const showAlert = useCallback((message: string, options?: AlertOptions) => { const showAlert = useCallback((message: string, options?: AlertOptions) => {
const variant = options?.variant ?? 'info'; const variant = options?.variant ?? 'info';
@@ -180,6 +213,28 @@ export function AppDialogProvider({ children }: { children: React.ReactNode }) {
}); });
}, []); }, []);
const dismissToast = useCallback(() => {
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = null;
}
setToast(null);
}, []);
const showToast = useCallback(
(message: string, options?: { variant?: AppDialogVariant; duration?: number }) => {
dismissToast();
const variant = options?.variant ?? 'success';
setToast({ message, variant });
const duration = options?.duration ?? 2000;
toastTimerRef.current = window.setTimeout(() => {
toastTimerRef.current = null;
setToast(null);
}, duration);
},
[dismissToast]
);
const closeDialog = useCallback((confirmed: boolean) => { const closeDialog = useCallback((confirmed: boolean) => {
setDialog((current) => { setDialog((current) => {
current?.resolve(confirmed); current?.resolve(confirmed);
@@ -188,9 +243,12 @@ export function AppDialogProvider({ children }: { children: React.ReactNode }) {
}, []); }, []);
return ( return (
<AppDialogContext.Provider value={{ showAlert, showConfirm }}> <AppDialogContext.Provider value={{ showAlert, showConfirm, showToast }}>
{children} {children}
{dialog && <AppDialogModal dialog={dialog} onClose={closeDialog} />} {dialog && <AppDialogModal dialog={dialog} onClose={closeDialog} />}
{toast && (
<AppToast message={toast.message} variant={toast.variant} onDismiss={dismissToast} />
)}
</AppDialogContext.Provider> </AppDialogContext.Provider>
); );
} }

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { TemplateElement } from '../types'; import { TemplateElement } from '../types';
import type { PrintColorMode } from '../types';
import { ContentResolveMode } from '../utils'; import { ContentResolveMode } from '../utils';
import { renderLabelToDataUrl } from '../labelRenderer'; import { renderLabelToDataUrl } from '../labelRenderer';
@@ -14,8 +15,12 @@ interface CanvasLabelImageProps {
variableDefaults?: Record<string, string> | null; variableDefaults?: Record<string, string> | null;
/** design=设计预览空变量显示占位output=排版/打印(空值隐藏图形元素) */ /** design=设计预览空变量显示占位output=排版/打印(空值隐藏图形元素) */
mode?: ContentResolveMode; mode?: ContentResolveMode;
referenceBackground?: string;
referenceBackgroundOpacity?: number;
referenceBackgroundFit?: 'contain' | 'cover' | 'fill';
/** 渲染倍率,设计画布应传 MM_TO_PX * zoom 与显示尺寸 1:1 对齐 */ /** 渲染倍率,设计画布应传 MM_TO_PX * zoom 与显示尺寸 1:1 对齐 */
renderScale?: number; renderScale?: number;
printColorMode?: PrintColorMode;
} }
export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
@@ -28,7 +33,11 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
transparentBackground = false, transparentBackground = false,
variableDefaults = null, variableDefaults = null,
mode = 'design' as ContentResolveMode, mode = 'design' as ContentResolveMode,
referenceBackground,
referenceBackgroundOpacity,
referenceBackgroundFit,
renderScale = 16, renderScale = 16,
printColorMode = 'cmyk',
}) => { }) => {
const [dataUrl, setDataUrl] = useState<string>(''); const [dataUrl, setDataUrl] = useState<string>('');
const [initialLoading, setInitialLoading] = useState<boolean>(true); const [initialLoading, setInitialLoading] = useState<boolean>(true);
@@ -54,6 +63,10 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
variableDefaults, variableDefaults,
mode, mode,
scale: renderScale, scale: renderScale,
referenceBackground,
referenceBackgroundOpacity,
referenceBackgroundFit,
printColorMode,
}) })
.then((url) => { .then((url) => {
if (!active) return; if (!active) return;
@@ -88,6 +101,10 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
transparentBackground, transparentBackground,
mode, mode,
renderScale, renderScale,
referenceBackground,
referenceBackgroundOpacity,
referenceBackgroundFit,
printColorMode,
]); ]);
if (initialLoading && !dataUrl) { if (initialLoading && !dataUrl) {

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { colorPickerPreviewValue, colorValueUsesVariable } from '../colorUtils';
import { CommitInput } from './CommitInput';
interface ColorValueFieldProps {
label?: string;
value: string;
onChange: (value: string) => void;
variableOptions?: string[];
defaultColor?: string;
placeholder?: string;
className?: string;
hint?: string;
}
export const ColorValueField: React.FC<ColorValueFieldProps> = ({
label,
value,
onChange,
variableOptions = [],
defaultColor = '#111827',
placeholder = '#111827',
className = '',
hint,
}) => {
const usesVariable = colorValueUsesVariable(value);
const pickerValue = colorPickerPreviewValue(value, defaultColor);
return (
<div className={className}>
{label && <label className="ps-field-label">{label}</label>}
<div className="flex gap-1 items-center">
<input
type="color"
value={pickerValue}
disabled={usesVariable}
onChange={(e) => onChange(e.target.value)}
className="color-input shrink-0 disabled:opacity-40"
title={usesVariable ? '当前为变量颜色,请使用文本框编辑' : undefined}
/>
<CommitInput
type="text"
value={value}
placeholder={placeholder}
onCommit={onChange}
className="ps-field font-mono text-[11px] flex-1 min-w-0"
/>
</div>
{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}}`;
onChange(value.includes(token) ? value : value ? `${value} ${token}` : token);
}}
className="ps-btn text-[9px] font-mono"
>
{`{${v}}`}
</button>
))}
</div>
)}
{hint && <p className="text-[9px] text-[#666] mt-1 leading-relaxed">{hint}</p>}
{!hint && variableOptions.length > 0 && (
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
hex/rgb/transparent {`{变量名}`}
</p>
)}
</div>
);
};

View File

@@ -86,3 +86,75 @@ export const CommitInput: React.FC<CommitInputProps> = ({
/> />
); );
}; };
export interface CommitTextareaProps
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange' | 'value'> {
value: string;
onCommit: (val: string) => void;
}
/** 失焦提交型多行输入框 */
export const CommitTextarea: React.FC<CommitTextareaProps> = ({
value,
onCommit,
onFocus,
onBlur,
onKeyDown,
...props
}) => {
const [localVal, setLocalVal] = useState<string>(value);
const focusedRef = useRef(false);
const localValRef = useRef(localVal);
const valueRef = useRef(value);
const onCommitRef = useRef(onCommit);
localValRef.current = localVal;
valueRef.current = value;
onCommitRef.current = onCommit;
useEffect(() => {
if (!focusedRef.current) {
setLocalVal(value);
}
}, [value]);
useEffect(() => {
return () => {
if (focusedRef.current && localValRef.current !== valueRef.current) {
onCommitRef.current(localValRef.current);
}
};
}, []);
const commitIfChanged = () => {
if (localValRef.current !== valueRef.current) {
onCommitRef.current(localValRef.current);
}
};
const handleBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
focusedRef.current = false;
onBlur?.(e);
commitIfChanged();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
(e.target as HTMLTextAreaElement).blur();
}
onKeyDown?.(e);
};
return (
<textarea
{...props}
value={localVal}
onChange={(e) => setLocalVal(e.target.value)}
onFocus={(e) => {
focusedRef.current = true;
onFocus?.(e);
}}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
);
};

View File

@@ -1,71 +1,116 @@
import React from 'react'; import React, { useMemo } from 'react';
import { Filter } from 'lucide-react'; import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow, VisibilityRule } from '../types';
import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow } from '../types'; import {
import { buildPrintablePages } from '../utils'; buildPrintablePages,
extractTemplateVariables,
getDataFilteredIndices,
} from '../utils';
import { PageInput } from './PreviewGrid'; import { PageInput } from './PreviewGrid';
import { FieldSelect } from './PsSelect'; import { FieldSelect } from './PsSelect';
import { VariableFilterRulesEditor } from './VariableFilterRulesEditor';
interface DataRangeFieldsProps { interface DataRangeFieldsProps {
variant?: 'ps' | 'mobile'; variant?: 'ps' | 'mobile';
exportRange: ExportRangeConfig; exportRange: ExportRangeConfig;
onChangeExportRange: (range: ExportRangeConfig) => void; onChangeExportRange: (range: ExportRangeConfig) => void;
onChangeDataFilterRules: (rules: VisibilityRule[]) => void;
rows: RecordRow[]; rows: RecordRow[];
paper: PaperConfig; paper: PaperConfig;
template: LabelTemplate; template: LabelTemplate;
disabled?: boolean; /** 排版过滤在无数据时不可用;数据过滤始终可编辑 */
layoutDisabled?: boolean;
} }
export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
variant = 'ps', variant = 'ps',
exportRange, exportRange,
onChangeExportRange, onChangeExportRange,
onChangeDataFilterRules,
rows, rows,
paper, paper,
template, template,
disabled = false, layoutDisabled = false,
}) => { }) => {
const isMobile = variant === 'mobile'; const isMobile = variant === 'mobile';
const rangeStats = buildPrintablePages(rows, paper, template, exportRange); const variableDefaults = template.variableDefaults ?? null;
const templateVariables = useMemo(() => extractTemplateVariables(template), [template]);
const dataFilteredCount = useMemo(
() => getDataFilteredIndices(rows, template, variableDefaults).length,
[rows, template, variableDefaults]
);
const rangeStats = buildPrintablePages(rows, paper, template, exportRange, variableDefaults);
const patchRange = (patch: Partial<ExportRangeConfig>) => { const patchRange = (patch: Partial<ExportRangeConfig>) => {
onChangeExportRange({ ...exportRange, ...patch }); onChangeExportRange({ ...exportRange, ...patch });
}; };
const sectionTitleClass = isMobile ? 'mobile-field-label mb-1 block' : 'ps-section-title';
const hintClass = isMobile ? 'text-[10px] text-slate-500' : 'text-[10px] text-[#888]';
return ( return (
<div className={isMobile ? 'space-y-4' : 'space-y-2'}> <div className={isMobile ? 'space-y-4' : 'space-y-3'}>
{!isMobile && <div className="ps-section-title"></div>} <div className="space-y-2">
<div> {rows.length > 0 && (
{isMobile && <label className="mobile-field-label"></label>} <div className="space-y-2">
{/* {!isMobile && <label className="ps-field-label">范围</label>} */} <div className={sectionTitleClass}></div>
<FieldSelect <p className={hintClass}></p>
value={exportRange.mode} <div>
disabled={disabled} {isMobile && <label className="mobile-field-label"></label>}
onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })} <FieldSelect
className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'} value={exportRange.mode}
> disabled={layoutDisabled}
<option value="all"></option> onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })}
<option value="odd"></option> className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'}
<option value="even"></option> >
<option value="custom"></option> <option value="all"></option>
</FieldSelect> <option value="odd"></option>
</div> <option value="even"></option>
{exportRange.mode === 'custom' && ( <option value="custom"></option>
<div> </FieldSelect>
{isMobile ? ( </div>
<label className="mobile-field-label"></label> {exportRange.mode === 'custom' && (
) : ( <div>
<label className="ps-field-label"></label> {isMobile ? (
)} <label className="mobile-field-label"></label>
<PageInput ) : (
type="text" <label className="ps-field-label"></label>
value={exportRange.custom ?? ''} )}
disabled={disabled} <PageInput
onCommit={(val) => patchRange({ custom: val })} type="text"
placeholder="如 1,3,5-10" value={exportRange.custom ?? ''}
inputClassName={isMobile ? 'mobile-field font-mono' : 'ps-field font-mono text-[11px]'} disabled={layoutDisabled}
onCommit={(val) => patchRange({ custom: val })}
placeholder="如 1,3,5-10"
inputClassName={isMobile ? 'mobile-field font-mono' : 'ps-field font-mono text-[11px]'}
/>
</div>
)}
<p className={hintClass}>
{rangeStats.selectedCount} / {rows.length}
</p>
</div>
)}
<div className="space-y-3">
<div className={sectionTitleClass}></div>
<p className={hintClass}></p>
<VariableFilterRulesEditor
rules={template.dataFilterRules ?? []}
templateVariables={templateVariables}
emptyHint="尚未添加过滤条件,将使用全部数据行"
description="按数据行与变量默认值判断;上传数据后可预览过滤结果。"
addTitle="添加数据过滤条件"
editTitle="编辑数据过滤条件"
onChangeRules={onChangeDataFilterRules}
/> />
{rows.length > 0 && (
<p className={hintClass}>
{dataFilteredCount} / {rows.length}
</p>
)}
</div> </div>
)} </div>
</div> </div>
); );
}; };

View File

@@ -3,6 +3,12 @@ import { TemplateElement } from '../types';
import { mmToPx } from '../utils'; import { mmToPx } from '../utils';
import { renderLabelToDataUrl } from '../labelRenderer'; import { renderLabelToDataUrl } from '../labelRenderer';
/** 离屏单元素渲染时预留描边空间,避免画布边缘裁切表格线 */
export function getFloatPreviewPaddingMm(element: TemplateElement): number {
if (element.type !== 'table') return 0;
return (element.tableBorderWidth ?? 0.2) / 2 + 0.1;
}
interface ElementFloatPreviewProps { interface ElementFloatPreviewProps {
element: TemplateElement; element: TemplateElement;
renderScale: number; renderScale: number;
@@ -17,10 +23,11 @@ export async function renderElementFloatPreview(
renderScale: number, renderScale: number,
variableDefaults?: Record<string, string> | null variableDefaults?: Record<string, string> | null
): Promise<string> { ): Promise<string> {
const padMm = getFloatPreviewPaddingMm(element);
return renderLabelToDataUrl({ return renderLabelToDataUrl({
widthMm: element.width, widthMm: element.width + padMm * 2,
heightMm: element.height, heightMm: element.height + padMm * 2,
elements: [{ ...element, x: 0, y: 0 }], elements: [{ ...element, x: padMm, y: padMm }],
rowData: null, rowData: null,
rowIndex: 0, rowIndex: 0,
showBorder: false, showBorder: false,
@@ -67,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;
@@ -80,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

@@ -0,0 +1,339 @@
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Upload, X } from 'lucide-react';
import type { ElementMaskConfig, TemplateElement } from '../types';
import { createDefaultElementMask } from '../elementMask';
import { FieldSelect } from './PsSelect';
import { CommitInput } from './CommitInput';
interface ElementMaskDialogProps {
open: boolean;
element: TemplateElement | null;
onClose: () => void;
onConfirm: (mask: ElementMaskConfig | undefined) => void;
}
const round1 = (n: number) => Math.round(n * 10) / 10;
export const ElementMaskDialog: React.FC<ElementMaskDialogProps> = ({
open,
element,
onClose,
onConfirm,
}) => {
const [draft, setDraft] = useState<ElementMaskConfig>({ enabled: true, type: 'rect' });
const draftRef = useRef(draft);
draftRef.current = draft;
useEffect(() => {
if (!open || !element) return;
const initial = element.mask?.enabled
? { ...createDefaultElementMask(element), ...element.mask, enabled: true }
: createDefaultElementMask(element);
draftRef.current = initial;
setDraft(initial);
}, [open, element]);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open, onClose]);
if (!open || !element || typeof document === 'undefined') return null;
const regionW = draft.width ?? element.width;
const regionH = draft.height ?? element.height;
const maskType = draft.type ?? 'rect';
const canConfirm = maskType !== 'image' || !!draft.image?.trim();
const patchDraft = (patch: Partial<ElementMaskConfig>) => {
setDraft((prev) => {
const next = { ...prev, ...patch };
draftRef.current = next;
return next;
});
};
const patchDraftCorner = (key: keyof ElementMaskConfig, val: string) => {
if (val.trim() === '') {
setDraft((prev) => {
const next = { ...prev };
delete next[key];
draftRef.current = next;
return next;
});
return;
}
patchDraft({ [key]: Math.max(0, round1(Number(val) || 0)) });
};
const normalizeDraft = (source: ElementMaskConfig): ElementMaskConfig => ({
...source,
enabled: true,
x: round1(Number(source.x) || 0),
y: round1(Number(source.y) || 0),
width: Math.max(0.5, round1(Number(source.width) || element.width)),
height: Math.max(0.5, round1(Number(source.height) || element.height)),
borderRadius: Math.max(0, round1(Number(source.borderRadius) || 0)),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const active = document.activeElement;
const submit = () => {
if (!canConfirm) return;
onConfirm(normalizeDraft(draftRef.current));
onClose();
};
if (active instanceof HTMLElement && form.contains(active)) {
active.blur();
setTimeout(submit, 0);
return;
}
submit();
};
const handleClear = () => {
onConfirm(undefined);
onClose();
};
return createPortal(
<div className="ps-modal-overlay" role="presentation">
<div
className="ps-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="element-mask-dialog-title"
>
<div className="ps-modal-header">
<h3 id="element-mask-dialog-title" className="ps-modal-title">
{element.name}
</h3>
<button type="button" onClick={onClose} className="ps-modal-close" aria-label="关闭">
<X className="w-3.5 h-3.5" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="ps-modal-body space-y-3">
<p className="text-[11px] text-[#888] leading-relaxed">
</p>
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={draft.mode ?? 'include'}
onChange={(e) =>
patchDraft({
mode: e.target.value as ElementMaskConfig['mode'],
})
}
className="ps-field text-[11px]"
>
<option value="include"></option>
<option value="exclude"></option>
</FieldSelect>
</div>
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={maskType}
onChange={(e) =>
patchDraft({
type: e.target.value as ElementMaskConfig['type'],
})
}
className="ps-field text-[11px]"
>
<option value="rect"></option>
<option value="image"></option>
</FieldSelect>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ps-field-label">X (mm)</label>
<CommitInput
type="number"
step="0.5"
value={draft.x ?? 0}
onCommit={(val) => patchDraft({ x: round1(Number(val) || 0) })}
className="ps-field font-mono text-[11px]"
/>
</div>
<div>
<label className="ps-field-label">Y (mm)</label>
<CommitInput
type="number"
step="0.5"
value={draft.y ?? 0}
onCommit={(val) => patchDraft({ y: round1(Number(val) || 0) })}
className="ps-field font-mono text-[11px]"
/>
</div>
<div>
<label className="ps-field-label"> (mm)</label>
<CommitInput
type="number"
step="0.5"
min="0.5"
value={regionW}
onCommit={(val) =>
patchDraft({
width: Math.max(0.5, round1(Number(val) || element.width)),
})
}
className="ps-field font-mono text-[11px]"
/>
</div>
<div>
<label className="ps-field-label"> (mm)</label>
<CommitInput
type="number"
step="0.5"
min="0.5"
value={regionH}
onCommit={(val) =>
patchDraft({
height: Math.max(0.5, round1(Number(val) || element.height)),
})
}
className="ps-field font-mono text-[11px]"
/>
</div>
</div>
{maskType === 'rect' && (
<>
<div>
<label className="ps-field-label"> (mm)</label>
<CommitInput
type="number"
min="0"
max="50"
step="0.5"
value={draft.borderRadius ?? 0}
onCommit={(val) =>
patchDraft({ borderRadius: Math.max(0, round1(Number(val) || 0)) })
}
className="ps-field font-mono text-[11px]"
/>
</div>
<div className="grid grid-cols-2 gap-2">
{(
[
['borderRadiusTL', '左上'],
['borderRadiusTR', '右上'],
['borderRadiusBL', '左下'],
['borderRadiusBR', '右下'],
] as const
).map(([key, label]) => (
<div key={key} className="flex items-center gap-1">
<span className="text-[9px] text-[#777] w-6 shrink-0">{label}</span>
<CommitInput
type="number"
min="0"
max="50"
step="0.5"
value={draft[key] ?? ''}
onCommit={(val) => patchDraftCorner(key, val)}
className="ps-field font-mono text-[10px] flex-1 min-w-0"
placeholder="—"
/>
</div>
))}
</div>
</>
)}
{maskType === 'image' && (
<div className="space-y-2">
<label className="ps-btn text-[10px] cursor-pointer inline-flex">
<Upload className="w-3.5 h-3.5" />
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => patchDraft({ image: reader.result as string });
reader.readAsDataURL(file);
e.target.value = '';
}}
/>
</label>
<p className="text-[9px] text-[#666] leading-relaxed">
</p>
{draft.image && (
<div className="border border-[#3a3a3a] bg-[#1e1e1e] p-2">
<img
src={draft.image}
alt="蒙版预览"
className="block max-h-28 mx-auto object-contain"
/>
</div>
)}
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={draft.imageFit ?? 'fill'}
onChange={(e) =>
patchDraft({
imageFit: e.target.value as ElementMaskConfig['imageFit'],
})
}
className="ps-field text-[11px]"
>
<option value="fill"> (fill)</option>
<option value="cover"> (cover)</option>
<option value="contain"> (contain)</option>
</FieldSelect>
</div>
</div>
)}
</div>
<div className="ps-modal-footer">
{element.mask && element.mask.enabled !== false && (
<button
type="button"
onClick={handleClear}
className="ps-btn text-[11px] text-red-400 hover:text-red-300 mr-auto"
>
</button>
)}
<button type="button" onClick={onClose} className="ps-btn text-[11px]">
</button>
<button
type="submit"
disabled={!canConfirm}
className="ps-btn ps-btn-primary text-[11px] disabled:opacity-50"
>
</button>
</div>
</form>
</div>
</div>,
document.body
);
};

View File

@@ -0,0 +1,66 @@
import React from 'react';
import type { ElementVisibilityMode, VisibilityRule } from '../types';
import { isExportImageFilterConditional } from '../utils';
import { FieldSelect } from './PsSelect';
import { VariableFilterRulesEditor } from './VariableFilterRulesEditor';
interface ExportImageFilterFieldsProps {
filterMode?: ElementVisibilityMode;
filterRules?: VisibilityRule[];
templateVariables: string[];
disabled?: boolean;
onChangeFilterMode: (mode: ElementVisibilityMode) => void;
onChangeFilterRules: (rules: VisibilityRule[]) => void;
}
export const ExportImageFilterFields: React.FC<ExportImageFilterFieldsProps> = ({
filterMode = 'always' as ElementVisibilityMode,
filterRules = [],
templateVariables,
disabled = false,
onChangeFilterMode,
onChangeFilterRules,
}) => {
const conditional = isExportImageFilterConditional({ exportImageFilterMode: filterMode });
const setRules = (next: VisibilityRule[]) => {
onChangeFilterMode('conditional');
onChangeFilterRules(next);
};
return (
<>
<div className="space-y-1.5">
<label className="ps-field-label mb-0"></label>
<FieldSelect
value={conditional ? 'conditional' : 'always'}
onChange={(e) => {
const nextConditional = e.target.value === 'conditional';
onChangeFilterMode(nextConditional ? 'conditional' : 'always');
if (nextConditional && (filterRules?.length ?? 0) === 0) {
onChangeFilterRules([]);
}
}}
className="ps-field text-[11px]"
disabled={disabled}
>
<option value="always"></option>
<option value="conditional"></option>
</FieldSelect>
</div>
{conditional && (
<VariableFilterRulesEditor
rules={filterRules}
templateVariables={templateVariables}
disabled={disabled}
emptyHint="尚未添加过滤条件,将导出全部标签"
description="全部条件满足时才导出该枚标签;按当前数据行与变量默认值判断。"
addTitle="添加导出过滤条件"
editTitle="编辑导出过滤条件"
onChangeRules={setRules}
/>
)}
</>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Database, Layout, Printer, Settings } from 'lucide-react'; import { Database, Layout, Printer, Settings } from 'lucide-react';
import { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig } from '../types'; import { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig, ElementVisibilityMode, VisibilityRule } from '../types';
import { DataImporter } from './DataImporter'; import { DataImporter } from './DataImporter';
import { VariableMappingPanel } from './VariableMappingPanel'; import { VariableMappingPanel } from './VariableMappingPanel';
import { PaperSettingsPanel, PageInput } from './PreviewGrid'; import { PaperSettingsPanel, PageInput } from './PreviewGrid';
@@ -8,6 +8,7 @@ import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { DataRangeFields } from './DataRangeFields'; import { DataRangeFields } from './DataRangeFields';
import { FieldSelect } from './PsSelect'; import { FieldSelect } from './PsSelect';
import { PrintModule } from './PrintModule'; import { PrintModule } from './PrintModule';
import { LayoutPageBackgroundFields } from './LayoutPageBackgroundFields';
interface ExportSidebarProps { interface ExportSidebarProps {
importData: ImportData | null; importData: ImportData | null;
@@ -21,9 +22,11 @@ interface ExportSidebarProps {
paper: PaperConfig; paper: PaperConfig;
onChangePaper: (updated: PaperConfig) => void; onChangePaper: (updated: PaperConfig) => void;
template: LabelTemplate; template: LabelTemplate;
onChangeTemplate: (updated: LabelTemplate) => void;
rows: RecordRow[]; rows: RecordRow[];
draftExportRange: ExportRangeConfig; draftExportRange: ExportRangeConfig;
onChangeDraftExportRange: (range: ExportRangeConfig) => void; onChangeDraftExportRange: (range: ExportRangeConfig) => void;
onChangeDataFilterRules: (rules: VisibilityRule[]) => void;
cutLine: CutLineConfig; cutLine: CutLineConfig;
onChangeCutLine: (cutLine: CutLineConfig) => void; onChangeCutLine: (cutLine: CutLineConfig) => void;
previewReady: boolean; previewReady: boolean;
@@ -35,8 +38,15 @@ interface ExportSidebarProps {
draftSelectedCount: number; draftSelectedCount: number;
printing: boolean; printing: boolean;
onPrint: (printerId: string) => void; onPrint: (printerId: string) => void;
imagesExporting?: boolean;
onExportImages?: () => void;
exportImageFileNamePattern?: string;
onChangeExportImageFileNamePattern?: (pattern: string) => void;
exportImageFilterMode?: ElementVisibilityMode;
exportImageFilterRules?: VisibilityRule[];
onChangeExportImageFilterMode?: (mode: ElementVisibilityMode) => void;
onChangeExportImageFilterRules?: (rules: VisibilityRule[]) => void;
printDisabled: boolean; printDisabled: boolean;
printDisabledReason?: string | null;
/** 移动端精简:仅数据、映射、数据范围 */ /** 移动端精简:仅数据、映射、数据范围 */
compact?: boolean; compact?: boolean;
} }
@@ -53,9 +63,11 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
paper, paper,
onChangePaper, onChangePaper,
template, template,
onChangeTemplate,
rows, rows,
draftExportRange, draftExportRange,
onChangeDraftExportRange, onChangeDraftExportRange,
onChangeDataFilterRules,
cutLine, cutLine,
onChangeCutLine, onChangeCutLine,
previewReady, previewReady,
@@ -67,8 +79,15 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
draftSelectedCount, draftSelectedCount,
printing, printing,
onPrint, onPrint,
imagesExporting = false,
onExportImages,
exportImageFileNamePattern = '',
onChangeExportImageFileNamePattern,
exportImageFilterMode = 'always',
exportImageFilterRules = [],
onChangeExportImageFilterMode,
onChangeExportImageFilterRules,
printDisabled, printDisabled,
printDisabledReason = null,
compact = false, compact = false,
}) => { }) => {
type ExportPanelKey = 'data' | 'layout' | 'print'; type ExportPanelKey = 'data' | 'layout' | 'print';
@@ -86,10 +105,11 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
variant={compact ? 'ps' : 'ps'} variant={compact ? 'ps' : 'ps'}
exportRange={draftExportRange} exportRange={draftExportRange}
onChangeExportRange={onChangeDraftExportRange} onChangeExportRange={onChangeDraftExportRange}
onChangeDataFilterRules={onChangeDataFilterRules}
rows={rows} rows={rows}
paper={paper} paper={paper}
template={template} template={template}
disabled={rows.length === 0} layoutDisabled={rows.length === 0}
/> />
); );
@@ -139,6 +159,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
templateVariables={templateVariables} templateVariables={templateVariables}
/> />
</div> </div>
{dataRangeSection}
{hasData && ( {hasData && (
<> <>
<div> <div>
@@ -151,7 +172,6 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
variableDefaults={template.variableDefaults} variableDefaults={template.variableDefaults}
/> />
</div> </div>
{dataRangeSection}
{applyDataSection} {applyDataSection}
</> </>
)} )}
@@ -178,6 +198,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
templateName={templateName} templateName={templateName}
templateVariables={templateVariables} templateVariables={templateVariables}
/> />
{dataRangeSection}
{hasData && ( {hasData && (
<> <>
<VariableMappingPanel <VariableMappingPanel
@@ -188,7 +209,6 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
onChangeMapping={onChangeMapping} onChangeMapping={onChangeMapping}
variableDefaults={template.variableDefaults} variableDefaults={template.variableDefaults}
/> />
{dataRangeSection}
{applyDataSection} {applyDataSection}
</> </>
)} )}
@@ -211,6 +231,9 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
exportRange={draftExportRange} exportRange={draftExportRange}
totalRowCount={rows.length} totalRowCount={rows.length}
/> />
<div className="ps-section-divider">
<LayoutPageBackgroundFields template={template} onChangeTemplate={onChangeTemplate} />
</div>
<div className="ps-section-divider space-y-2"> <div className="ps-section-divider space-y-2">
<div className="ps-section-title"> <div className="ps-section-title">
<Settings className="w-3 h-3" /> <Settings className="w-3 h-3" />
@@ -304,9 +327,17 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
> >
<PrintModule <PrintModule
disabled={printDisabled} disabled={printDisabled}
disabledReason={printDisabledReason}
printing={printing} printing={printing}
onPrint={onPrint} onPrint={onPrint}
imagesExporting={imagesExporting}
onExportImages={onExportImages}
exportImageFileNamePattern={exportImageFileNamePattern}
onChangeExportImageFileNamePattern={onChangeExportImageFileNamePattern}
exportImageFilterMode={exportImageFilterMode}
exportImageFilterRules={exportImageFilterRules}
onChangeExportImageFilterMode={onChangeExportImageFilterMode}
onChangeExportImageFilterRules={onChangeExportImageFilterRules}
templateVariables={templateVariables}
/> />
</CollapsiblePanelSection> </CollapsiblePanelSection>
</aside> </aside>

View File

@@ -1,7 +1,9 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { LabelTemplate, TemplateElement } from '../types'; import { LabelTemplate, TemplateElement } from '../types';
import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils'; import { mmToPx, MM_TO_PX, shouldRenderElement, centerElementOnLabel } from '../utils';
import { getSelectionBounds } from '../elementAlign';
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
import { import {
createDefaultTableElement, createDefaultTableElement,
getTableColWidths, getTableColWidths,
@@ -15,9 +17,10 @@ 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 { import {
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
@@ -40,6 +43,10 @@ interface LabelDesignerProps {
variant?: 'desktop' | 'mobile'; variant?: 'desktop' | 'mobile';
/** 微调等场景下隐藏选区框与缩放手柄 */ /** 微调等场景下隐藏选区框与缩放手柄 */
suppressSelectionChrome?: boolean; suppressSelectionChrome?: boolean;
elementClipboard?: Pick<
ElementClipboardActions,
'copySelectedElements' | 'pasteClipboardElements' | 'canCopy' | 'canPaste' | 'clipboardCount'
>;
} }
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode' | 'table'; type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode' | 'table';
@@ -212,6 +219,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
onSelectTableCells, onSelectTableCells,
variant = 'desktop', variant = 'desktop',
suppressSelectionChrome = false, suppressSelectionChrome = false,
elementClipboard,
}) => { }) => {
const { showConfirm } = useAppDialog(); const { showConfirm } = useAppDialog();
const selectedElementId = selectedElementIds[0] || null; const selectedElementId = selectedElementIds[0] || null;
@@ -228,6 +236,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
() => template.variableDefaults ?? null, () => template.variableDefaults ?? null,
[template.variableDefaults] [template.variableDefaults]
); );
const printColorMode = template.paper?.printColorMode ?? 'cmyk';
const showReferencePreview = useMemo(
() =>
!!template.previewReferenceBackground &&
template.enablePreviewReferenceBackground !== false,
[template.previewReferenceBackground, template.enablePreviewReferenceBackground]
);
const localElementsRef = useRef<TemplateElement[] | null>(null); const localElementsRef = useRef<TemplateElement[] | null>(null);
/** 交互开始时的元素快照:拖动底图分层、缩放节流重绘 */ /** 交互开始时的元素快照:拖动底图分层、缩放节流重绘 */
@@ -304,17 +320,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}`,
x: 5, ...centerElementOnLabel(current.width, current.height, 30, 30),
y: 5,
width: 30, width: 30,
height: 30, height: 30,
content: src, content: src,
@@ -329,20 +347,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);
@@ -407,17 +426,26 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}; };
} }
const centered = centerElementOnLabel(
current.width,
current.height,
newElement.width,
newElement.height
);
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
@@ -425,14 +453,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, {
@@ -443,10 +471,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') {
@@ -484,7 +512,18 @@ 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;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
e.preventDefault();
elementClipboard?.copySelectedElements();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
e.preventDefault();
elementClipboard?.pasteClipboardElements();
setActiveTool('move');
return; return;
} }
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
@@ -509,9 +548,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,
@@ -532,6 +572,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
addElement, addElement,
onSelectElements, onSelectElements,
deleteSelectedElements, deleteSelectedElements,
elementClipboard,
]); ]);
const clearInteractionCanvasState = useCallback(() => { const clearInteractionCanvasState = useCallback(() => {
@@ -1003,7 +1044,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;
@@ -1408,6 +1449,33 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
suppressSelectionChrome || (dragState?.type === 'drag' && dragInteractionVisualActive); suppressSelectionChrome || (dragState?.type === 'drag' && dragInteractionVisualActive);
const showUnselectedDimLayer = activeTool === 'move'; const showUnselectedDimLayer = activeTool === 'move';
const showUnselectedDim = showUnselectedDimLayer && selectedElementIds.length > 0; const showUnselectedDim = showUnselectedDimLayer && selectedElementIds.length > 0;
const multiSelectionBounds = useMemo(() => {
if (selectedElementIds.length < 2 || hideSelectionChrome) return null;
const selected = template.elements.filter(
(el) => selectedElementIds.includes(el.id) && !el.locked
);
if (selected.length < 2) return null;
return getSelectionBounds(selected);
}, [selectedElementIds, template.elements, hideSelectionChrome]);
const dragMultiSelectionBounds = useMemo(() => {
if (
selectedElementIds.length < 2 ||
dragState?.type !== 'drag' ||
!dragInteractionVisualActive ||
dragFloatElements.length < 2
) {
return null;
}
return getSelectionBounds(dragFloatElements);
}, [
selectedElementIds.length,
dragState?.type,
dragInteractionVisualActive,
dragFloatElements,
]);
const resizingPreviewElement = const resizingPreviewElement =
dragState?.type === 'resize' dragState?.type === 'resize'
? interactionFloatElements?.find((el) => el.id === dragState.elementId) ? interactionFloatElements?.find((el) => el.id === dragState.elementId)
@@ -1423,6 +1491,28 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' }, { id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
]; ];
const mobileToolItems = useMemo<MobileToolButtonItem[]>(
() => [
...tools.map((tool) => ({
id: tool.id,
icon: tool.icon,
label: tool.label,
shortcut: tool.shortcut,
active: activeTool === tool.id,
onClick: () => handleToolClick(tool.id),
})),
{
id: 'image',
icon: <ImageIcon className="w-[18px] h-[18px]" />,
label: '图片',
active: false,
onClick: handleImageToolClick,
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps -- tools 为静态配置
[activeTool, handleToolClick, handleImageToolClick]
);
const mainToolButtons = ( const mainToolButtons = (
<> <>
{tools.map((tool) => ( {tools.map((tool) => (
@@ -1527,20 +1617,27 @@ 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
data-drag-float-placeholder={el.id} data-drag-float-placeholder={el.id}
className="absolute inset-0 bg-white border border-[#31a8ff]/40 shadow-[0_1px_4px_rgba(0,0,0,0.12)]" className="absolute inset-0 bg-white shadow-[0_1px_4px_rgba(0,0,0,0.12)]"
/> />
<img <img
ref={(node) => { ref={(node) => {
@@ -1560,7 +1657,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';
@@ -1568,8 +1671,33 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (placeholder) placeholder.style.display = 'none'; if (placeholder) placeholder.style.display = 'none';
}} }}
/> />
{selectedElementIds.length === 1 && (
<div
className="absolute inset-0 ps-resize-outline pointer-events-none z-[3]"
aria-hidden
/>
)}
</div> </div>
))} );
})}
{dragMultiSelectionBounds && (
<div
className="absolute ps-resize-outline pointer-events-none z-[5]"
style={{
left: `${mmToPx(dragMultiSelectionBounds.minX, displayZoom)}px`,
top: `${mmToPx(dragMultiSelectionBounds.minY, displayZoom)}px`,
width: `${mmToPx(
dragMultiSelectionBounds.maxX - dragMultiSelectionBounds.minX,
displayZoom
)}px`,
height: `${mmToPx(
dragMultiSelectionBounds.maxY - dragMultiSelectionBounds.minY,
displayZoom
)}px`,
}}
aria-hidden
/>
)}
</div> </div>
)} )}
{resizeFloatElements && resizeFloatElements.length > 0 && ( {resizeFloatElements && resizeFloatElements.length > 0 && (
@@ -1577,27 +1705,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>,
@@ -1629,7 +1764,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
<div <div
ref={canvasAreaRef} ref={canvasAreaRef}
className={`flex-1 ps-workspace-bg ps-design-canvas-touch flex items-center justify-center min-h-0 min-w-0 p-4 ${ className={`flex-1 ps-workspace-bg ps-design-canvas-touch flex items-center justify-center min-h-0 min-w-0 p-4 ${
allowInteractionOverflow ? 'overflow-visible' : 'overflow-auto' allowInteractionOverflow ? 'overflow-visible' : 'overflow-auto scrollbar-hide overscroll-contain'
}`} }`}
onPointerDown={handleWorkspacePointerDown} onPointerDown={handleWorkspacePointerDown}
onClick={(e) => { onClick={(e) => {
@@ -1653,20 +1788,27 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
> >
<div <div
ref={canvasRef} ref={canvasRef}
className="absolute inset-0 bg-white shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none overflow-hidden" className={`absolute inset-0 shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none overflow-hidden ${
showReferencePreview ? '' : 'bg-white'
}`}
> >
{/* <div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]"> {showReferencePreview && (
{template.width} mm <img
</div> src={template.previewReferenceBackground}
<div alt=""
className="absolute -left-8 top-0 text-[10px] font-mono text-[#aaa]" className="absolute inset-0 w-full h-full pointer-events-none select-none z-0"
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }} style={{
> objectFit: template.previewReferenceBackgroundFit ?? 'cover',
{template.height} mm opacity: Math.min(
</div> */} 1,
Math.max(0, (template.previewReferenceBackgroundOpacity ?? 100) / 100)
),
}}
/>
)}
{/* 整页统一渲染:半透明背景按图层层级与下方元素真实合成 */} {/* 整页统一渲染:半透明背景按图层层级与下方元素真实合成 */}
<div className="absolute inset-0 overflow-hidden pointer-events-none z-0"> <div className="absolute inset-0 overflow-hidden pointer-events-none z-[1]">
<CanvasLabelImage <CanvasLabelImage
widthMm={template.width} widthMm={template.width}
heightMm={template.height} heightMm={template.height}
@@ -1674,7 +1816,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
rowData={null} rowData={null}
rowIndex={0} rowIndex={0}
showBorder={false} showBorder={false}
transparentBackground={showReferencePreview}
variableDefaults={previewDefaults} variableDefaults={previewDefaults}
printColorMode={printColorMode}
renderScale={canvasRenderScale} renderScale={canvasRenderScale}
/> />
</div> </div>
@@ -1685,32 +1829,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
showUnselectedDim ? ' ps-unselected-dim-layer--visible' : '' showUnselectedDim ? ' ps-unselected-dim-layer--visible' : ''
}`} }`}
aria-hidden={!showUnselectedDim} aria-hidden={!showUnselectedDim}
> />
{overlayElements.map((element) => {
if (selectedElementIds.includes(element.id)) return null;
if (dragState?.type === 'drag' && dragMovingIdSet?.has(element.id)) return null;
const elemX = mmToPx(element.x, displayZoom);
const elemY = mmToPx(element.y, displayZoom);
const elemW = mmToPx(element.width, displayZoom);
const elemH = mmToPx(element.height, displayZoom);
return (
<div
key={`dim-${element.id}`}
className="ps-unselected-dim absolute"
style={{
left: `${elemX}px`,
top: `${elemY}px`,
width: `${elemW}px`,
height: `${elemH}px`,
transform: element.rotation
? `rotate(${element.rotation}deg)`
: undefined,
transformOrigin: 'center center',
}}
/>
);
})}
</div>
)} )}
{selectedLiftElements && ( {selectedLiftElements && (
@@ -1724,6 +1843,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
showBorder={false} showBorder={false}
transparentBackground transparentBackground
variableDefaults={previewDefaults} variableDefaults={previewDefaults}
printColorMode={printColorMode}
renderScale={canvasRenderScale} renderScale={canvasRenderScale}
/> />
</div> </div>
@@ -1762,6 +1882,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;
@@ -1821,6 +1944,21 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
</div> </div>
)} )}
{isBeingResized && (
<div className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]" aria-hidden />
)}
{isSelected &&
!hideSelectionChrome &&
!isLocked &&
selectedElementIds.length === 1 &&
!isBeingResized && (
<div
className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]"
aria-hidden
/>
)}
{showResizeHandles && ( {showResizeHandles && (
<> <>
<div <div
@@ -1900,6 +2038,25 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
</div> </div>
); );
})} })}
{multiSelectionBounds && (
<div
className="absolute ps-resize-outline pointer-events-none z-[1005]"
style={{
left: `${mmToPx(multiSelectionBounds.minX, displayZoom)}px`,
top: `${mmToPx(multiSelectionBounds.minY, displayZoom)}px`,
width: `${mmToPx(
multiSelectionBounds.maxX - multiSelectionBounds.minX,
displayZoom
)}px`,
height: `${mmToPx(
multiSelectionBounds.maxY - multiSelectionBounds.minY,
displayZoom
)}px`,
}}
aria-hidden
/>
)}
</div> </div>
</div> </div>
@@ -1907,9 +2064,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
{isMobileLayout ? ( {isMobileLayout ? (
<div className="mobile-design-toolstrip"> <div className="mobile-design-toolstrip">
<div className="mobile-design-toolstrip-tools"> <MobileMainToolButtons items={mobileToolItems} />
{mainToolButtons} <input
</div> ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageFile}
/>
{zoomControls} {zoomControls}
</div> </div>
) : ( ) : (
@@ -1926,18 +2088,21 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
<span> <span>
: {template.width} × {template.height} mm : {template.width} × {template.height} mm
</span> </span>
{selectedElemObj && ( {selectedElemObj && selectedElementIds.length === 1 && (
<span className="text-[#31a8ff]"> <span className="text-[#31a8ff]">
| {selectedElemObj.name} X:{selectedElemObj.x} Y:{selectedElemObj.y} W: | {selectedElemObj.name} X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
{selectedElemObj.width} H:{selectedElemObj.height} mm {selectedElemObj.width} H:{selectedElemObj.height} mm
</span> </span>
)} )}
{selectedElementIds.length > 1 && ( {multiSelectionBounds && (
<span className="text-emerald-400">| {selectedElementIds.length} </span> <span className="text-emerald-400">
| {selectedElementIds.length} X:{multiSelectionBounds.minX} Y:
{multiSelectionBounds.minY} mm
</span>
)} )}
{activeTool === 'move' && ( {activeTool === 'move' && (
<span className="text-[#888] hidden sm:inline"> <span className="text-[#888] hidden sm:inline">
| · Shift/Ctrl+ · Ctrl+A | · Shift/Ctrl+ · Ctrl+A · Ctrl+C/V
</span> </span>
)} )}
</> </>

View File

@@ -0,0 +1,240 @@
import React, { useState } from 'react';
import { Calculator, ChevronDown, ChevronUp } from 'lucide-react';
import type { PaperConfig, PaperType } from '../types';
import { computeLabelSizeFromLayout, DEFAULT_PAPER_CONFIG } from '../utils';
import { CommitInput } from './CommitInput';
import { FieldSelect } from './PsSelect';
import { useAppDialog } from './AppDialog';
const fieldClass =
'w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono focus:border-indigo-400 focus:outline-none';
const labelClass = 'block text-[11px] font-semibold text-gray-500 mb-1.5';
interface LabelLayoutSizeCalculatorProps {
onCalculated: (result: { width: number; height: number; paper: PaperConfig }) => void;
}
function handlePaperTypeChange(paper: PaperConfig, type: PaperType): PaperConfig {
let width = 210;
let height = 297;
if (type === 'A5') {
width = 148;
height = 210;
} else if (type === 'A6') {
width = 105;
height = 148;
} else if (type === 'custom') {
width = paper.width;
height = paper.height;
}
return { ...paper, type, width, height };
}
export const LabelLayoutSizeCalculator: React.FC<LabelLayoutSizeCalculatorProps> = ({
onCalculated,
}) => {
const { showAlert } = useAppDialog();
const [open, setOpen] = useState(false);
const [paper, setPaper] = useState<PaperConfig>(DEFAULT_PAPER_CONFIG);
const [cols, setCols] = useState('3');
const [rows, setRows] = useState('8');
const patchPaper = (patch: Partial<PaperConfig>) => {
setPaper((prev) => ({ ...prev, ...patch }));
};
const handleCalculate = async () => {
const colCount = Math.max(1, Math.min(99, Number(cols) || 0));
const rowCount = Math.max(1, Math.min(99, Number(rows) || 0));
if (!cols.trim() || !rows.trim() || colCount < 1 || rowCount < 1) {
await showAlert('请输入有效的排版列数与行数。', { variant: 'warning' });
return;
}
const size = computeLabelSizeFromLayout(paper, colCount, rowCount);
if (!size) {
await showAlert(
'无法根据当前纸张、边距与间距计算出有效标签尺寸(单边至少 10 mm。请调整参数后重试。',
{ variant: 'warning' }
);
return;
}
onCalculated({ width: size.width, height: size.height, paper });
setOpen(false);
};
return (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-gray-50 hover:bg-gray-100 text-left transition cursor-pointer"
>
<span className="flex items-center gap-2 text-[12px] font-medium text-gray-700">
<Calculator className="w-4 h-4 text-indigo-500" />
</span>
{open ? (
<ChevronUp className="w-4 h-4 text-gray-400 shrink-0" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400 shrink-0" />
)}
</button>
{open && (
<div className="px-3 py-3 space-y-3 border-t border-gray-200 bg-white">
<p className="text-[11px] text-gray-500 leading-relaxed">
n×m
</p>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className={labelClass}></label>
<FieldSelect
value={paper.type}
onChange={(e) => patchPaper(handlePaperTypeChange(paper, e.target.value as PaperType))}
className={fieldClass}
>
<option value="A4">A4 (210×297 mm)</option>
<option value="A5">A5 (148×210 mm)</option>
<option value="A6">A6 (105×148 mm)</option>
<option value="custom"></option>
</FieldSelect>
</div>
{paper.type === 'custom' && (
<>
<div>
<label className={labelClass}> (mm)</label>
<CommitInput
type="number"
min="10"
value={paper.width}
onCommit={(val) => patchPaper({ width: Math.max(10, Number(val) || 10) })}
className={fieldClass}
/>
</div>
<div>
<label className={labelClass}> (mm)</label>
<CommitInput
type="number"
min="10"
value={paper.height}
onCommit={(val) => patchPaper({ height: Math.max(10, Number(val) || 10) })}
className={fieldClass}
/>
</div>
</>
)}
<div className="col-span-2">
<label className={labelClass}></label>
<div className="flex gap-2">
<button
type="button"
onClick={() => patchPaper({ orientation: 'portrait' })}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium border cursor-pointer ${
paper.orientation === 'portrait'
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
: 'bg-gray-50 border-gray-200 text-gray-600'
}`}
>
</button>
<button
type="button"
onClick={() => patchPaper({ orientation: 'landscape' })}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-medium border cursor-pointer ${
paper.orientation === 'landscape'
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
: 'bg-gray-50 border-gray-200 text-gray-600'
}`}
>
</button>
</div>
</div>
<div>
<label className={labelClass}> n</label>
<CommitInput
type="number"
min="1"
max="99"
value={cols}
onCommit={(val) => setCols(val)}
className={fieldClass}
/>
</div>
<div>
<label className={labelClass}> m</label>
<CommitInput
type="number"
min="1"
max="99"
value={rows}
onCommit={(val) => setRows(val)}
className={fieldClass}
/>
</div>
{(
[
['上边距', 'marginTop', paper.marginTop],
['下边距', 'marginBottom', paper.marginBottom],
['左边距', 'marginLeft', paper.marginLeft],
['右边距', 'marginRight', paper.marginRight],
] as const
).map(([label, key, val]) => (
<div key={key}>
<label className={labelClass}>{label} (mm)</label>
<CommitInput
type="number"
min="0"
max="100"
value={val}
onCommit={(v) => patchPaper({ [key]: Math.max(0, Number(v) || 0) })}
className={fieldClass}
/>
</div>
))}
<div>
<label className={labelClass}> (mm)</label>
<CommitInput
type="number"
min="0"
max="50"
value={paper.columnGap}
onCommit={(val) => patchPaper({ columnGap: Math.max(0, Number(val) || 0) })}
className={fieldClass}
/>
</div>
<div>
<label className={labelClass}> (mm)</label>
<CommitInput
type="number"
min="0"
max="50"
value={paper.rowGap}
onCommit={(val) => patchPaper({ rowGap: Math.max(0, Number(val) || 0) })}
className={fieldClass}
/>
</div>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={() => void handleCalculate()}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-xs font-semibold cursor-pointer"
>
</button>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { Image as ImageIcon, Upload } from 'lucide-react';
import type { LabelTemplate } from '../types';
import { FieldSelect } from './PsSelect';
interface LayoutPageBackgroundFieldsProps {
template: LabelTemplate;
onChangeTemplate: (updated: LabelTemplate) => void;
}
export const LayoutPageBackgroundFields: React.FC<LayoutPageBackgroundFieldsProps> = ({
template,
onChangeTemplate,
}) => (
<div className="space-y-3">
<div className="ps-section-title">
<ImageIcon className="w-3 h-3" />
</div>
<p className="text-[9px] text-[#777] leading-relaxed">
PDF /
</p>
<label className="ps-btn text-[10px] cursor-pointer inline-flex">
<Upload className="w-3.5 h-3.5" />
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () =>
onChangeTemplate({
...template,
layoutPageBackground: reader.result as string,
layoutPageBackgroundOpacity: template.layoutPageBackgroundOpacity ?? 100,
layoutPageBackgroundFit: template.layoutPageBackgroundFit ?? 'cover',
enableLayoutPageBackground: true,
});
reader.readAsDataURL(file);
e.target.value = '';
}}
/>
</label>
{template.layoutPageBackground && (
<>
<div className="border border-[#3a3a3a] bg-[#1e1e1e] p-2">
<img
src={template.layoutPageBackground}
alt="页面背景预览"
className="block max-h-24 mx-auto object-contain"
/>
</div>
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={template.layoutPageBackgroundFit ?? 'cover'}
onChange={(e) =>
onChangeTemplate({
...template,
layoutPageBackgroundFit: e.target.value as 'contain' | 'cover' | 'fill',
})
}
className="ps-field text-[11px]"
>
<option value="cover"> (cover)</option>
<option value="contain"> (contain)</option>
<option value="fill"> (fill)</option>
</FieldSelect>
</div>
<div>
<label className="ps-field-label">
({template.layoutPageBackgroundOpacity ?? 100}%)
</label>
<input
type="range"
min="0"
max="100"
step="1"
value={template.layoutPageBackgroundOpacity ?? 100}
onChange={(e) =>
onChangeTemplate({
...template,
layoutPageBackgroundOpacity: Number(e.target.value),
})
}
className="w-full accent-[#31a8ff]"
/>
</div>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={template.enableLayoutPageBackground !== false}
onChange={(e) =>
onChangeTemplate({
...template,
enableLayoutPageBackground: e.target.checked ? true : false,
})
}
className="ps-checkbox mt-0.5"
/>
<span className="text-xs text-[#ccc] leading-normal">
<span className="block text-[9px] text-[#777] mt-0.5">
</span>
</span>
</label>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={template.includeLayoutPageBackgroundInOutput ?? false}
onChange={(e) =>
onChangeTemplate({
...template,
includeLayoutPageBackgroundInOutput: e.target.checked ? true : undefined,
})
}
className="ps-checkbox mt-0.5"
/>
<span className="text-xs text-[#ccc] leading-normal">
PDF
</span>
</label>
<button
type="button"
onClick={() =>
onChangeTemplate({
...template,
layoutPageBackground: undefined,
layoutPageBackgroundOpacity: undefined,
layoutPageBackgroundFit: undefined,
enableLayoutPageBackground: undefined,
includeLayoutPageBackgroundInOutput: undefined,
})
}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer"
>
</button>
</>
)}
</div>
);

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react'; import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react';
import { LabelTemplate, PaperConfig } from '../types'; import { LabelTemplate, PaperConfig } from '../types';
import type { TableCellCoord } from '../tableUtils'; import type { TableCellCoord } from '../tableUtils';
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
import { LabelDesigner } from './LabelDesigner'; import { LabelDesigner } from './LabelDesigner';
import { PropSidebar, MobilePropModule } from './PropSidebar'; import { PropSidebar, MobilePropModule } from './PropSidebar';
@@ -19,6 +20,7 @@ interface MobileDesignViewProps {
onRedo: () => void; onRedo: () => void;
onBack: () => void; onBack: () => void;
onExport: () => void; onExport: () => void;
elementClipboard: ElementClipboardActions;
} }
export const MobileDesignView: React.FC<MobileDesignViewProps> = ({ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
@@ -35,6 +37,7 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
onRedo, onRedo,
onBack, onBack,
onExport, onExport,
elementClipboard,
}) => { }) => {
const [mobileModule, setMobileModule] = useState<MobilePropModule>('properties'); const [mobileModule, setMobileModule] = useState<MobilePropModule>('properties');
@@ -90,6 +93,7 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
selectedTableCells={selectedTableCells} selectedTableCells={selectedTableCells}
onSelectTableCells={onSelectTableCells} onSelectTableCells={onSelectTableCells}
suppressSelectionChrome={mobileModule === 'nudge'} suppressSelectionChrome={mobileModule === 'nudge'}
elementClipboard={elementClipboard}
/> />
</div> </div>
@@ -107,6 +111,7 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
paper={paper} paper={paper}
mobileModule={mobileModule} mobileModule={mobileModule}
onMobileModuleChange={setMobileModule} onMobileModuleChange={setMobileModule}
elementClipboard={elementClipboard}
/> />
</div> </div>
</div> </div>

View File

@@ -9,9 +9,11 @@ import {
} from '../types'; } from '../types';
import { PaperSettingsPanel, PageInput } from './PreviewGrid'; import { PaperSettingsPanel, PageInput } from './PreviewGrid';
import { FieldSelect } from './PsSelect'; import { FieldSelect } from './PsSelect';
import { LayoutPageBackgroundFields } from './LayoutPageBackgroundFields';
interface MobileExportLayoutPanelProps { interface MobileExportLayoutPanelProps {
template: LabelTemplate; template: LabelTemplate;
onChangeTemplate: (updated: LabelTemplate) => void;
paper: PaperConfig; paper: PaperConfig;
onChangePaper: (paper: PaperConfig) => void; onChangePaper: (paper: PaperConfig) => void;
rows: RecordRow[]; rows: RecordRow[];
@@ -22,6 +24,7 @@ interface MobileExportLayoutPanelProps {
export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = ({ export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = ({
template, template,
onChangeTemplate,
paper, paper,
onChangePaper, onChangePaper,
rows, rows,
@@ -40,6 +43,9 @@ export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = (
exportRange={exportRange} exportRange={exportRange}
totalRowCount={rows.length} totalRowCount={rows.length}
/> />
<div className="ps-section-divider">
<LayoutPageBackgroundFields template={template} onChangeTemplate={onChangeTemplate} />
</div>
<div className="ps-section-divider space-y-2"> <div className="ps-section-divider space-y-2">
<div className="ps-section-title"> <div className="ps-section-title">
<Settings className="w-3 h-3" /> <Settings className="w-3 h-3" />

View File

@@ -15,6 +15,8 @@ import {
ExportRangeConfig, ExportRangeConfig,
PaperConfig, PaperConfig,
CutLineConfig, CutLineConfig,
ElementVisibilityMode,
VisibilityRule,
} from '../types'; } from '../types';
import { isVariableMappingConfigured, type PrintableLabel } from '../utils'; import { isVariableMappingConfigured, type PrintableLabel } from '../utils';
import { DataImporter, downloadDataTemplate } from './DataImporter'; import { DataImporter, downloadDataTemplate } from './DataImporter';
@@ -38,6 +40,7 @@ const MOBILE_EXPORT_MODULES: {
interface MobileExportViewProps { interface MobileExportViewProps {
template: LabelTemplate; template: LabelTemplate;
onChangeTemplate: (updated: LabelTemplate) => void;
templateName: string; templateName: string;
templateVariables: string[]; templateVariables: string[];
importData: ImportData | null; importData: ImportData | null;
@@ -51,6 +54,7 @@ interface MobileExportViewProps {
rows: RecordRow[]; rows: RecordRow[];
draftExportRange: ExportRangeConfig; draftExportRange: ExportRangeConfig;
onChangeDraftExportRange: (range: ExportRangeConfig) => void; onChangeDraftExportRange: (range: ExportRangeConfig) => void;
onChangeDataFilterRules: (rules: VisibilityRule[]) => void;
cutLine: CutLineConfig; cutLine: CutLineConfig;
onChangeCutLine: (cutLine: CutLineConfig) => void; onChangeCutLine: (cutLine: CutLineConfig) => void;
printablePages: (PrintableLabel | null)[][]; printablePages: (PrintableLabel | null)[][];
@@ -66,16 +70,22 @@ interface MobileExportViewProps {
activeRowIndex: number; activeRowIndex: number;
onSelectRowIndex: (idx: number) => void; onSelectRowIndex: (idx: number) => void;
pdfGenerating: boolean; pdfGenerating: boolean;
imagesZipGenerating?: boolean;
printing: boolean; printing: boolean;
onBack: () => void; onBack: () => void;
onExportPdf: () => void; onExportPdf: () => void;
onPrint: (printerId: string) => void; onPrint: (printerId: string) => void;
onExportImages?: () => void;
onChangeExportImageFileNamePattern?: (pattern: string) => void;
onChangeExportImageFilterMode?: (mode: ElementVisibilityMode) => void;
onChangeExportImageFilterRules?: (rules: VisibilityRule[]) => void;
printDisabled: boolean; printDisabled: boolean;
printDisabledReason?: string | null; imagesExporting?: boolean;
} }
export const MobileExportView: React.FC<MobileExportViewProps> = ({ export const MobileExportView: React.FC<MobileExportViewProps> = ({
template, template,
onChangeTemplate,
templateName, templateName,
templateVariables, templateVariables,
importData, importData,
@@ -89,6 +99,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
rows, rows,
draftExportRange, draftExportRange,
onChangeDraftExportRange, onChangeDraftExportRange,
onChangeDataFilterRules,
cutLine, cutLine,
onChangeCutLine, onChangeCutLine,
printablePages, printablePages,
@@ -104,12 +115,17 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
activeRowIndex, activeRowIndex,
onSelectRowIndex, onSelectRowIndex,
pdfGenerating, pdfGenerating,
imagesZipGenerating = false,
printing, printing,
onBack, onBack,
onExportPdf, onExportPdf,
onPrint, onPrint,
printDisabled, printDisabled,
printDisabledReason = null, onExportImages,
imagesExporting = false,
onChangeExportImageFileNamePattern,
onChangeExportImageFilterMode,
onChangeExportImageFilterRules,
}) => { }) => {
const [mobileModule, setMobileModule] = useState<MobileExportModule>('data'); const [mobileModule, setMobileModule] = useState<MobileExportModule>('data');
@@ -128,7 +144,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
? '当前数据范围内无标签' ? '当前数据范围内无标签'
: null; : null;
const exportDisabled = !!exportBlockedReason || pdfGenerating; const exportDisabled = !!exportBlockedReason || pdfGenerating || imagesZipGenerating;
return ( return (
<div className="mobile-export-view mobile-design-view"> <div className="mobile-export-view mobile-design-view">
@@ -233,28 +249,27 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
templateVariables={templateVariables} templateVariables={templateVariables}
/> />
{hasData && ( {hasData && (
<> <div>
<div> <p className="text-[11px] font-semibold text-[#e8e8e8] mb-2"></p>
<p className="text-[11px] font-semibold text-[#e8e8e8] mb-2"></p> <VariableMappingPanel
<VariableMappingPanel
variant="ps"
variables={templateVariables}
columns={dataColumns}
mapping={variableColumnMapping}
onChangeMapping={onChangeMapping}
variableDefaults={template.variableDefaults}
/>
</div>
<DataRangeFields
variant="ps" variant="ps"
exportRange={draftExportRange} variables={templateVariables}
onChangeExportRange={onChangeDraftExportRange} columns={dataColumns}
rows={rows} mapping={variableColumnMapping}
paper={paper} onChangeMapping={onChangeMapping}
template={template} variableDefaults={template.variableDefaults}
/> />
</> </div>
)} )}
<DataRangeFields
variant="ps"
exportRange={draftExportRange}
onChangeExportRange={onChangeDraftExportRange}
onChangeDataFilterRules={onChangeDataFilterRules}
rows={rows}
paper={paper}
template={template}
/>
{/* {exportBlockedReason && !pdfGenerating && ( {/* {exportBlockedReason && !pdfGenerating && (
<p className="text-[10px] text-amber-500/90 text-center">{exportBlockedReason}</p> <p className="text-[10px] text-amber-500/90 text-center">{exportBlockedReason}</p>
)} */} )} */}
@@ -264,6 +279,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
<div className="ps-panel-body p-3 min-h-0"> <div className="ps-panel-body p-3 min-h-0">
<MobileExportLayoutPanel <MobileExportLayoutPanel
template={template} template={template}
onChangeTemplate={onChangeTemplate}
paper={paper} paper={paper}
onChangePaper={onChangePaper} onChangePaper={onChangePaper}
rows={rows} rows={rows}
@@ -278,9 +294,17 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
<PrintModule <PrintModule
variant="mobile" variant="mobile"
disabled={printDisabled} disabled={printDisabled}
disabledReason={printDisabledReason}
printing={printing} printing={printing}
onPrint={onPrint} onPrint={onPrint}
imagesExporting={imagesExporting}
onExportImages={onExportImages}
exportImageFileNamePattern={template.exportImageFileNamePattern ?? ''}
onChangeExportImageFileNamePattern={onChangeExportImageFileNamePattern}
exportImageFilterMode={template.exportImageFilterMode ?? 'always'}
exportImageFilterRules={template.exportImageFilterRules ?? []}
onChangeExportImageFilterMode={onChangeExportImageFilterMode}
onChangeExportImageFilterRules={onChangeExportImageFilterRules}
templateVariables={templateVariables}
/> />
</div> </div>
)} )}

View File

@@ -0,0 +1,123 @@
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { MoreHorizontal } from 'lucide-react';
import { AnchoredMenu } from './AnchoredMenu';
const TOOL_BTN_SIZE_PX = 40;
const TOOL_BTN_GAP_PX = 4;
const TOOL_BTN_SLOT_PX = TOOL_BTN_SIZE_PX + TOOL_BTN_GAP_PX;
export interface MobileToolButtonItem {
id: string;
icon: React.ReactNode;
label: string;
shortcut?: string;
active?: boolean;
onClick: () => void;
}
interface MobileMainToolButtonsProps {
items: MobileToolButtonItem[];
}
function computeVisibleCount(containerWidth: number, total: number): number {
if (total <= 0 || containerWidth <= 0) return total;
if (containerWidth >= total * TOOL_BTN_SLOT_PX) return total;
const withMore = Math.floor((containerWidth - TOOL_BTN_SLOT_PX) / TOOL_BTN_SLOT_PX);
return Math.max(1, Math.min(withMore, total - 1));
}
function reorderItemsForActive(items: MobileToolButtonItem[], visibleCount: number): MobileToolButtonItem[] {
if (visibleCount >= items.length) return items;
const activeIndex = items.findIndex((item) => item.active);
if (activeIndex < 0 || activeIndex < visibleCount) return items;
const next = [...items];
const [activeItem] = next.splice(activeIndex, 1);
next.splice(visibleCount - 1, 0, activeItem);
return next;
}
export const MobileMainToolButtons: React.FC<MobileMainToolButtonsProps> = ({ items }) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const moreButtonRef = useRef<HTMLButtonElement | null>(null);
const [visibleCount, setVisibleCount] = useState(items.length);
const [moreOpen, setMoreOpen] = useState(false);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const measure = () => {
setVisibleCount(computeVisibleCount(container.clientWidth, items.length));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(container);
return () => observer.disconnect();
}, [items.length]);
const orderedItems = useMemo(
() => reorderItemsForActive(items, visibleCount),
[items, visibleCount]
);
const visibleItems = orderedItems.slice(0, visibleCount);
const overflowItems = orderedItems.slice(visibleCount);
const overflowHasActive = overflowItems.some((item) => item.active);
const renderToolButton = (item: MobileToolButtonItem) => (
<button
key={item.id}
type="button"
className={`ps-tool-btn mobile-design-tool-btn ${item.active ? 'active' : ''}`}
onClick={item.onClick}
title={`${item.label}${item.shortcut ? ` (${item.shortcut})` : ''}`}
aria-label={item.label}
>
{item.icon}
</button>
);
return (
<div ref={containerRef} className="mobile-design-toolstrip-tools">
{visibleItems.map((item) => renderToolButton(item))}
{overflowItems.length > 0 && (
<>
<button
ref={moreButtonRef}
type="button"
className={`ps-tool-btn mobile-design-tool-btn`}
onClick={() => setMoreOpen((open) => !open)}
title="更多工具"
aria-label="更多工具"
aria-expanded={moreOpen}
>
<MoreHorizontal className="w-[18px] h-[18px]" />
</button>
<AnchoredMenu
open={moreOpen}
onClose={() => setMoreOpen(false)}
anchorRef={moreButtonRef}
className="mobile-toolstrip-overflow-menu"
>
{overflowItems.map((item) => (
<button
key={item.id}
type="button"
role="menuitem"
className={`mobile-toolstrip-overflow-menu-item${item.active ? ' active' : ''}`}
onClick={() => {
item.onClick();
setMoreOpen(false);
}}
>
<span className="mobile-toolstrip-overflow-menu-icon">{item.icon}</span>
<span>{item.label}</span>
</button>
))}
</AnchoredMenu>
</>
)}
</div>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useMemo, useState, useEffect, useRef } from 'react'; import React, { useMemo, useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { PaperConfig, LabelTemplate, RecordRow, PaperType, CutLineConfig, ExportRangeConfig } from '../types'; import { PaperConfig, LabelTemplate, RecordRow, PaperType, CutLineConfig, ExportRangeConfig, PrintColorMode, PRINT_COLOR_MODE_LABELS } from '../types';
import { import {
computeAutoLayoutPaper, computeAutoLayoutPaper,
DEFAULT_EXPORT_RANGE, DEFAULT_EXPORT_RANGE,
@@ -15,6 +15,7 @@ import {
type PrintableLabel, type PrintableLabel,
} from '../utils'; } from '../utils';
import { CanvasLabelImage } from './CanvasLabelImage'; import { CanvasLabelImage } from './CanvasLabelImage';
import { resolveReferenceBackgroundForOutput, resolveLayoutPageBackgroundForPreview } from '../labelRenderer';
import { CommitInput } from './CommitInput'; import { CommitInput } from './CommitInput';
import { useAppDialog } from './AppDialog'; import { useAppDialog } from './AppDialog';
import { FieldSelect } from './PsSelect'; import { FieldSelect } from './PsSelect';
@@ -65,7 +66,8 @@ const paperEquals = (a: PaperConfig, b: PaperConfig) =>
a.marginLeft === b.marginLeft && a.marginLeft === b.marginLeft &&
a.columnGap === b.columnGap && a.columnGap === b.columnGap &&
a.rowGap === b.rowGap && a.rowGap === b.rowGap &&
a.flow === b.flow; a.flow === b.flow &&
(a.printColorMode ?? 'cmyk') === (b.printColorMode ?? 'cmyk');
export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
paper, paper,
@@ -130,7 +132,14 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
const rangeForStats = exportRange ?? DEFAULT_EXPORT_RANGE; const rangeForStats = exportRange ?? DEFAULT_EXPORT_RANGE;
const draftLayoutStats = useMemo( const draftLayoutStats = useMemo(
() => buildPrintablePages(rows, draftPaper, template, rangeForStats), () =>
buildPrintablePages(
rows,
draftPaper,
template,
rangeForStats,
template.variableDefaults ?? null
),
[rows, draftPaper, template, rangeForStats] [rows, draftPaper, template, rangeForStats]
); );
@@ -511,6 +520,29 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
</div> </div>
</div> </div>
<div className="space-y-2">
<h4 className={sectionTitleClass}></h4>
<div>
<label className={labelClass}></label>
<FieldSelect
value={draftPaper.printColorMode ?? 'cmyk'}
onChange={(e) =>
patchDraftPaper({ printColorMode: e.target.value as PrintColorMode })
}
className={selectClass}
>
{(Object.keys(PRINT_COLOR_MODE_LABELS) as PrintColorMode[]).map((mode) => (
<option key={mode} value={mode}>
{PRINT_COLOR_MODE_LABELS[mode]}
</option>
))}
</FieldSelect>
<p className={`${isPs || isMobile ? 'text-[9px] text-[#666]' : 'text-[10px] text-gray-500'} mt-1 leading-relaxed`}>
/使 CMYK
</p>
</div>
</div>
</div> </div>
</div> </div>
{autoLayoutDialog} {autoLayoutDialog}
@@ -588,6 +620,24 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
const cutChannelXPx = cutChannel.x * previewScale; const cutChannelXPx = cutChannel.x * previewScale;
const cutChannelYPx = cutChannel.y * previewScale; const cutChannelYPx = cutChannel.y * previewScale;
const cutDash = formatCutLineSvgDashArray(cutLine, previewScale); const cutDash = formatCutLineSvgDashArray(cutLine, previewScale);
const referenceBackgroundRender = useMemo(
() => resolveReferenceBackgroundForOutput(template),
[
template.previewReferenceBackground,
template.previewReferenceBackgroundOpacity,
template.previewReferenceBackgroundFit,
template.includeReferenceBackgroundInOutput,
]
);
const layoutPageBackground = useMemo(
() => resolveLayoutPageBackgroundForPreview(template),
[
template.layoutPageBackground,
template.layoutPageBackgroundOpacity,
template.layoutPageBackgroundFit,
template.enableLayoutPageBackground,
]
);
const pageCutLines = useMemo( const pageCutLines = useMemo(
() => () =>
collectGridCutLinePositions( collectGridCutLinePositions(
@@ -688,7 +738,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
</div> </div>
<div <div
className={`flex-1 overflow-auto ps-workspace-bg min-h-0 ps-preview-workspace ${isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6' className={`flex-1 overflow-auto scrollbar-hide overscroll-contain ps-workspace-bg min-h-0 ps-preview-workspace ${isMobileVariant ? 'p-3 mobile-preview-workspace' : 'p-6'
}`} }`}
> >
{!previewReady ? ( {!previewReady ? (
@@ -704,17 +754,10 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
<div <div
className={ className={
isMobileVariant isMobileVariant
? 'flex flex-col items-center space-y-4 pb-4' ? 'flex flex-col items-center justify-center min-h-full w-full space-y-4 pb-4'
: 'grid pb-8 justify-items-center content-start w-full' : 'flex flex-wrap justify-center content-center w-full min-h-full pb-8'
}
style={
isMobileVariant
? undefined
: {
gridTemplateColumns: `repeat(auto-fill, minmax(${pageCardWidthPx}px, 1fr))`,
gap: `${previewPageGapPx}px`,
}
} }
style={isMobileVariant ? undefined : { gap: `${previewPageGapPx}px` }}
> >
{visiblePages.map((pageRows, pageIdx) => ( {visiblePages.map((pageRows, pageIdx) => (
<div <div
@@ -731,16 +774,32 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
</span> */} </span> */}
</div> </div>
<div <div
className="bg-white flex items-start justify-start relative select-none overflow-hidden shrink-0" className="bg-white relative select-none overflow-hidden shrink-0"
style={{ style={{
width: `${pageCardWidthPx}px`, width: `${pageCardWidthPx}px`,
height: `${currentPaperH * previewScale}px`, height: `${currentPaperH * previewScale}px`,
padding: `${paper.marginTop * previewScale}px ${paper.marginRight * previewScale}px ${paper.marginBottom * previewScale}px ${paper.marginLeft * previewScale}px`,
boxSizing: 'border-box',
}} }}
> >
{layoutPageBackground && (
<img
src={layoutPageBackground.background}
alt=""
aria-hidden
className="absolute inset-0 w-full h-full pointer-events-none z-0"
style={{
objectFit: layoutPageBackground.fit,
opacity: layoutPageBackground.opacity / 100,
}}
/>
)}
<div <div
className="grid bg-white relative" className="relative z-10 flex items-center justify-center h-full box-border"
style={{
padding: `${paper.marginTop * previewScale}px ${paper.marginRight * previewScale}px ${paper.marginBottom * previewScale}px ${paper.marginLeft * previewScale}px`,
}}
>
<div
className="grid bg-transparent relative"
style={{ style={{
gridTemplateColumns: `repeat(${gridInfo.cols}, ${labelWPx}px)`, gridTemplateColumns: `repeat(${gridInfo.cols}, ${labelWPx}px)`,
gridAutoRows: `${labelHPx}px`, gridAutoRows: `${labelHPx}px`,
@@ -810,8 +869,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
<div <div
key={cellIdx} key={cellIdx}
onClick={() => onSelectRowIndex(item.sourceIndex)} onClick={() => onSelectRowIndex(item.sourceIndex)}
className={`bg-white text-center relative flex flex-col justify-center cursor-pointer box-border ${isActive ? 'ring-2 ring-inset ring-[#31a8ff] z-10' : '' className={`bg-white text-center relative flex flex-col justify-center cursor-pointer box-border`}
}`}
style={{ style={{
width: `${labelWPx}px`, width: `${labelWPx}px`,
height: `${labelHPx}px`, height: `${labelHPx}px`,
@@ -828,12 +886,15 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
rowIndex={item.sourceIndex} rowIndex={item.sourceIndex}
showBorder={false} showBorder={false}
mode="output" mode="output"
printColorMode={paper.printColorMode ?? 'cmyk'}
{...referenceBackgroundRender}
/> />
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
</div>
</div> </div>
</div> </div>
))} ))}
@@ -871,7 +932,13 @@ export const PreviewGrid: React.FC<PreviewGridProps> = ({
activeRowIndex, activeRowIndex,
onSelectRowIndex, onSelectRowIndex,
}) => { }) => {
const layout = buildPrintablePages(rows, paper, template, DEFAULT_EXPORT_RANGE); const layout = buildPrintablePages(
rows,
paper,
template,
DEFAULT_EXPORT_RANGE,
template.variableDefaults ?? null
);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PaperSettingsPanel <PaperSettingsPanel

View File

@@ -1,7 +1,9 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Loader2, Printer, RefreshCw } from 'lucide-react'; import { Images, Loader2, Printer, RefreshCw } from 'lucide-react';
import type { ElementVisibilityMode, VisibilityRule } from '../types';
import { FieldSelect } from './PsSelect'; import { FieldSelect } from './PsSelect';
import { CommitInput } from './CommitInput'; import { CommitInput, CommitTextarea } from './CommitInput';
import { ExportImageFilterFields } from './ExportImageFilterFields';
import { import {
addCustomPrinter, addCustomPrinter,
DEFAULT_PRINTER_ID, DEFAULT_PRINTER_ID,
@@ -14,17 +16,33 @@ import {
interface PrintModuleProps { interface PrintModuleProps {
disabled: boolean; disabled: boolean;
disabledReason?: string | null;
printing: boolean; printing: boolean;
onPrint: (printerId: string) => void; onPrint: (printerId: string) => void;
imagesExporting?: boolean;
onExportImages?: () => void;
exportImageFileNamePattern?: string;
onChangeExportImageFileNamePattern?: (pattern: string) => void;
exportImageFilterMode?: ElementVisibilityMode;
exportImageFilterRules?: VisibilityRule[];
onChangeExportImageFilterMode?: (mode: ElementVisibilityMode) => void;
onChangeExportImageFilterRules?: (rules: VisibilityRule[]) => void;
templateVariables?: string[];
variant?: 'desktop' | 'mobile'; variant?: 'desktop' | 'mobile';
} }
export const PrintModule: React.FC<PrintModuleProps> = ({ export const PrintModule: React.FC<PrintModuleProps> = ({
disabled, disabled,
disabledReason,
printing, printing,
onPrint, onPrint,
imagesExporting = false,
onExportImages,
exportImageFileNamePattern = '',
onChangeExportImageFileNamePattern,
exportImageFilterMode = 'always',
exportImageFilterRules = [],
onChangeExportImageFilterMode,
onChangeExportImageFilterRules,
templateVariables = [],
variant = 'desktop', variant = 'desktop',
}) => { }) => {
const [printers, setPrinters] = useState<PrinterOption[]>([ const [printers, setPrinters] = useState<PrinterOption[]>([
@@ -73,6 +91,16 @@ export const PrintModule: React.FC<PrintModuleProps> = ({
const directAvailable = supportsDirectPrinting(printers); const directAvailable = supportsDirectPrinting(printers);
const selectedPrinter = printers.find((p) => p.id === selectedPrinterId); const selectedPrinter = printers.find((p) => p.id === selectedPrinterId);
const usesDirect = !!selectedPrinter?.direct; const usesDirect = !!selectedPrinter?.direct;
const exportBusy = printing || imagesExporting;
const appendFileNameToken = (token: string) => {
if (!onChangeExportImageFileNamePattern) return;
const current = exportImageFileNamePattern.trim();
const next = current
? `${current}${current.endsWith('_') || current.endsWith('-') || current.endsWith(' ') ? '' : '_'}${token}`
: token;
onChangeExportImageFileNamePattern(next);
};
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -92,7 +120,7 @@ export const PrintModule: React.FC<PrintModuleProps> = ({
value={selectedPrinterId} value={selectedPrinterId}
onChange={(e) => setSelectedPrinterId(e.target.value)} onChange={(e) => setSelectedPrinterId(e.target.value)}
className="ps-field text-[11px]" className="ps-field text-[11px]"
disabled={loadingPrinters || printing} disabled={loadingPrinters || exportBusy}
> >
{printers.map((printer) => ( {printers.map((printer) => (
<option key={printer.id} value={printer.id}> <option key={printer.id} value={printer.id}>
@@ -111,12 +139,12 @@ export const PrintModule: React.FC<PrintModuleProps> = ({
onCommit={setCustomPrinterName} onCommit={setCustomPrinterName}
placeholder="与系统中打印机名称一致" placeholder="与系统中打印机名称一致"
className="ps-field text-[11px] flex-1 min-w-0" className="ps-field text-[11px] flex-1 min-w-0"
disabled={printing} disabled={exportBusy}
/> />
<button <button
type="button" type="button"
onClick={handleAddCustomPrinter} onClick={handleAddCustomPrinter}
disabled={!customPrinterName.trim() || printing} disabled={!customPrinterName.trim() || exportBusy}
className="ps-btn text-[10px] shrink-0 disabled:opacity-40 disabled:cursor-not-allowed" className="ps-btn text-[10px] shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
> >
@@ -133,16 +161,12 @@ export const PrintModule: React.FC<PrintModuleProps> = ({
: '当前浏览器不支持枚举打印机;打印时将打开系统对话框,请在对话框中选择对应打印机。已保存的名称便于下次快速识别。'} : '当前浏览器不支持枚举打印机;打印时将打开系统对话框,请在对话框中选择对应打印机。已保存的名称便于下次快速识别。'}
</p> </p>
{disabledReason && disabled && (
<p className="text-[10px] text-amber-500/90">{disabledReason}</p>
)}
<button <button
type="button" type="button"
onClick={handlePrint} onClick={handlePrint}
disabled={disabled || printing || loadingPrinters} disabled={disabled || exportBusy || loadingPrinters}
className={`w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold transition cursor-pointer ${ className={`w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold transition cursor-pointer ${
!disabled && !printing && !loadingPrinters !disabled && !exportBusy && !loadingPrinters
? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white' ? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white'
: 'bg-[#444] text-[#666] cursor-not-allowed' : 'bg-[#444] text-[#666] cursor-not-allowed'
}`} }`}
@@ -154,6 +178,98 @@ export const PrintModule: React.FC<PrintModuleProps> = ({
)} )}
{printing ? '准备打印…' : variant === 'mobile' ? '打印' : '开始打印'} {printing ? '准备打印…' : variant === 'mobile' ? '打印' : '开始打印'}
</button> </button>
{onExportImages && (
<>
<div className="border-t border-[#3a3a3a] pt-3 space-y-2">
<p className="text-[10px] text-[#666] leading-relaxed">
ZIP
</p>
{onChangeExportImageFileNamePattern && (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<label className="ps-field-label mb-0"></label>
<button
type="button"
disabled={!exportImageFileNamePattern || exportBusy}
onClick={() => onChangeExportImageFileNamePattern('')}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
>
</button>
</div>
<CommitTextarea
value={exportImageFileNamePattern}
onCommit={onChangeExportImageFileNamePattern}
placeholder="留空则按 001、002… 命名"
rows={3}
className="ps-field text-[11px] font-mono resize-y min-h-[72px]"
disabled={exportBusy}
/>
<p className="text-[9px] text-[#777] leading-relaxed">
使 {'{#SEQ}'}{'{#INDEX}'}{'{变量名}'}{'{#SEQ:4}'}
</p>
<div className="flex flex-wrap gap-1">
<button
type="button"
onClick={() => appendFileNameToken('{#SEQ}')}
disabled={exportBusy}
className="ps-btn text-[9px] font-mono"
>
{'{#SEQ}'}
</button>
<button
type="button"
onClick={() => appendFileNameToken('{#INDEX}')}
disabled={exportBusy}
className="ps-btn text-[9px] font-mono"
>
{'{#INDEX}'}
</button>
{templateVariables.map((variable) => (
<button
key={variable}
type="button"
onClick={() => appendFileNameToken(`{${variable}}`)}
disabled={exportBusy}
className="ps-btn text-[9px] font-mono"
>
{`{${variable}}`}
</button>
))}
</div>
</div>
)}
{onChangeExportImageFilterMode && onChangeExportImageFilterRules && (
<ExportImageFilterFields
filterMode={exportImageFilterMode}
filterRules={exportImageFilterRules}
templateVariables={templateVariables}
disabled={exportBusy}
onChangeFilterMode={onChangeExportImageFilterMode}
onChangeFilterRules={onChangeExportImageFilterRules}
/>
)}
<button
type="button"
onClick={onExportImages}
disabled={disabled || exportBusy || loadingPrinters}
className={`w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold transition cursor-pointer ${
!disabled && !exportBusy && !loadingPrinters
? 'bg-[#383838] hover:bg-[#444] text-[#e8e8e8] border border-[#4a4a4a]'
: 'bg-[#444] text-[#666] cursor-not-allowed border border-[#333]'
}`}
>
{imagesExporting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
) : (
<Images className="w-3.5 h-3.5 shrink-0" />
)}
{imagesExporting ? '正在导出图片…' : '批量保存为图片'}
</button>
</div>
</>
)}
</div> </div>
); );
}; };

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'; import React, { useState, useEffect, useMemo, useRef } from 'react';
import { LabelTemplate, TemplateElement, PaperConfig, VisibilityRule } from '../types'; import { LabelTemplate, TemplateElement, PaperConfig, VisibilityRule, ElementMaskConfig } from '../types';
import { import {
extractTemplateVariables, extractTemplateVariables,
extractVariablesFromContent, extractVariablesFromContent,
@@ -7,14 +7,20 @@ import {
isTemplateVariableInUse, isTemplateVariableInUse,
removeUnusedTemplateVariable, removeUnusedTemplateVariable,
resolveVisibilityRules, resolveVisibilityRules,
toggleVisibilityRuleEnabled,
withContentAndSyncedName,
} from '../utils'; } from '../utils';
import { VisibilityRuleDialog } from './VisibilityRuleDialog'; import { VisibilityRuleDialog } from './VisibilityRuleDialog';
import { ElementMaskDialog } from './ElementMaskDialog';
import { VisibilityRuleSummary } from './VisibilityRuleSummary'; import { VisibilityRuleSummary } from './VisibilityRuleSummary';
import { ColorValueField } from './ColorValueField';
import { ValueExtractFields } from './ValueExtractFields'; import { ValueExtractFields } from './ValueExtractFields';
import { TextDisplayAffixFields } from './TextDisplayAffixFields'; import { TextDisplayAffixFields } from './TextDisplayAffixFields';
import { import {
alignToSelectionBounds, alignToSelectionBounds,
alignSelectionToCanvas, alignSelectionToCanvas,
getSelectionBounds,
setSelectionOrigin,
tileSelectedEvenly, tileSelectedEvenly,
tileSelectedFlush, tileSelectedFlush,
type AlignMode, type AlignMode,
@@ -24,6 +30,7 @@ import { FieldSelect } from './PsSelect';
import { CommitInput } from './CommitInput'; import { CommitInput } from './CommitInput';
import { useAppDialog } from './AppDialog'; import { useAppDialog } from './AppDialog';
import { useIsNarrowScreen } from '../hooks/useIsMobile'; import { useIsNarrowScreen } from '../hooks/useIsMobile';
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
import { import {
TablePropertyFields, TablePropertyFields,
TableCellContentFields, TableCellContentFields,
@@ -31,6 +38,7 @@ import {
} from './TablePropertyFields'; } from './TablePropertyFields';
import type { TableCellCoord } from '../tableUtils'; import type { TableCellCoord } from '../tableUtils';
import { expandTableForPadding } from '../tableUtils'; import { expandTableForPadding } from '../tableUtils';
import { isElementMaskActive } from '../elementMask';
import { import {
Sliders, Sliders,
LayoutGrid, LayoutGrid,
@@ -45,6 +53,8 @@ import {
HelpCircle, HelpCircle,
Lock, Lock,
Unlock, Unlock,
Eye,
EyeOff,
ArrowUp, ArrowUp,
ArrowDown, ArrowDown,
ArrowLeft, ArrowLeft,
@@ -52,6 +62,7 @@ import {
Move, Move,
Layers, Layers,
Trash2, Trash2,
Copy,
AlignHorizontalJustifyStart, AlignHorizontalJustifyStart,
AlignHorizontalJustifyCenter, AlignHorizontalJustifyCenter,
AlignHorizontalJustifyEnd, AlignHorizontalJustifyEnd,
@@ -71,6 +82,7 @@ import {
Image as ImageIcon, Image as ImageIcon,
Upload, Upload,
Table2, Table2,
Blend,
} from 'lucide-react'; } from 'lucide-react';
const PANEL_COLLAPSE_STORAGE_KEY = 'label_designer_panel_collapse_v2'; const PANEL_COLLAPSE_STORAGE_KEY = 'label_designer_panel_collapse_v2';
@@ -179,13 +191,14 @@ interface PropSidebarProps {
variant?: 'desktop' | 'mobile'; variant?: 'desktop' | 'mobile';
mobileModule?: MobilePropModule; mobileModule?: MobilePropModule;
onMobileModuleChange?: (module: MobilePropModule) => void; onMobileModuleChange?: (module: MobilePropModule) => void;
elementClipboard?: ElementClipboardActions;
} }
const MOBILE_PROP_MODULES: { id: MobilePropModule; label: string; icon: React.ReactNode }[] = [ const MOBILE_PROP_MODULES: { id: MobilePropModule; label: string; icon: React.ReactNode }[] = [
{ id: 'properties', label: '属性', icon: <Sliders className="w-4 h-4" /> }, { id: 'properties', label: '属性', icon: <Sliders className="w-4 h-4" /> },
{ id: 'nudge', label: '微调', icon: <Move className="w-4 h-4" /> },
{ id: 'content', label: '内容', icon: <Link2 className="w-4 h-4" /> }, { id: 'content', label: '内容', icon: <Link2 className="w-4 h-4" /> },
{ id: 'layers', label: '图层', icon: <Layers className="w-4 h-4" /> }, { id: 'layers', label: '图层', icon: <Layers className="w-4 h-4" /> },
{ id: 'nudge', label: '微调', icon: <Move className="w-4 h-4" /> },
]; ];
export const PropSidebar: React.FC<PropSidebarProps> = ({ export const PropSidebar: React.FC<PropSidebarProps> = ({
@@ -201,8 +214,11 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
variant = 'desktop', variant = 'desktop',
mobileModule: mobileModuleProp, mobileModule: mobileModuleProp,
onMobileModuleChange, onMobileModuleChange,
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);
@@ -210,6 +226,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
const [visibilityRuleDialog, setVisibilityRuleDialog] = useState< const [visibilityRuleDialog, setVisibilityRuleDialog] = useState<
{ mode: 'add' } | { mode: 'edit'; index: number } | null { mode: 'add' } | { mode: 'edit'; index: number } | null
>(null); >(null);
const [maskDialogElementId, setMaskDialogElementId] = useState<string | null>(null);
const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState); const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState);
const [internalMobileModule, setInternalMobileModule] = const [internalMobileModule, setInternalMobileModule] =
useState<MobilePropModule>('properties'); useState<MobilePropModule>('properties');
@@ -238,27 +255,6 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
const templateVariables = useMemo(() => extractTemplateVariables(template), [template]); const templateVariables = useMemo(() => extractTemplateVariables(template), [template]);
const addableVisibilityVariables = useMemo(() => {
if (!selectedElem || !isConditionalVisibility(selectedElem)) return [];
const rules = resolveVisibilityRules(selectedElem);
return templateVariables.filter(
(v) => !rules.some((r) => r.target !== 'content' && r.variable === v)
);
}, [selectedElem, templateVariables]);
const visibilityRuleDialogVariableOptions = useMemo(() => {
if (!selectedElem || !visibilityRuleDialog) return [];
const rules = resolveVisibilityRules(selectedElem);
if (visibilityRuleDialog.mode === 'add') {
return addableVisibilityVariables;
}
const usedByOthers = rules
.filter((_, i) => i !== visibilityRuleDialog.index)
.filter((r) => r.target !== 'content')
.map((r) => r.variable);
return templateVariables.filter((v) => !usedByOthers.includes(v));
}, [selectedElem, visibilityRuleDialog, addableVisibilityVariables, templateVariables]);
const editingVisibilityRule = useMemo(() => { const editingVisibilityRule = useMemo(() => {
if (!selectedElem || visibilityRuleDialog?.mode !== 'edit') return undefined; if (!selectedElem || visibilityRuleDialog?.mode !== 'edit') return undefined;
return resolveVisibilityRules(selectedElem)[visibilityRuleDialog.index]; return resolveVisibilityRules(selectedElem)[visibilityRuleDialog.index];
@@ -272,9 +268,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)),
}); });
}; };
@@ -286,6 +283,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
@@ -293,6 +295,24 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
.map((el) => el.id); .map((el) => el.id);
}, [selectedElementIds, template.elements]); }, [selectedElementIds, template.elements]);
const isMultiSelect = selectedElementIds.length >= 2;
const multiSelectionBounds = useMemo(() => {
if (!isMultiSelect) return null;
const selected = template.elements.filter(
(el) => selectedElementIds.includes(el.id) && !el.locked
);
if (selected.length < 2) return null;
return getSelectionBounds(selected);
}, [isMultiSelect, selectedElementIds, template.elements]);
const moveSelectedGroupTo = (newMinX: number, newMinY: number) => {
onChangeTemplate({
...template,
elements: setSelectionOrigin(template.elements, selectedElementIds, newMinX, newMinY),
});
};
const nudgeSelectedElements = (deltaX: number, deltaY: number) => { const nudgeSelectedElements = (deltaX: number, deltaY: number) => {
if (nudgeableElementIds.length === 0) return; if (nudgeableElementIds.length === 0) return;
const idSet = new Set(nudgeableElementIds); const idSet = new Set(nudgeableElementIds);
@@ -309,6 +329,19 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
}); });
}; };
const maskDialogElement = maskDialogElementId
? template.elements.find((el) => el.id === maskDialogElementId) ?? null
: null;
const updateElementMask = (elementId: string, mask: ElementMaskConfig | undefined) => {
onChangeTemplate({
...template,
elements: template.elements.map((el) =>
el.id === elementId ? { ...el, mask: mask || undefined } : el
),
});
};
const applyVisibilityRules = (next: VisibilityRule[]) => { const applyVisibilityRules = (next: VisibilityRule[]) => {
if (!selectedElem) return; if (!selectedElem) return;
handleElemChange({ handleElemChange({
@@ -418,7 +451,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 = () => {
@@ -479,10 +512,10 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<p className="text-[10px] text-[#888]"></p> <p className="text-[10px] text-[#888]"></p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-2 pt-2">
<div className="text-[10px] font-bold text-[#31a8ff]"></div> <div className="text-[10px] font-bold text-[#31a8ff]"></div>
<p className="text-[10px] text-[#666] leading-relaxed mb-3"> <p className="text-[10px] text-[#666] leading-relaxed">
</p> </p>
{templateVariables.map((v) => { {templateVariables.map((v) => {
const inUse = isTemplateVariableInUse(template, v); const inUse = isTemplateVariableInUse(template, v);
@@ -559,6 +592,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 };
@@ -566,6 +602,20 @@ 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 toggleLayerVisibility = (idx: number) => {
const list = template.elements.map((el, i) => {
if (i !== idx) return el;
return { ...el, hidden: el.hidden ? undefined : true };
});
onChangeTemplate({ ...template, elements: list });
}; };
const deleteLayer = async (id: string) => { const deleteLayer = async (id: string) => {
@@ -583,8 +633,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));
@@ -615,9 +665,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)));
}; };
@@ -650,8 +701,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 = '';
}} }}
@@ -666,7 +716,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"
> >
@@ -674,7 +724,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={
@@ -747,7 +797,10 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
/> />
) : ( ) : (
<> <>
<ValueExtractFields elem={selectedElem} onChange={handleElemChange} /> <ValueExtractFields
config={selectedElem}
onChange={(patch) => handleElemChange({ ...selectedElem, ...patch })}
/>
{selectedElem.type === 'text' && ( {selectedElem.type === 'text' && (
<TextDisplayAffixFields elem={selectedElem} onChange={handleElemChange} /> <TextDisplayAffixFields elem={selectedElem} onChange={handleElemChange} />
)} )}
@@ -821,6 +874,13 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
rule={rule} rule={rule}
onEdit={() => setVisibilityRuleDialog({ mode: 'edit', index })} onEdit={() => setVisibilityRuleDialog({ mode: 'edit', index })}
onDelete={() => removeRule(index)} onDelete={() => removeRule(index)}
onToggleEnabled={() =>
setRules(
rules.map((item, i) =>
i === index ? toggleVisibilityRuleEnabled(item) : item
)
)
}
/> />
))} ))}
</div> </div>
@@ -843,8 +903,150 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
); );
}; };
const renderPreviewReferenceBackgroundFields = () => (
<div className="space-y-3">
<h5 className="ps-section-title">
<ImageIcon className="w-3.5 h-3.5" />
</h5>
<p className="text-[9px] text-[#777] leading-relaxed">
/
</p>
<label className="ps-btn text-[10px] cursor-pointer inline-flex">
<Upload className="w-3.5 h-3.5" />
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () =>
onChangeTemplate({
...template,
previewReferenceBackground: reader.result as string,
previewReferenceBackgroundOpacity:
template.previewReferenceBackgroundOpacity ?? 100,
previewReferenceBackgroundFit:
template.previewReferenceBackgroundFit ?? 'cover',
enablePreviewReferenceBackground: true,
});
reader.readAsDataURL(file);
e.target.value = '';
}}
/>
</label>
{template.previewReferenceBackground && (
<>
<div className="border border-[#3a3a3a] bg-[#1e1e1e] p-2">
<img
src={template.previewReferenceBackground}
alt="预览参考背景"
className="block max-h-24 mx-auto object-contain"
/>
</div>
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={template.previewReferenceBackgroundFit ?? 'cover'}
onChange={(e) =>
onChangeTemplate({
...template,
previewReferenceBackgroundFit: e.target.value as 'contain' | 'cover' | 'fill',
})
}
className="ps-field text-[11px]"
>
<option value="cover"> (cover)</option>
<option value="contain"> (contain)</option>
<option value="fill"> (fill)</option>
</FieldSelect>
</div>
<div>
<label className="ps-field-label">
({template.previewReferenceBackgroundOpacity ?? 100}%)
</label>
<input
type="range"
min="0"
max="100"
step="1"
value={template.previewReferenceBackgroundOpacity ?? 100}
onChange={(e) =>
onChangeTemplate({
...template,
previewReferenceBackgroundOpacity: Number(e.target.value),
})
}
className="w-full accent-[#31a8ff]"
/>
</div>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={template.enablePreviewReferenceBackground !== false}
onChange={(e) =>
onChangeTemplate({
...template,
enablePreviewReferenceBackground: e.target.checked ? true : false,
})
}
className="ps-checkbox mt-0.5"
/>
<span className="text-xs text-[#ccc] leading-normal">
<span className="block text-[9px] text-[#777] mt-0.5">
</span>
</span>
</label>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={template.includeReferenceBackgroundInOutput ?? false}
onChange={(e) =>
onChangeTemplate({
...template,
includeReferenceBackgroundInOutput: e.target.checked ? true : undefined,
})
}
className="ps-checkbox mt-0.5"
/>
<span className="text-xs text-[#ccc] leading-normal">
<span className="block text-[9px] text-[#777] mt-0.5">
PDF
</span>
</span>
</label>
<button
type="button"
onClick={() =>
onChangeTemplate({
...template,
previewReferenceBackground: undefined,
previewReferenceBackgroundOpacity: undefined,
previewReferenceBackgroundFit: undefined,
enablePreviewReferenceBackground: undefined,
includeReferenceBackgroundInOutput: undefined,
})
}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer"
>
</button>
</>
)}
</div>
);
const renderPropertiesPanelBody = () => ( const renderPropertiesPanelBody = () => (
<> <>
{!selectedElem && (
<div className="pb-3 border-b border-[#3a3a3a]">{renderPreviewReferenceBackgroundFields()}</div>
)}
{/* Label Canvas Dimensions */} {/* Label Canvas Dimensions */}
{!selectedElem && ( {!selectedElem && (
<div className="space-y-4"> <div className="space-y-4">
@@ -968,7 +1170,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>
@@ -1151,22 +1359,19 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
{/* Text color */} {/* Text color */}
<div className="border-t border-[#3a3a3a] pt-2.5"> <div className="border-t border-[#3a3a3a] pt-2.5">
<label className="block text-[10px] font-semibold text-[#777] mb-1"></label> <ColorValueField
<div className="flex gap-1 items-center"> label="文本颜色"
<input value={selectedElem.textColor || ''}
type="color" onChange={(val) =>
value={selectedElem.textColor || '#111827'} handleElemChange({
onChange={(e) => handleElemChange({ ...selectedElem, textColor: e.target.value })} ...selectedElem,
className="color-input shrink-0" textColor: val || undefined,
/> })
<input }
type="text" variableOptions={templateVariables}
placeholder="#111827" defaultColor="#111827"
value={selectedElem.textColor || ''} placeholder="#111827"
onChange={(e) => handleElemChange({ ...selectedElem, textColor: e.target.value })} />
className="ps-field font-mono text-[11px] flex-1 min-w-0"
/>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -1378,6 +1583,60 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
/> />
<span className="text-[12px] text-[#ccc]"></span> <span className="text-[12px] text-[#ccc]"></span>
</label> </label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="ps-field-label"></label>
<PropInput
type="number"
min="0"
max="99"
step="1"
value={selectedElem.textMaxLines ?? ''}
onCommit={(val) => {
const raw = String(val).trim();
if (!raw) {
handleElemChange({
...selectedElem,
textMaxLines: undefined,
textEllipsis: undefined,
});
return;
}
const n = Math.max(0, Math.min(99, Math.floor(Number(val) || 0)));
handleElemChange({
...selectedElem,
textMaxLines: n > 0 ? n : undefined,
textEllipsis: n > 0 ? selectedElem.textEllipsis : undefined,
});
}}
className="ps-field font-mono"
placeholder="不限"
/>
<p className="text-[9px] text-[#666] mt-0.5"></p>
</div>
<div className="flex items-end pb-5">
<label
className={`flex items-center gap-2 cursor-pointer ${
!selectedElem.textMaxLines ? 'opacity-40 cursor-not-allowed' : ''
}`}
>
<input
type="checkbox"
checked={selectedElem.textEllipsis === true}
disabled={!selectedElem.textMaxLines || selectedElem.textMaxLines <= 0}
onChange={(e) =>
handleElemChange({
...selectedElem,
textEllipsis: e.target.checked ? true : undefined,
})
}
className="ps-checkbox"
/>
<span className="text-[12px] text-[#ccc]"></span>
</label>
</div>
</div>
</div> </div>
)} )}
@@ -1489,39 +1748,60 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<div className="space-y-3"> <div className="space-y-3">
<h5 className="ps-section-title"> <h5 className="ps-section-title">
<Ruler className="w-3.5 h-3.5" /> <Ruler className="w-3.5 h-3.5" />
(mm) {isMultiSelect && multiSelectionBounds ? '选区位置 (mm)' : '位置与尺寸 (mm)'}
</h5> </h5>
{isMultiSelect && multiSelectionBounds && (
<p className="text-[10px] text-[#666] leading-snug">
XY
</p>
)}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="ps-field-label">X ()</label> <label className="ps-field-label">
{isMultiSelect ? 'X 坐标 (选区左边距)' : 'X 坐标 (左边距)'}
</label>
<PropInput <PropInput
type="number" type="number"
step="0.5" step="0.5"
value={selectedElem.x} value={isMultiSelect && multiSelectionBounds ? multiSelectionBounds.minX : selectedElem.x}
onCommit={(val) => { onCommit={(val) => {
const xVal = Math.round((Number(val) || 0) * 10) / 10;
if (isMultiSelect && multiSelectionBounds) {
moveSelectedGroupTo(xVal, multiSelectionBounds.minY);
return;
}
handleElemChange({ handleElemChange({
...selectedElem, ...selectedElem,
x: Math.round((Number(val) || 0) * 10) / 10, x: xVal,
}); });
}} }}
className="ps-field font-mono" className="ps-field font-mono"
/> />
</div> </div>
<div> <div>
<label className="ps-field-label">Y ()</label> <label className="ps-field-label">
{isMultiSelect ? 'Y 坐标 (选区顶边距)' : 'Y 坐标 (顶边距)'}
</label>
<PropInput <PropInput
type="number" type="number"
step="0.5" step="0.5"
value={selectedElem.y} value={isMultiSelect && multiSelectionBounds ? multiSelectionBounds.minY : selectedElem.y}
onCommit={(val) => { onCommit={(val) => {
const yVal = Math.round((Number(val) || 0) * 10) / 10;
if (isMultiSelect && multiSelectionBounds) {
moveSelectedGroupTo(multiSelectionBounds.minX, yVal);
return;
}
handleElemChange({ handleElemChange({
...selectedElem, ...selectedElem,
y: Math.round((Number(val) || 0) * 10) / 10, y: yVal,
}); });
}} }}
className="ps-field font-mono" className="ps-field font-mono"
/> />
</div> </div>
{!isMultiSelect && (
<>
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<PropInput <PropInput
@@ -1531,13 +1811,15 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
value={selectedElem.width} value={selectedElem.width}
onCommit={(val) => { onCommit={(val) => {
const wVal = Math.max(2, Number(val) || 2); const wVal = Math.max(2, Number(val) || 2);
let updated = { ...selectedElem, width: wVal };
if (selectedElem.type === 'qrcode') { if (selectedElem.type === 'qrcode') {
updated.height = wVal; handleElemChange({ ...selectedElem, width: wVal, height: wVal });
} else if (selectedElem.type === 'table') { } else if (selectedElem.type === 'table') {
updated = scaleTableElementSize(updated, wVal, updated.height); handleElemChange(
scaleTableElementSize(selectedElem, wVal, selectedElem.height)
);
} else {
handleElemChange({ ...selectedElem, width: wVal });
} }
handleElemChange(updated);
}} }}
className="ps-field font-mono" className="ps-field font-mono"
/> />
@@ -1582,6 +1864,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
/> />
</div> </div>
</div> </div>
</>
)}
</div> </div>
</div> </div>
@@ -1653,6 +1937,35 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
className="ps-field font-mono text-[11px] flex-1 min-w-0" className="ps-field font-mono text-[11px] flex-1 min-w-0"
/> />
</div> </div>
{templateVariables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{templateVariables.map((v) => (
<button
key={v}
type="button"
onClick={() => {
const token = `{${v}}`;
const current =
selectedElem.backgroundColor === 'transparent'
? ''
: selectedElem.backgroundColor || '';
handleElemChange({
...selectedElem,
backgroundColor: current.includes(token)
? current
: current
? `${current} ${token}`
: token,
backgroundOpacity: 100,
});
}}
className="ps-btn text-[9px] font-mono"
>
{`{${v}}`}
</button>
))}
</div>
)}
<div> <div>
<div className="flex justify-between items-center mb-1"> <div className="flex justify-between items-center mb-1">
<span className="text-[10px] text-[#777]"></span> <span className="text-[10px] text-[#777]"></span>
@@ -1791,35 +2104,20 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div> </div>
</div> </div>
<div> <ColorValueField
<label className="ps-field-label"></label> label="边框颜色"
<div className="flex gap-1 items-center"> value={selectedElem.borderColor || ''}
<input onChange={(val) =>
type="color" handleElemChange({
value={selectedElem.borderColor || '#111827'} ...selectedElem,
onChange={(e) => borderColor: val || undefined,
handleElemChange({ borderWidth: selectedElem.borderWidth || 0.3,
...selectedElem, })
borderColor: e.target.value, }
borderWidth: selectedElem.borderWidth || 0.3, variableOptions={templateVariables}
}) defaultColor="#111827"
} placeholder="#111827"
className="color-input shrink-0" />
/>
<input
type="text"
value={selectedElem.borderColor || ''}
placeholder="#111827"
onChange={(e) =>
handleElemChange({
...selectedElem,
borderColor: e.target.value,
})
}
className="ps-field font-mono text-[11px] flex-1 min-w-0"
/>
</div>
</div>
<div> <div>
<label className="ps-field-label"> (mm0 )</label> <label className="ps-field-label"> (mm0 )</label>
@@ -1957,6 +2255,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
const actualIdx = template.elements.length - 1 - revIdx; const actualIdx = template.elements.length - 1 - revIdx;
const isSelected = selectedElementIds.includes(elem.id); const isSelected = selectedElementIds.includes(elem.id);
const isLocked = !!elem.locked; const isLocked = !!elem.locked;
const isHidden = elem.hidden === true;
return ( return (
<div <div
@@ -1986,15 +2285,15 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
} }
} }
className={`ps-layer-item group ${isSelected && !isLocked ? 'selected' : ''} ${isLocked ? 'ps-layer-item-locked' : '' className={`ps-layer-item group ${isSelected && !isLocked ? 'selected' : ''} ${isLocked ? 'ps-layer-item-locked' : ''
}`} }${isHidden ? ' ps-layer-item-hidden' : ''}`}
> >
<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}
@@ -2002,10 +2301,15 @@ 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 ${
? 'opacity-100' isNarrowScreen || variant === 'mobile' || isLocked
: 'opacity-0 group-hover:opacity-100 transition-opacity' ? ''
}`} : 'ps-layer-item-actions--float'
} ${
isNarrowScreen || variant === 'mobile' || isLocked
? 'opacity-100'
: ''
}`}
> >
{!isLocked && ( {!isLocked && (
<> <>
@@ -2014,37 +2318,60 @@ 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>
</> </>
)} )}
{!isLocked && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setMaskDialogElementId(elem.id);
}}
title={isElementMaskActive(elem.mask) ? '编辑蒙版' : '添加蒙版'}
className={
isElementMaskActive(elem.mask)
? 'text-[#31a8ff]'
: 'text-[#666] hover:text-[#ccc]'
}
>
<Blend />
</button>
)}
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggleLayerVisibility(actualIdx); }}
title={isHidden ? '显示图层' : '隐藏图层'}
className={isHidden ? 'text-[#666]' : 'text-[#ccc] hover:text-white'}
>
{isHidden ? <EyeOff /> : <Eye />}
</button>
<button <button
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>
@@ -2114,14 +2441,16 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
{primaryElem && ( {primaryElem && (
<div className="mobile-nudge-info"> <div className="mobile-nudge-info">
<p className="text-[12px] font-medium text-[#ddd] truncate">{primaryElem.name}</p> <p className="text-[12px] font-medium text-[#ddd] truncate">
{nudgeableElementIds.length > 1 ? `已选 ${nudgeableElementIds.length} 个元素` : primaryElem.name}
</p>
<p className="text-[11px] font-mono text-[#31a8ff] mt-1"> <p className="text-[11px] font-mono text-[#31a8ff] mt-1">
X {primaryElem.x} · Y {primaryElem.y} mm {nudgeableElementIds.length > 1 && multiSelectionBounds
? `X ${multiSelectionBounds.minX} · Y ${multiSelectionBounds.minY} mm`
: `X ${primaryElem.x} · Y ${primaryElem.y} mm`}
</p> </p>
{nudgeableElementIds.length > 1 && ( {nudgeableElementIds.length > 1 && (
<p className="text-[10px] text-[#888] mt-1"> <p className="text-[10px] text-[#888] mt-1"></p>
{nudgeableElementIds.length}
</p>
)} )}
</div> </div>
)} )}
@@ -2136,16 +2465,29 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
open={visibilityRuleDialog !== null} open={visibilityRuleDialog !== null}
mode={visibilityRuleDialog?.mode ?? 'add'} mode={visibilityRuleDialog?.mode ?? 'add'}
initialRule={editingVisibilityRule} initialRule={editingVisibilityRule}
variableOptions={visibilityRuleDialogVariableOptions} variableOptions={templateVariables}
onClose={() => setVisibilityRuleDialog(null)} onClose={() => setVisibilityRuleDialog(null)}
onConfirm={handleConfirmVisibilityRule} onConfirm={handleConfirmVisibilityRule}
/> />
); );
const elementMaskDialogNode = (
<ElementMaskDialog
open={maskDialogElementId !== null}
element={maskDialogElement}
onClose={() => setMaskDialogElementId(null)}
onConfirm={(mask) => {
if (!maskDialogElementId) return;
updateElementMask(maskDialogElementId, mask);
}}
/>
);
if (variant === 'mobile') { if (variant === 'mobile') {
return ( return (
<div className="mobile-design-props"> <div className="mobile-design-props">
{visibilityRuleDialogNode} {visibilityRuleDialogNode}
{elementMaskDialogNode}
<nav className="mobile-design-props-nav" aria-label="属性模块"> <nav className="mobile-design-props-nav" aria-label="属性模块">
{MOBILE_PROP_MODULES.map((item) => ( {MOBILE_PROP_MODULES.map((item) => (
<button <button
@@ -2159,6 +2501,18 @@ 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?.canCopy && (
<button
type="button"
onClick={() => elementClipboard.copyAndPasteSelectedElements()}
className="mobile-design-props-nav-btn"
aria-label="复制选中元素"
title="复制选中元素并创建副本"
>
<Copy className="w-4 h-4" />
<span></span>
</button>
)}
<button <button
type="button" type="button"
onClick={() => void deleteSelectedElements()} onClick={() => void deleteSelectedElements()}
@@ -2192,6 +2546,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
return ( return (
<div className="ps-sidebar-stack"> <div className="ps-sidebar-stack">
{visibilityRuleDialogNode} {visibilityRuleDialogNode}
{elementMaskDialogNode}
<CollapsiblePanelSection <CollapsiblePanelSection
sectionClassName="properties-panel flex flex-col" sectionClassName="properties-panel flex flex-col"
collapsed={panelCollapsed.properties} collapsed={panelCollapsed.properties}

View File

@@ -3,6 +3,7 @@ import { TemplateElement } from '../types';
import { CommitInput } from './CommitInput'; import { CommitInput } from './CommitInput';
import { FieldSelect } from './PsSelect'; import { FieldSelect } from './PsSelect';
import { ValueExtractFields } from './ValueExtractFields'; import { ValueExtractFields } from './ValueExtractFields';
import { ColorValueField } from './ColorValueField';
import { TextDisplayAffixFields } from './TextDisplayAffixFields'; import { TextDisplayAffixFields } from './TextDisplayAffixFields';
import { import {
applyCellTextElementChange, applyCellTextElementChange,
@@ -173,22 +174,14 @@ export const TableCellTextFields: React.FC<TableCellTextFieldsProps> = ({
</div> </div>
<div className="border-t border-[#3a3a3a] pt-2.5"> <div className="border-t border-[#3a3a3a] pt-2.5">
<label className="block text-[10px] font-semibold text-[#777] mb-1"></label> <ColorValueField
<div className="flex gap-1 items-center"> label="文本颜色"
<input value={textElem.textColor || ''}
type="color" onChange={(val) => patchText({ ...textElem, textColor: val || undefined })}
value={textElem.textColor || '#111827'} variableOptions={templateVariables}
onChange={(e) => patchText({ ...textElem, textColor: e.target.value })} defaultColor="#111827"
className="color-input shrink-0" placeholder="#111827"
/> />
<input
type="text"
placeholder="#111827"
value={textElem.textColor || ''}
onChange={(e) => patchText({ ...textElem, textColor: e.target.value })}
className="ps-field font-mono text-[11px] flex-1 min-w-0"
/>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -384,6 +377,53 @@ export const TableCellTextFields: React.FC<TableCellTextFieldsProps> = ({
<span className="text-[12px] text-[#ccc]"></span> <span className="text-[12px] text-[#ccc]"></span>
</label> </label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="ps-field-label"></label>
<CommitInput
type="number"
min="0"
max="99"
step="1"
value={textElem.textMaxLines ?? ''}
onCommit={(val) => {
const raw = String(val).trim();
if (!raw) {
patchText({ ...textElem, textMaxLines: undefined, textEllipsis: undefined });
return;
}
const n = Math.max(0, Math.min(99, Math.floor(Number(val) || 0)));
patchText({
...textElem,
textMaxLines: n > 0 ? n : undefined,
textEllipsis: n > 0 ? textElem.textEllipsis : undefined,
});
}}
className="ps-field font-mono"
placeholder="不限"
/>
<p className="text-[9px] text-[#666] mt-0.5"></p>
</div>
<div className="flex items-end pb-5">
<label
className={`flex items-center gap-2 cursor-pointer ${
!textElem.textMaxLines ? 'opacity-40 cursor-not-allowed' : ''
}`}
>
<input
type="checkbox"
checked={textElem.textEllipsis === true}
disabled={!textElem.textMaxLines || textElem.textMaxLines <= 0}
onChange={(e) =>
patchText({ ...textElem, textEllipsis: e.target.checked ? true : undefined })
}
className="ps-checkbox"
/>
<span className="text-[12px] text-[#ccc]"></span>
</label>
</div>
</div>
<div className="grid grid-cols-2 gap-2 border-t border-[#3a3a3a] pt-2"> <div className="grid grid-cols-2 gap-2 border-t border-[#3a3a3a] pt-2">
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
@@ -409,7 +449,10 @@ export const TableCellTextFields: React.FC<TableCellTextFieldsProps> = ({
</div> </div>
</div> </div>
<ValueExtractFields elem={textElem} onChange={patchText} /> <ValueExtractFields
config={textElem}
onChange={(patch) => patchText({ ...textElem, ...patch })}
/>
<TextDisplayAffixFields elem={textElem} onChange={patchText} /> <TextDisplayAffixFields elem={textElem} onChange={patchText} />
</div> </div>
); );

View File

@@ -14,6 +14,8 @@ import {
updateTableCells, updateTableCells,
updateTableColWidth, updateTableColWidth,
updateTableRowHeight, updateTableRowHeight,
equalizeTableColWidths,
equalizeTableRowHeights,
getTableColWidths, getTableColWidths,
getTableRowHeights, getTableRowHeights,
getCellRawContent, getCellRawContent,
@@ -21,6 +23,7 @@ import {
} from '../tableUtils'; } from '../tableUtils';
import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react'; import { Table2, Merge, SplitSquareHorizontal } from 'lucide-react';
import { TableCellTextFields } from './TableCellTextFields'; import { TableCellTextFields } from './TableCellTextFields';
import { ColorValueField } from './ColorValueField';
const PropInput = CommitInput; const PropInput = CommitInput;
@@ -90,7 +93,17 @@ export const TablePropertyFields: React.FC<TablePropertyFieldsProps> = ({
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<span className="text-[10px] font-semibold text-[#aaa]"> (mm)</span> <div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold text-[#aaa]"> (mm)</span>
<button
type="button"
onClick={() => onChange(equalizeTableRowHeights(elem))}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer shrink-0"
title="将各行高度恢复为相等"
>
</button>
</div>
<div className="grid grid-cols-3 gap-1.5"> <div className="grid grid-cols-3 gap-1.5">
{rowHeights.map((h, i) => ( {rowHeights.map((h, i) => (
<div key={`rh-${i}`}> <div key={`rh-${i}`}>
@@ -111,7 +124,17 @@ export const TablePropertyFields: React.FC<TablePropertyFieldsProps> = ({
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<span className="text-[10px] font-semibold text-[#aaa]"> (mm)</span> <div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold text-[#aaa]"> (mm)</span>
<button
type="button"
onClick={() => onChange(equalizeTableColWidths(elem))}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer shrink-0"
title="将各列宽度恢复为相等"
>
</button>
</div>
<div className="grid grid-cols-3 gap-1.5"> <div className="grid grid-cols-3 gap-1.5">
{colWidths.map((w, i) => ( {colWidths.map((w, i) => (
<div key={`cw-${i}`}> <div key={`cw-${i}`}>
@@ -145,15 +168,14 @@ export const TablePropertyFields: React.FC<TablePropertyFieldsProps> = ({
className="ps-field font-mono" className="ps-field font-mono"
/> />
</div> </div>
<div> <ColorValueField
<label className="ps-field-label">线</label> label="网格线颜色"
<input value={elem.tableBorderColor ?? ''}
type="color" onChange={(val) => onChange({ ...elem, tableBorderColor: val || undefined })}
value={elem.tableBorderColor ?? '#374151'} variableOptions={templateVariables}
onChange={(e) => onChange({ ...elem, tableBorderColor: e.target.value })} defaultColor="#374151"
className="color-input w-full" placeholder="#374151"
/> />
</div>
</div> </div>
{normalizedCells.length > 0 && ( {normalizedCells.length > 0 && (
@@ -287,7 +309,9 @@ export const TableCellContentFields: React.FC<TableCellContentFieldsProps> = ({
export function scaleTableElementSize( export function scaleTableElementSize(
elem: TemplateElement, elem: TemplateElement,
newWidth: number, newWidth: number,
newHeight: number newHeight: number,
startWidth = elem.width,
startHeight = elem.height
): TemplateElement { ): TemplateElement {
if (elem.type !== 'table') { if (elem.type !== 'table') {
return { ...elem, width: newWidth, height: newHeight }; return { ...elem, width: newWidth, height: newHeight };
@@ -296,8 +320,8 @@ export function scaleTableElementSize(
elem, elem,
newWidth, newWidth,
newHeight, newHeight,
elem.width, startWidth,
elem.height, startHeight,
getTableRowHeights(elem), getTableRowHeights(elem),
getTableColWidths(elem) getTableColWidths(elem)
); );

View File

@@ -45,7 +45,7 @@ export const TextDisplayAffixFields: React.FC<TextDisplayAffixFieldsProps> = ({
/> />
</div> </div>
<p className="col-span-2 text-[9px] text-[#666] leading-relaxed"> <p className="col-span-2 text-[9px] text-[#666] leading-relaxed">
</p> </p>
</div> </div>
); );

View File

@@ -1,34 +1,39 @@
import React from 'react'; import React from 'react';
import { TemplateElement } from '../types'; import type { ValueExtractConfig } from '../types';
import { CommitInput } from './CommitInput'; import { CommitInput } from './CommitInput';
import { FieldSelect } from './PsSelect'; import { FieldSelect } from './PsSelect';
interface ValueExtractFieldsProps { interface ValueExtractFieldsProps {
elem: TemplateElement; config: ValueExtractConfig;
onChange: (elem: TemplateElement) => void; onChange: (patch: Partial<ValueExtractConfig>) => void;
} }
const PropInput = CommitInput; const PropInput = CommitInput;
function getEffectiveExtractMode(elem: TemplateElement) { function getEffectiveExtractMode(config: ValueExtractConfig) {
if (elem.textExtractMode !== undefined) return elem.textExtractMode; if (config.textExtractMode !== undefined) return config.textExtractMode;
return elem.textSplitDelimiter?.trim() ? 'split' : 'none'; return config.textSplitDelimiter?.trim() ? 'split' : 'none';
} }
export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, onChange }) => { type ExtractSelectValue = 'none' | 'split' | 'prefix' | 'suffix' | 'inverse';
const extractMode = getEffectiveExtractMode(elem);
export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ config, onChange }) => {
const isInverse = config.textExtractInverse === true;
const extractMode = getEffectiveExtractMode(config);
const selectValue: ExtractSelectValue = isInverse ? 'inverse' : extractMode;
const showExtractFields = selectValue !== 'none';
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<FieldSelect <FieldSelect
value={extractMode} value={selectValue}
onChange={(e) => { onChange={(e) => {
const mode = e.target.value as 'none' | 'split' | 'prefix' | 'suffix'; const mode = e.target.value as ExtractSelectValue;
if (mode === 'none') { if (mode === 'none') {
onChange({ onChange({
...elem, textExtractInverse: false,
textExtractMode: 'none', textExtractMode: 'none',
textSplitDelimiter: undefined, textSplitDelimiter: undefined,
textSplitIndex: undefined, textSplitIndex: undefined,
@@ -36,14 +41,23 @@ export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, on
}); });
return; return;
} }
if (mode === 'inverse') {
onChange({
textExtractInverse: true,
textExtractMode: extractMode === 'none' ? 'prefix' : extractMode,
textSplitIndex: config.textSplitIndex ?? 1,
textExtractLength: config.textExtractLength ?? 1,
});
return;
}
onChange({ onChange({
...elem, textExtractInverse: false,
textExtractMode: mode, textExtractMode: mode,
textSplitIndex: mode === 'split' ? elem.textSplitIndex ?? 1 : elem.textSplitIndex, textSplitIndex: mode === 'split' ? config.textSplitIndex ?? 1 : config.textSplitIndex,
textExtractLength: textExtractLength:
mode === 'prefix' || mode === 'suffix' mode === 'prefix' || mode === 'suffix'
? elem.textExtractLength ?? 1 ? config.textExtractLength ?? 1
: elem.textExtractLength, : config.textExtractLength,
}); });
}} }}
className="ps-field" className="ps-field"
@@ -52,48 +66,76 @@ export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, on
<option value="split"></option> <option value="split"></option>
<option value="prefix"> N </option> <option value="prefix"> N </option>
<option value="suffix"> N </option> <option value="suffix"> N </option>
<option value="inverse"></option>
</FieldSelect> </FieldSelect>
</div> </div>
{extractMode === 'split' && ( {isInverse && (
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={extractMode === 'none' ? 'prefix' : extractMode}
onChange={(e) => {
const mode = e.target.value as 'split' | 'prefix' | 'suffix';
onChange({
textExtractInverse: true,
textExtractMode: mode,
textSplitIndex: mode === 'split' ? config.textSplitIndex ?? 1 : config.textSplitIndex,
textExtractLength:
mode === 'prefix' || mode === 'suffix'
? config.textExtractLength ?? 1
: config.textExtractLength,
});
}}
className="ps-field"
>
<option value="prefix"> N </option>
<option value="split"></option>
<option value="suffix"> N </option>
</FieldSelect>
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
+ + A-1-1-1 1
- 1-1-1
</p>
</div>
)}
{showExtractFields && extractMode === 'split' && (
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<PropInput <PropInput
type="text" type="text"
value={elem.textSplitDelimiter ?? ''} value={config.textSplitDelimiter ?? ''}
placeholder="如 -" placeholder="如 -"
onCommit={(val) => { onCommit={(val) => {
const trimmed = val.trim(); const trimmed = val.trim();
if (!trimmed) { if (!trimmed) {
onChange({ onChange({
...elem, textExtractMode: isInverse ? 'split' : 'none',
textExtractMode: 'none', textExtractInverse: isInverse ? true : false,
textSplitDelimiter: undefined, textSplitDelimiter: undefined,
textSplitIndex: undefined, textSplitIndex: undefined,
}); });
return; return;
} }
onChange({ onChange({
...elem,
textExtractMode: 'split', textExtractMode: 'split',
textSplitDelimiter: trimmed, textSplitDelimiter: trimmed,
textSplitIndex: elem.textSplitIndex ?? 1, textSplitIndex: config.textSplitIndex ?? 1,
}); });
}} }}
className="ps-field font-mono" className="ps-field font-mono"
/> />
</div> </div>
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label">{isInverse ? '去除第几位' : '显示第几位'}</label>
<PropInput <PropInput
type="number" type="number"
min="1" min="1"
max="99" max="99"
value={elem.textSplitIndex ?? 1} value={config.textSplitIndex ?? 1}
disabled={!elem.textSplitDelimiter?.trim()} disabled={!config.textSplitDelimiter?.trim()}
onCommit={(val) => onCommit={(val) =>
onChange({ onChange({
...elem,
textExtractMode: 'split', textExtractMode: 'split',
textSplitIndex: Math.max(1, Math.min(99, Number(val) || 1)), textSplitIndex: Math.max(1, Math.min(99, Number(val) || 1)),
}) })
@@ -101,34 +143,37 @@ export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, on
className="ps-field font-mono" className="ps-field font-mono"
/> />
</div> </div>
<p className="col-span-2 text-[9px] text-[#666] leading-relaxed"> {!isInverse && (
N 1 <p className="col-span-2 text-[9px] text-[#666] leading-relaxed">
</p> N 1
</p>
)}
</div> </div>
)} )}
{(extractMode === 'prefix' || extractMode === 'suffix') && ( {showExtractFields && (extractMode === 'prefix' || extractMode === 'suffix') && (
<div> <div>
<label className="ps-field-label"> <label className="ps-field-label">
{extractMode === 'prefix' ? '前' : '后'} N {isInverse ? '去除' : extractMode === 'prefix' ? '前' : '后'} N
</label> </label>
<PropInput <PropInput
type="number" type="number"
min="1" min="1"
max="999" max="999"
value={elem.textExtractLength ?? 1} value={config.textExtractLength ?? 1}
onCommit={(val) => onCommit={(val) =>
onChange({ onChange({
...elem,
textExtractLength: Math.max(1, Math.min(999, Number(val) || 1)), textExtractLength: Math.max(1, Math.min(999, Number(val) || 1)),
}) })
} }
className="ps-field font-mono" className="ps-field font-mono"
/> />
<p className="text-[9px] text-[#666] mt-1 leading-relaxed"> {!isInverse && (
{extractMode === 'prefix' <p className="text-[9px] text-[#666] mt-1 leading-relaxed">
? '变量替换为完整内容后,取最前面的 N 个字符' {extractMode === 'prefix'
: '变量替换为完整内容后,取最面的 N 个字符'} ? '变量替换为完整内容后,取最面的 N 个字符'
</p> : '变量替换为完整内容后,取最后面的 N 个字符'}
</p>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,98 @@
import React, { useState } from 'react';
import { Plus } from 'lucide-react';
import type { VisibilityRule } from '../types';
import { resolveVariableFilterRules, toggleVisibilityRuleEnabled } from '../utils';
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
interface VariableFilterRulesEditorProps {
rules?: VisibilityRule[];
templateVariables: string[];
disabled?: boolean;
emptyHint?: string;
description?: string;
addTitle?: string;
editTitle?: string;
onChangeRules: (rules: VisibilityRule[]) => void;
}
export const VariableFilterRulesEditor: React.FC<VariableFilterRulesEditorProps> = ({
rules = [],
templateVariables,
disabled = false,
emptyHint = '尚未添加过滤条件',
description = '与元素「显示控制」相同:全部条件满足时才纳入;按当前数据行与变量默认值判断。',
addTitle = '添加过滤条件',
editTitle = '编辑过滤条件',
onChangeRules,
}) => {
const [ruleDialog, setRuleDialog] = useState<{ mode: 'add' | 'edit'; index?: number } | null>(
null
);
const normalizedRules = resolveVariableFilterRules(rules);
const editingRule =
ruleDialog?.mode === 'edit' && ruleDialog.index !== undefined
? normalizedRules[ruleDialog.index]
: undefined;
const handleConfirmRule = (rule: VisibilityRule) => {
if (!ruleDialog) return;
if (ruleDialog.mode === 'edit' && ruleDialog.index !== undefined) {
onChangeRules(normalizedRules.map((item, index) => (index === ruleDialog.index ? rule : item)));
return;
}
onChangeRules([...normalizedRules, rule]);
};
return (
<>
<div className="space-y-2">
<p className="text-[9px] text-[#777] leading-relaxed">{description}</p>
{normalizedRules.length > 0 ? (
<div className="space-y-1.5">
{normalizedRules.map((rule, index) => (
<VisibilityRuleSummary
key={`${rule.variable}-${rule.operator}-${index}`}
rule={rule}
onEdit={() => setRuleDialog({ mode: 'edit', index })}
onDelete={() => onChangeRules(normalizedRules.filter((_, i) => i !== index))}
onToggleEnabled={() =>
onChangeRules(
normalizedRules.map((item, i) =>
i === index ? toggleVisibilityRuleEnabled(item) : item
)
)
}
/>
))}
</div>
) : (
<p className="text-[10px] text-[#888]">{emptyHint}</p>
)}
<button
type="button"
onClick={() => setRuleDialog({ mode: 'add' })}
disabled={disabled}
className="ps-btn w-full text-[10px] justify-center disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="w-3 h-3" />
</button>
</div>
<VisibilityRuleDialog
open={ruleDialog !== null}
mode={ruleDialog?.mode ?? 'add'}
initialRule={editingRule}
variableOptions={templateVariables}
variableOnly
addTitle={addTitle}
editTitle={editTitle}
onClose={() => setRuleDialog(null)}
onConfirm={handleConfirmRule}
/>
</>
);
};

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { VisibilityRule } from '../types'; import { VisibilityRule } from '../types';
import { visibilityOperatorNeedsValue } from '../utils'; import { visibilityOperatorNeedsValue, valueExtractConfigHasExtract } from '../utils';
import { VisibilityRuleForm, type VisibilityRuleDraft } from './VisibilityRuleForm'; import { VisibilityRuleForm, type VisibilityRuleDraft } from './VisibilityRuleForm';
const EMPTY_DRAFT: VisibilityRuleDraft = { const EMPTY_DRAFT: VisibilityRuleDraft = {
@@ -11,11 +12,54 @@ const EMPTY_DRAFT: VisibilityRuleDraft = {
value: '', value: '',
}; };
function pickRuleExtractFields(draft: VisibilityRuleDraft): Partial<VisibilityRule> {
if (!valueExtractConfigHasExtract(draft)) {
return {
textExtractMode: undefined,
textSplitDelimiter: undefined,
textSplitIndex: undefined,
textExtractLength: undefined,
textExtractInverse: undefined,
textDisplayPrefix: undefined,
textDisplaySuffix: undefined,
};
}
return {
textExtractMode: draft.textExtractMode,
textSplitDelimiter: draft.textSplitDelimiter,
textSplitIndex: draft.textSplitIndex,
textExtractLength: draft.textExtractLength,
textExtractInverse: draft.textExtractInverse || undefined,
textDisplayPrefix: draft.textDisplayPrefix || undefined,
textDisplaySuffix: draft.textDisplaySuffix || undefined,
};
}
function ruleToDraft(rule: VisibilityRule): VisibilityRuleDraft {
return {
target: rule.target ?? 'variable',
variable: rule.variable,
operator: rule.operator,
value: rule.value ?? '',
textExtractMode: rule.textExtractMode,
textSplitDelimiter: rule.textSplitDelimiter,
textSplitIndex: rule.textSplitIndex,
textExtractLength: rule.textExtractLength,
textExtractInverse: rule.textExtractInverse,
textDisplayPrefix: rule.textDisplayPrefix,
textDisplaySuffix: rule.textDisplaySuffix,
};
}
interface VisibilityRuleDialogProps { interface VisibilityRuleDialogProps {
open: boolean; open: boolean;
mode: 'add' | 'edit'; mode: 'add' | 'edit';
initialRule?: VisibilityRule; initialRule?: VisibilityRule;
variableOptions: string[]; variableOptions: string[];
variableOnly?: boolean;
addTitle?: string;
editTitle?: string;
onClose: () => void; onClose: () => void;
onConfirm: (rule: VisibilityRule) => void; onConfirm: (rule: VisibilityRule) => void;
} }
@@ -25,6 +69,9 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
mode, mode,
initialRule, initialRule,
variableOptions, variableOptions,
variableOnly = false,
addTitle = '添加显示条件',
editTitle = '编辑显示条件',
onClose, onClose,
onConfirm, onConfirm,
}) => { }) => {
@@ -33,19 +80,14 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (mode === 'edit' && initialRule) { if (mode === 'edit' && initialRule) {
setDraft({ setDraft(ruleToDraft(initialRule));
target: initialRule.target ?? 'variable',
variable: initialRule.variable,
operator: initialRule.operator,
value: initialRule.value ?? '',
});
} else { } else {
setDraft({ setDraft({
...EMPTY_DRAFT, ...EMPTY_DRAFT,
target: variableOptions.length > 0 ? 'variable' : 'content', target: variableOnly || variableOptions.length > 0 ? 'variable' : 'content',
}); });
} }
}, [open, mode, initialRule, variableOptions.length]); }, [open, mode, initialRule, variableOptions.length, variableOnly]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -59,12 +101,11 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
return () => window.removeEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown);
}, [open, onClose]); }, [open, onClose]);
if (!open) return null; if (!open || typeof document === 'undefined') return null;
const target = draft.target ?? 'variable'; const target = draft.target ?? 'variable';
const canConfirm = const canConfirm = target === 'content' || draft.variable.trim().length > 0;
target === 'content' || draft.variable.trim().length > 0; const title = mode === 'add' ? addTitle : editTitle;
const title = mode === 'add' ? '添加显示条件' : '编辑显示条件';
const confirmLabel = mode === 'add' ? '确定添加' : '保存'; const confirmLabel = mode === 'add' ? '确定添加' : '保存';
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@@ -77,12 +118,13 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
value: visibilityOperatorNeedsValue(draft.operator) value: visibilityOperatorNeedsValue(draft.operator)
? draft.value?.trim() || undefined ? draft.value?.trim() || undefined
: undefined, : undefined,
...pickRuleExtractFields(draft),
}); });
onClose(); onClose();
}; };
return ( return createPortal(
<div className="ps-modal-overlay" onClick={onClose} role="presentation"> <div className="ps-modal-overlay" role="presentation">
<div <div
className="ps-modal" className="ps-modal"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -109,6 +151,7 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
rule={draft} rule={draft}
onChange={(patch) => setDraft((d) => ({ ...d, ...patch }))} onChange={(patch) => setDraft((d) => ({ ...d, ...patch }))}
variableOptions={variableOptions} variableOptions={variableOptions}
variableOnly={variableOnly}
/> />
</div> </div>
<div className="ps-modal-footer"> <div className="ps-modal-footer">
@@ -125,6 +168,7 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
</div> </div>
</form> </form>
</div> </div>
</div> </div>,
document.body
); );
}; };

View File

@@ -3,13 +3,16 @@ import {
VisibilityRule, VisibilityRule,
VisibilityOperator, VisibilityOperator,
VisibilityRuleTarget, VisibilityRuleTarget,
ValueExtractConfig,
VISIBILITY_OPERATOR_LABELS, VISIBILITY_OPERATOR_LABELS,
VISIBILITY_RULE_TARGET_LABELS, VISIBILITY_RULE_TARGET_LABELS,
} from '../types'; } from '../types';
import { visibilityOperatorNeedsValue } from '../utils'; import { visibilityOperatorNeedsValue, valueExtractConfigHasExtract } from '../utils';
import { FieldSelect } from './PsSelect'; import { FieldSelect } from './PsSelect';
import { ValueExtractFields } from './ValueExtractFields';
import { TextDisplayAffixFields } from './TextDisplayAffixFields';
export interface VisibilityRuleDraft { export interface VisibilityRuleDraft extends ValueExtractConfig {
target: VisibilityRuleTarget; target: VisibilityRuleTarget;
variable: string; variable: string;
operator: VisibilityOperator; operator: VisibilityOperator;
@@ -20,37 +23,43 @@ interface VisibilityRuleFormProps {
rule: VisibilityRuleDraft; rule: VisibilityRuleDraft;
onChange: (patch: Partial<VisibilityRule>) => void; onChange: (patch: Partial<VisibilityRule>) => void;
variableOptions: string[]; variableOptions: string[];
/** 为 true 时仅支持按变量判断(如批量导出过滤) */
variableOnly?: boolean;
} }
export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
rule, rule,
onChange, onChange,
variableOptions, variableOptions,
variableOnly = false,
}) => { }) => {
const target = rule.target ?? 'variable'; const target = variableOnly ? 'variable' : (rule.target ?? 'variable');
const hasExtract = valueExtractConfigHasExtract(rule);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div> {!variableOnly && (
<label className="ps-field-label"></label> <div>
<FieldSelect <label className="ps-field-label"></label>
value={target} <FieldSelect
onChange={(e) => { value={target}
const nextTarget = e.target.value as VisibilityRuleTarget; onChange={(e) => {
onChange({ const nextTarget = e.target.value as VisibilityRuleTarget;
target: nextTarget, onChange({
variable: nextTarget === 'content' ? '' : rule.variable, target: nextTarget,
}); variable: nextTarget === 'content' ? '' : rule.variable,
}} });
className="ps-field text-[11px] w-full mt-0.5" }}
> className="ps-field text-[11px] w-full mt-0.5"
{(Object.keys(VISIBILITY_RULE_TARGET_LABELS) as VisibilityRuleTarget[]).map((t) => ( >
<option key={t} value={t}> {(Object.keys(VISIBILITY_RULE_TARGET_LABELS) as VisibilityRuleTarget[]).map((t) => (
{VISIBILITY_RULE_TARGET_LABELS[t]} <option key={t} value={t}>
</option> {VISIBILITY_RULE_TARGET_LABELS[t]}
))} </option>
</FieldSelect> ))}
</div> </FieldSelect>
</div>
)}
{target === 'variable' ? ( {target === 'variable' ? (
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
@@ -68,15 +77,56 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
</FieldSelect> </FieldSelect>
{variableOptions.length === 0 && ( {variableOptions.length === 0 && (
<p className="text-[9px] text-[#666] mt-1 leading-relaxed"> <p className="text-[9px] text-[#666] mt-1 leading-relaxed">
{variableOnly
? '请先在模板中添加变量后再设置过滤条件'
: '请先在「变量管理」中添加变量,或改用「内容值」作为条件对象'}
</p> </p>
)} )}
</div> </div>
) : ( ) : (
<p className="text-[9px] text-[#666] leading-relaxed"> !variableOnly && (
使/ <p className="text-[9px] text-[#666] leading-relaxed">
</p> 使
</p>
)
)} )}
<div className="pt-2 border-t border-[#3a3a3a] space-y-2">
<div className="text-[10px] font-bold text-[#31a8ff]"></div>
<ValueExtractFields config={rule} onChange={onChange} />
{hasExtract && rule.textExtractInverse && (
<TextDisplayAffixFields
elem={{
id: '',
type: 'text',
name: '',
x: 0,
y: 0,
width: 0,
height: 0,
content: '',
fontSize: 10,
textAlign: 'left',
fontWeight: 'normal',
barcodeFormat: 'CODE128',
showText: true,
fontFamily: 'sans',
textDisplayPrefix: rule.textDisplayPrefix,
textDisplaySuffix: rule.textDisplaySuffix,
}}
onChange={(patch) =>
onChange({
textDisplayPrefix: patch.textDisplayPrefix,
textDisplaySuffix: patch.textDisplaySuffix,
})
}
/>
)}
{hasExtract && !rule.textExtractInverse && (
<p className="text-[9px] text-[#666] leading-relaxed">
{target === 'content' ? '内容值' : '变量值'}
</p>
)}
</div>
<div> <div>
<label className="ps-field-label"></label> <label className="ps-field-label"></label>
<FieldSelect <FieldSelect
@@ -99,14 +149,51 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
</div> </div>
{visibilityOperatorNeedsValue(rule.operator) && ( {visibilityOperatorNeedsValue(rule.operator) && (
<div> <div>
<label className="ps-field-label"></label> <div className="flex items-center justify-between gap-2">
<label className="ps-field-label"></label>
<button
type="button"
disabled={!rule.value}
onClick={() => onChange({ value: '' })}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
>
</button>
</div>
<input <input
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,12 +1,13 @@
import React from 'react'; import React from 'react';
import { Pencil, Trash2 } from 'lucide-react'; import { Pencil, Trash2 } from 'lucide-react';
import { VisibilityRule } from '../types'; import { VisibilityRule } from '../types';
import { formatVisibilityRuleDescription } from '../utils'; import { formatVisibilityRuleDescription, isVisibilityRuleEnabled } from '../utils';
interface VisibilityRuleSummaryProps { interface VisibilityRuleSummaryProps {
rule: VisibilityRule; rule: VisibilityRule;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onToggleEnabled?: () => void;
} }
/** 将 {变量} 或「内容值」高亮展示 */ /** 将 {变量} 或「内容值」高亮展示 */
@@ -37,31 +38,46 @@ export const VisibilityRuleSummary: React.FC<VisibilityRuleSummaryProps> = ({
rule, rule,
onEdit, onEdit,
onDelete, onDelete,
}) => ( onToggleEnabled,
<div className="visibility-rule-summary"> }) => {
<button const enabled = isVisibilityRuleEnabled(rule);
type="button"
onClick={onEdit} return (
className="visibility-rule-summary-btn" <div className={`visibility-rule-summary${enabled ? '' : ' is-disabled'}`}>
title="编辑条件" {onToggleEnabled && (
> <input
<p className="visibility-rule-summary-text">{renderRuleDescription(rule)}</p> type="checkbox"
</button> checked={enabled}
<button onChange={onToggleEnabled}
type="button" className="ps-checkbox shrink-0"
onClick={onEdit} title={enabled ? '禁用条件' : '启用条件'}
title="编辑" aria-label={enabled ? '禁用条件' : '启用条件'}
className="shrink-0 p-1 rounded text-[#666] hover:text-[#31a8ff] hover:bg-[#333] transition-colors" />
> )}
<Pencil className="w-3 h-3" /> <button
</button> type="button"
<button onClick={onEdit}
type="button" className="visibility-rule-summary-btn"
onClick={onDelete} title="编辑条件"
title="删除条件" >
className="shrink-0 p-1 rounded text-[#666] hover:text-red-400 hover:bg-[#3a2020] transition-colors" <p className="visibility-rule-summary-text">{renderRuleDescription(rule)}</p>
> </button>
<Trash2 className="w-3.5 h-3.5" /> <button
</button> type="button"
</div> onClick={onEdit}
); title="编辑"
className="shrink-0 p-1 rounded text-[#666] hover:text-[#31a8ff] hover:bg-[#333] transition-colors"
>
<Pencil className="w-3 h-3" />
</button>
<button
type="button"
onClick={onDelete}
title="删除条件"
className="shrink-0 p-1 rounded text-[#666] hover:text-red-400 hover:bg-[#3a2020] transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
);
};

View File

@@ -28,6 +28,30 @@ export function getSelectionBounds(selected: TemplateElement[]) {
return { minX, minY, maxX, maxY }; return { minX, minY, maxX, maxY };
} }
/** 将选中元素整体平移,使选区左上角移至 (newMinX, newMinY) */
export function setSelectionOrigin(
elements: TemplateElement[],
selectedIds: string[],
newMinX: number,
newMinY: number
): TemplateElement[] {
const selected = elements.filter((e) => selectedIds.includes(e.id) && !e.locked);
if (selected.length === 0) return elements;
const { minX, minY } = getSelectionBounds(selected);
const deltaX = round1(newMinX - minX);
const deltaY = round1(newMinY - minY);
if (deltaX === 0 && deltaY === 0) return elements;
const idSet = new Set(selected.map((e) => e.id));
return elements.map((el) => {
if (!idSet.has(el.id)) return el;
return {
...el,
x: round1(el.x + deltaX),
y: round1(el.y + deltaY),
};
});
}
/** 选中元素对齐到共同选区边界 */ /** 选中元素对齐到共同选区边界 */
export function alignToSelectionBounds( export function alignToSelectionBounds(
elements: TemplateElement[], elements: TemplateElement[],

278
src/elementMask.ts Normal file
View File

@@ -0,0 +1,278 @@
import type { ElementMaskConfig, TemplateElement } from './types';
import { traceRoundedRect, type CornerRadiiPx } from './elementBox';
function loadImageOrNull(src: string): Promise<HTMLImageElement | null> {
const trimmed = src.trim();
if (!trimmed) return Promise.resolve(null);
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = trimmed;
});
}
function drawImageWithFit(
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
x: number,
y: number,
w: number,
h: number,
fit: 'contain' | 'cover' | 'fill' = 'contain'
) {
if (fit === 'fill') {
ctx.drawImage(img, x, y, w, h);
return;
}
const imgRatio = img.width / img.height;
const boxRatio = w / h;
if (fit === 'contain') {
let dw = w;
let dh = h;
let dx = x;
let dy = y;
if (imgRatio > boxRatio) {
dh = w / imgRatio;
dy = y + (h - dh) / 2;
} else {
dw = h * imgRatio;
dx = x + (w - dw) / 2;
}
ctx.drawImage(img, dx, dy, dw, dh);
return;
}
let sx = 0;
let sy = 0;
let sw = img.width;
let sh = img.height;
if (imgRatio > boxRatio) {
sw = img.height * boxRatio;
sx = (img.width - sw) / 2;
} else {
sh = img.width / boxRatio;
sy = (img.height - sh) / 2;
}
ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h);
}
export function isElementMaskActive(mask?: ElementMaskConfig): boolean {
if (!mask) return false;
if (mask.enabled === false) return false;
if (mask.type === 'image') return !!mask.image?.trim();
return true;
}
export function createDefaultElementMask(elem: TemplateElement): ElementMaskConfig {
return {
enabled: true,
type: 'rect',
mode: 'include',
x: 0,
y: 0,
width: elem.width,
height: elem.height,
borderRadius: 0,
imageFit: 'fill',
};
}
export function resolveMaskRegionMm(
elem: TemplateElement,
mask: ElementMaskConfig
): { x: number; y: number; width: number; height: number } {
return {
x: mask.x ?? 0,
y: mask.y ?? 0,
width: mask.width ?? elem.width,
height: mask.height ?? elem.height,
};
}
export function resolveMaskCornerRadiiPx(
mask: ElementMaskConfig,
scale: number,
w: number,
h: number
): CornerRadiiPx {
const maxR = Math.min(w, h) / 2;
const baseMm = mask.borderRadius ?? 0;
const radiusFor = (specificMm?: number): number => {
const mm = specificMm !== undefined && specificMm !== null ? specificMm : baseMm;
if (mm <= 0) return 0;
return Math.min(maxR, mm * scale);
};
return {
tl: radiusFor(mask.borderRadiusTL),
tr: radiusFor(mask.borderRadiusTR),
br: radiusFor(mask.borderRadiusBR),
bl: radiusFor(mask.borderRadiusBL),
};
}
function resolveMaskRegionPx(
elem: TemplateElement,
mask: ElementMaskConfig,
scale: number
) {
const region = resolveMaskRegionMm(elem, mask);
return {
x: Math.round(region.x * scale),
y: Math.round(region.y * scale),
w: Math.max(1, Math.round(region.width * scale)),
h: Math.max(1, Math.round(region.height * scale)),
};
}
function fillRectMaskAlpha(
ctx: CanvasRenderingContext2D,
mx: number,
my: number,
mw: number,
mh: number,
mask: ElementMaskConfig,
scale: number
) {
ctx.fillStyle = '#ffffff';
traceRoundedRect(ctx, mx, my, mw, mh, resolveMaskCornerRadiiPx(mask, scale, mw, mh));
ctx.fill();
}
/** 将图片按亮度转为 alpha 蒙版(白=不透明,黑=透明) */
function buildImageLuminanceMaskCanvas(
img: HTMLImageElement,
w: number,
h: number,
fit: 'contain' | 'cover' | 'fill',
invert: boolean
): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return canvas;
drawImageWithFit(ctx, img, 0, 0, w, h, fit);
const imageData = ctx.getImageData(0, 0, w, h);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const luminance = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
const alpha = invert ? 255 - luminance : luminance;
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = alpha;
}
ctx.putImageData(imageData, 0, 0);
return canvas;
}
/** 生成与元素同尺寸的蒙版 alpha 图(不透明区域保留图层像素) */
async function buildMaskAlphaCanvas(
elem: TemplateElement,
mask: ElementMaskConfig,
ew: number,
eh: number,
scale: number
): Promise<HTMLCanvasElement> {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, ew);
canvas.height = Math.max(1, eh);
const ctx = canvas.getContext('2d');
if (!ctx) return canvas;
const { x: mx, y: my, w: mw, h: mh } = resolveMaskRegionPx(elem, mask, scale);
const include = (mask.mode ?? 'include') === 'include';
if (mask.type === 'image' && mask.image) {
const img = await loadImageOrNull(mask.image);
if (img) {
const maskCanvas = buildImageLuminanceMaskCanvas(
img,
mw,
mh,
mask.imageFit ?? 'fill',
!include
);
ctx.drawImage(maskCanvas, mx, my);
} else if (include) {
fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale);
}
return canvas;
}
if (include) {
fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale);
return canvas;
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, ew, eh);
ctx.globalCompositeOperation = 'destination-out';
fillRectMaskAlpha(ctx, mx, my, mw, mh, mask, scale);
ctx.globalCompositeOperation = 'source-over';
return canvas;
}
/** 对已绘制的图层离屏画布应用蒙版destination-in */
async function applyMaskToLayerCanvas(
layerCtx: CanvasRenderingContext2D,
elem: TemplateElement,
mask: ElementMaskConfig,
ew: number,
eh: number,
scale: number
) {
const alphaCanvas = await buildMaskAlphaCanvas(elem, mask, ew, eh, scale);
layerCtx.save();
layerCtx.globalCompositeOperation = 'destination-in';
layerCtx.drawImage(alphaCanvas, 0, 0);
layerCtx.restore();
}
type DrawLayerFn = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number
) => void | Promise<void>;
/**
* 绘制元素图层:无蒙版时直接绘制;有蒙版时先在离屏画布绘制整层(背景、内容、边框),应用蒙版后再合成到主画布。
*/
export async function composeElementLayer(
mainCtx: CanvasRenderingContext2D,
elem: TemplateElement,
ex: number,
ey: number,
ew: number,
eh: number,
scale: number,
drawLayer: DrawLayerFn
) {
const mask = isElementMaskActive(elem.mask) ? elem.mask : undefined;
if (!mask) {
await drawLayer(mainCtx, ex, ey, ew, eh);
return;
}
const layerCanvas = document.createElement('canvas');
layerCanvas.width = Math.max(1, ew);
layerCanvas.height = Math.max(1, eh);
const layerCtx = layerCanvas.getContext('2d');
if (!layerCtx) {
await drawLayer(mainCtx, ex, ey, ew, eh);
return;
}
await drawLayer(layerCtx, 0, 0, ew, eh);
await applyMaskToLayerCanvas(layerCtx, elem, mask, ew, eh, scale);
mainCtx.drawImage(layerCanvas, ex, ey);
}
/** @deprecated 使用 composeElementLayer */
export const drawElementContentWithMask = composeElementLayer;

View File

@@ -0,0 +1,113 @@
import { useCallback, useRef, useState } from 'react';
import type { LabelTemplate, TemplateElement } from '../types';
import type { TableCellCoord } from '../tableUtils';
import { cloneTemplateElementsForPaste } from '../utils';
/** 应用内元素剪贴板(内存,跨模板/跨页面模块共享) */
const globalElementClipboard: { elements: TemplateElement[] | null } = { elements: null };
function getClipboardCount() {
return globalElementClipboard.elements?.length ?? 0;
}
function setGlobalClipboard(elements: TemplateElement[] | null) {
globalElementClipboard.elements = elements;
}
export interface UseElementClipboardOptions {
template: LabelTemplate;
onChange: (updated: LabelTemplate) => void;
selectedElementIds: string[];
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;
}
export function useElementClipboard({
template,
onChange,
selectedElementIds,
onSelectElements,
onSelectTableCells,
onCopySuccess,
onDuplicateSuccess,
}: UseElementClipboardOptions): ElementClipboardActions {
const templateRef = useRef(template);
templateRef.current = template;
const [clipboardCount, setClipboardCount] = useState(getClipboardCount);
const syncClipboardCount = useCallback((elements: TemplateElement[] | null) => {
setGlobalClipboard(elements);
setClipboardCount(elements?.length ?? 0);
}, []);
const canCopy = selectedElementIds.length > 0;
const canPaste = clipboardCount > 0;
const copySelectedElements = 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 cloned = JSON.parse(JSON.stringify(toCopy)) as TemplateElement[];
syncClipboardCount(cloned);
onCopySuccess?.(toCopy.length);
}, [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({
...current,
elements: [...current.elements, ...pasted],
});
onSelectElements(pasted.map((el) => el.id));
onSelectTableCells?.([]);
syncClipboardCount(null);
}, [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,
};
}

View File

@@ -17,9 +17,18 @@ export function useMediaQuery(query: string): boolean {
return matches; return matches;
} }
/** 与 Tailwind `md` 断点一致:&lt; 768px 视为移动端 */ /** 移动端上下布局:手机窄屏,或平板竖屏(宽度 < 1024 且竖屏) */
export const MOBILE_LAYOUT_MEDIA_QUERY =
'(max-width: 767px), (max-width: 1023px) and (orientation: portrait)';
/** 移动端上下布局:手机窄屏,或平板竖屏(宽度 < 1024 且竖屏) */
export function useIsMobile(): boolean { export function useIsMobile(): boolean {
return useMediaQuery('(max-width: 767px)'); return useMediaQuery(MOBILE_LAYOUT_MEDIA_QUERY);
}
/** 语义别名:是否使用移动端上下布局 */
export function useMobileLayout(): boolean {
return useIsMobile();
} }
/** 平板等窄屏:&lt; 1024px */ /** 平板等窄屏:&lt; 1024px */

View File

@@ -1,10 +1,16 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
@import "tailwindcss"; @import "tailwindcss";
html {
height: 100%;
height: -webkit-fill-available;
}
html, html,
body, body,
#root { #root {
height: 100%; height: 100%;
min-height: 100dvh;
overscroll-behavior: none; overscroll-behavior: none;
} }
@@ -13,6 +19,15 @@ body {
overflow: hidden; overflow: hidden;
} }
body {
background-color: #f8fafc;
}
html.app-dark-shell,
html.app-dark-shell body {
background-color: #323232;
}
/* 可滚动区域隐藏滚动条 */ /* 可滚动区域隐藏滚动条 */
.scrollbar-hide { .scrollbar-hide {
scrollbar-width: none; scrollbar-width: none;
@@ -575,7 +590,7 @@ body {
.ps-modal-overlay { .ps-modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 9000; z-index: 10000;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -656,6 +671,20 @@ body {
background: #2a2a2a; background: #2a2a2a;
} }
.visibility-rule-summary.is-disabled {
opacity: 0.62;
background: #222;
}
.visibility-rule-summary.is-disabled .visibility-rule-summary-text {
color: #777;
text-decoration: line-through;
}
.visibility-rule-summary.is-disabled .visibility-rule-summary-text .var-token {
color: #5a8fb0;
}
.visibility-rule-summary-btn { .visibility-rule-summary-btn {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -724,11 +753,14 @@ body {
} }
.ps-layer-item { .ps-layer-item {
position: relative;
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;
@@ -754,6 +786,83 @@ body {
background: transparent; background: transparent;
} }
.ps-layer-item-hidden {
opacity: 0.55;
}
.ps-layer-item-hidden .truncate {
color: #888;
}
.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;
}
/* PC未 hover 时不占位,悬停时浮于右侧 */
.ps-layer-item-actions--float {
position: absolute;
right: 4px;
top: 50%;
z-index: 1;
transform: translateY(-50%);
opacity: 0;
pointer-events: none;
padding-left: 16px;
background: linear-gradient(to left, #2d2d2d 180px, transparent);
transition: opacity 0.12s ease;
}
.ps-layer-item:hover .ps-layer-item-actions--float {
opacity: 1;
pointer-events: auto;
background: linear-gradient(to left, #383838 180px, transparent);
}
.ps-layer-item.selected:hover .ps-layer-item-actions--float {
background: linear-gradient(to left, #3d4f5f 180px, transparent);
}
.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;
@@ -768,6 +877,12 @@ 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;
@@ -780,10 +895,6 @@ body {
.ps-unselected-dim-layer--visible { .ps-unselected-dim-layer--visible {
opacity: 1; opacity: 1;
}
.ps-unselected-dim {
position: absolute;
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
@@ -867,8 +978,8 @@ body {
background-size: 24px 24px; background-size: 24px 24px;
} }
/* ── Mobile layout (< 768px) ── */ /* ── Mobile layout:手机窄屏,或平板竖屏 ── */
@media (max-width: 767px) { @media (max-width: 767px), (max-width: 1023px) and (orientation: portrait) {
/* Mobile export flow — shares mobile-design-view shell */ /* Mobile export flow — shares mobile-design-view shell */
.mobile-export-view { .mobile-export-view {
@@ -1477,13 +1588,25 @@ body {
gap: 4px; gap: 4px;
width: 64px; width: 64px;
flex-shrink: 0; flex-shrink: 0;
min-height: 0;
align-self: stretch;
padding: 8px 6px; padding: 8px 6px;
background: #252525; background: #252525;
border-right: 1px solid #1a1a1a; border-right: 1px solid #1a1a1a;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-overflow-scrolling: touch;
}
.mobile-design-props-nav::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
} }
.mobile-design-props-nav-spacer { .mobile-design-props-nav-spacer {
flex: 1; flex: 1 1 auto;
min-height: 8px; min-height: 8px;
} }
@@ -1556,21 +1679,66 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
overflow-x: auto; overflow: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
min-width: 0; min-width: 0;
flex: 1; flex: 1;
} }
.mobile-design-toolstrip-tools::-webkit-scrollbar {
display: none;
}
.mobile-design-tool-btn { .mobile-design-tool-btn {
width: 40px !important; width: 40px !important;
height: 40px !important; height: 40px !important;
min-height: 40px !important; min-height: 40px !important;
border-radius: 10px !important;
flex-shrink: 0;
}
.mobile-design-tool-btn.active:hover {
background: #1a1a1a;
color: var(--color-ps-accent);
}
.mobile-design-tool-btn:hover {
background: transparent;
color: #fff;
}
.mobile-toolstrip-overflow-menu {
min-width: 148px;
padding: 4px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.mobile-toolstrip-overflow-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: #ccc;
font-size: 12px;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.mobile-toolstrip-overflow-menu-item:hover,
.mobile-toolstrip-overflow-menu-item.active {
background: #1e3a4f;
color: #31a8ff;
}
.mobile-toolstrip-overflow-menu-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1654,7 +1822,8 @@ body {
} }
} }
@media (min-width: 768px) { /* 桌面宽屏 / 横屏平板:隐藏仅移动端渲染的视图 */
@media (min-width: 768px) and (orientation: landscape), (min-width: 1024px) {
.mobile-only { .mobile-only {
display: none !important; display: none !important;
} }

146
src/labelImagesExport.ts Normal file
View File

@@ -0,0 +1,146 @@
import JSZip from 'jszip';
import type { LabelTemplate } from './types';
import {
filterLabelsForImageExport,
resolveExportImageFileName,
type PrintableLabel,
} from './utils';
export interface LabelImagesExportProgress {
done: number;
total: number;
phase: 'render' | 'zip' | 'save';
zipPercent?: number;
}
export interface ExportLabelsImagesZipOptions {
printablePages: (PrintableLabel | null)[][];
filename?: string;
imageExtension: 'png' | 'jpg';
fileNamePattern?: string;
variableDefaults?: Record<string, string> | null;
exportFilter?: Pick<LabelTemplate, 'exportImageFilterMode' | 'exportImageFilterRules'>;
renderLabel: (label: PrintableLabel) => Promise<string>;
onProgress?: (progress: LabelImagesExportProgress) => void;
}
const yieldToMain = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
const RENDER_CONCURRENCY = 3;
function dataUrlToBlob(dataUrl: string): Blob {
const [header, base64] = dataUrl.split(',');
const mime = header.match(/:(.*?);/)?.[1] ?? 'image/png';
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new Blob([bytes], { type: mime });
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function assignUniqueZipEntryName(
baseName: string,
extension: string,
usedNames: Map<string, number>
): string {
const first = `${baseName}.${extension}`;
if (!usedNames.has(first)) {
usedNames.set(first, 1);
return first;
}
const duplicate = usedNames.get(first)! + 1;
usedNames.set(first, duplicate);
return `${baseName}_${duplicate}.${extension}`;
}
/** 逐张渲染标签图片,按模板命名并打包为 ZIP 下载 */
export async function exportLabelsImagesZipAsync(
options: ExportLabelsImagesZipOptions
): Promise<void> {
const {
printablePages,
filename = 'labels.zip',
imageExtension,
fileNamePattern,
variableDefaults = null,
exportFilter,
renderLabel,
onProgress,
} = options;
const labels = filterLabelsForImageExport(
printablePages,
exportFilter ?? { exportImageFilterMode: 'always' },
variableDefaults
);
const total = labels.length;
if (total === 0) return;
const zip = new JSZip();
const padWidth = String(total).length;
const usedNames = new Map<string, number>();
let done = 0;
let progressTick = 0;
const report = (phase: LabelImagesExportProgress['phase'], zipPercent?: number) => {
onProgress?.({ done, total, phase, zipPercent });
};
report('render');
for (let i = 0; i < labels.length; i += RENDER_CONCURRENCY) {
const batch = labels.slice(i, i + RENDER_CONCURRENCY);
const rendered = await Promise.all(
batch.map(async (label, batchIdx) => {
const seq = i + batchIdx + 1;
const dataUrl = await renderLabel(label);
return { seq, label, dataUrl };
})
);
for (const { seq, label, dataUrl } of rendered) {
const baseName = resolveExportImageFileName(
fileNamePattern,
label,
seq,
padWidth,
variableDefaults
);
const name = assignUniqueZipEntryName(baseName, imageExtension, usedNames);
zip.file(name, dataUrlToBlob(dataUrl));
done++;
}
progressTick++;
if (progressTick >= 2) {
progressTick = 0;
report('render');
await yieldToMain();
}
}
report('render');
await yieldToMain();
report('zip', 0);
const zipBlob = await zip.generateAsync(
{ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } },
(metadata) => {
report('zip', metadata.percent);
}
);
report('save');
await yieldToMain();
downloadBlob(zipBlob, filename);
}

View File

@@ -1,6 +1,6 @@
import JsBarcode from 'jsbarcode'; import JsBarcode from 'jsbarcode';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { TemplateElement, RecordRow } from './types'; import { TemplateElement, RecordRow, LabelTemplate } from './types';
import { import {
resolveElementValue, resolveElementValue,
isGraphicContentEmpty, isGraphicContentEmpty,
@@ -27,6 +27,14 @@ import {
buildTableGridBoundariesPx, buildTableGridBoundariesPx,
getCellRectPx, getCellRectPx,
} from './tableUtils'; } from './tableUtils';
import { composeElementLayer, isElementMaskActive } from './elementMask';
import {
resolveElementColorsForRender,
resolveRenderableColor,
applyPrintColorModeToCanvas,
type RenderColorContext,
} from './colorUtils';
import type { PrintColorMode } from './types';
const GENERATOR_TRANSPARENT_BG = '#00000000'; const GENERATOR_TRANSPARENT_BG = '#00000000';
@@ -212,6 +220,58 @@ function layoutHorizontalTextLines(
return lines; return lines;
} }
const TEXT_ELLIPSIS = '…';
function truncateLineWithEllipsis(
ctx: CanvasRenderingContext2D,
line: string,
maxWidth: number,
ellipsis = TEXT_ELLIPSIS
): string {
if (!line) return ellipsis;
if (ctx.measureText(line).width <= maxWidth) return line;
let truncated = line;
while (truncated.length > 0 && ctx.measureText(truncated + ellipsis).width > maxWidth) {
truncated = truncated.slice(0, -1);
}
return truncated.length > 0 ? truncated + ellipsis : ellipsis;
}
function applyHorizontalLineLimit(
ctx: CanvasRenderingContext2D,
lines: string[],
maxLines: number | undefined,
ellipsis: boolean,
writableW: number
): string[] {
if (!maxLines || maxLines <= 0 || lines.length <= maxLines) return lines;
const visible = lines.slice(0, maxLines);
if (ellipsis) {
const lastIdx = visible.length - 1;
visible[lastIdx] = truncateLineWithEllipsis(ctx, visible[lastIdx], writableW - 4);
}
return visible;
}
function applyVerticalColumnLimit(
columns: string[][],
maxLines: number | undefined,
ellipsis: boolean
): string[][] {
if (!maxLines || maxLines <= 0 || columns.length <= maxLines) return columns;
const visible = columns.slice(0, maxLines);
if (ellipsis && columns.length > maxLines) {
const lastCol = [...visible[visible.length - 1]];
if (lastCol.length > 0) {
lastCol[lastCol.length - 1] = TEXT_ELLIPSIS;
visible[visible.length - 1] = lastCol;
} else {
visible[visible.length - 1] = [TEXT_ELLIPSIS];
}
}
return visible;
}
/** 竖排:按列组织字符,列高受限时换列 */ /** 竖排:按列组织字符,列高受限时换列 */
function layoutVerticalTextColumns( function layoutVerticalTextColumns(
value: string, value: string,
@@ -313,7 +373,8 @@ function drawTextContentInBox(
const vAlign = elem.verticalAlign ?? 'middle'; const vAlign = elem.verticalAlign ?? 'middle';
if (isVertical) { if (isVertical) {
const columns = layoutVerticalTextColumns(String(value), writableH, lineHeight, wordWrap); let columns = layoutVerticalTextColumns(String(value), writableH, lineHeight, wordWrap);
columns = applyVerticalColumnLimit(columns, elem.textMaxLines, elem.textEllipsis === true);
const totalColumnsWidth = columns.length * columnWidth; const totalColumnsWidth = columns.length * columnWidth;
let startX: number; let startX: number;
@@ -348,7 +409,14 @@ function drawTextContentInBox(
} }
} }
} else { } else {
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap); let lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
lines = applyHorizontalLineLimit(
ctx,
lines,
elem.textMaxLines,
elem.textEllipsis === true,
writableW
);
const totalTextHeight = lines.length * lineHeight; const totalTextHeight = lines.length * lineHeight;
let textX = boxX + padPx.left; let textX = boxX + padPx.left;
@@ -422,12 +490,17 @@ function drawTableGridLines(
ctx.beginPath(); ctx.beginPath();
const inset = borderW / 2; const inset = borderW / 2;
ctx.rect( const right = left + width;
alignStrokeCoord(left + inset), const bottom = top + height;
alignStrokeCoord(top + inset), // 外框:描边完全落在网格区域内,避免 clip / 画布边缘裁切右、下边
Math.max(0, width - borderW), ctx.moveTo(alignStrokeCoord(left + inset), alignStrokeCoord(top + inset));
Math.max(0, height - borderW) ctx.lineTo(alignStrokeCoord(right - inset), alignStrokeCoord(top + inset));
); ctx.moveTo(alignStrokeCoord(right - inset), alignStrokeCoord(top + inset));
ctx.lineTo(alignStrokeCoord(right - inset), alignStrokeCoord(bottom - inset));
ctx.moveTo(alignStrokeCoord(right - inset), alignStrokeCoord(bottom - inset));
ctx.lineTo(alignStrokeCoord(left + inset), alignStrokeCoord(bottom - inset));
ctx.moveTo(alignStrokeCoord(left + inset), alignStrokeCoord(bottom - inset));
ctx.lineTo(alignStrokeCoord(left + inset), alignStrokeCoord(top + inset));
for (let r = 1; r < rows; r++) { for (let r = 1; r < rows; r++) {
const y = alignStrokeCoord(rowBounds[r]); const y = alignStrokeCoord(rowBounds[r]);
@@ -471,69 +544,85 @@ async function drawTableElement(
rowData: RecordRow | null, rowData: RecordRow | null,
rowIndex: number, rowIndex: number,
variableDefaults: Record<string, string> | null | undefined, variableDefaults: Record<string, string> | null | undefined,
mode: ContentResolveMode mode: ContentResolveMode,
colorCtx: RenderColorContext
) { ) {
const radii = resolveCornerRadiiPx(elem, scale, ew, eh); const renderElem = resolveElementColorsForRender(elem, colorCtx);
const sides = resolveBorderSides(elem); const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
const sides = resolveBorderSides(renderElem);
drawElementFill(ctx, ex, ey, ew, eh, elem, radii); const drawCellContent = async (
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale); targetCtx: CanvasRenderingContext2D,
ox: number,
oy: number
) => {
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ox, oy);
const gridX = colBounds[0];
const gridY = rowBounds[0];
const gridW = colBounds[colBounds.length - 1] - gridX;
const gridH = rowBounds[rowBounds.length - 1] - gridY;
targetCtx.save();
targetCtx.beginPath();
targetCtx.rect(gridX, gridY, gridW, gridH);
targetCtx.clip();
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ex, ey); const anchors = iterTableCells(elem);
const gridX = colBounds[0]; for (const { row, col } of anchors) {
const gridY = rowBounds[0]; const { x: cellX, y: cellY, width: cellW, height: cellH } = getCellRectPx(
const gridW = colBounds[colBounds.length - 1] - gridX; elem,
const gridH = rowBounds[rowBounds.length - 1] - gridY; row,
ctx.save(); col,
ctx.beginPath(); scale,
ctx.rect(gridX, gridY, gridW, gridH); ox,
ctx.clip(); oy
);
const anchors = iterTableCells(elem); const cellData = getCellData(elem, row, col);
for (const { row, col } of anchors) { const cellBg = resolveRenderableColor(cellData?.backgroundColor, colorCtx);
const { x: cellX, y: cellY, width: cellW, height: cellH } = getCellRectPx( if (cellBg && cellBg !== 'transparent') {
elem, targetCtx.fillStyle = cellBg;
row, targetCtx.fillRect(cellX, cellY, cellW, cellH);
col, }
scale,
ex,
ey
);
const cellData = getCellData(elem, row, col); const textElem = resolveElementColorsForRender(
if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') { getEffectiveCellStyle(elem, row, col),
ctx.fillStyle = cellData.backgroundColor; colorCtx
ctx.fillRect(cellX, cellY, cellW, cellH); );
if (!textElem.content.trim()) continue;
const cellValue = resolveElementValue(textElem, rowData, rowIndex, variableDefaults, mode);
const displayText = String(cellValue ?? '').trim();
if (!displayText) continue;
drawTextContentInBox(
targetCtx,
{
...textElem,
padding: 0,
paddingTop: 0,
paddingRight: 0,
paddingBottom: 0,
paddingLeft: 0,
},
cellX,
cellY,
cellW,
cellH,
displayText,
scale
);
} }
const textElem = getEffectiveCellStyle(elem, row, col); targetCtx.restore();
if (!textElem.content.trim()) continue; };
const cellValue = resolveElementValue(textElem, rowData, rowIndex, variableDefaults, mode); await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
const displayText = String(cellValue ?? '').trim(); drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
if (!displayText) continue; await drawCellContent(layerCtx, ox, oy);
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
});
drawTextContentInBox( drawTableGridLines(ctx, renderElem, ex, ey, scale);
ctx,
{
...textElem,
padding: 0,
paddingTop: 0,
paddingRight: 0,
paddingBottom: 0,
paddingLeft: 0,
},
cellX,
cellY,
cellW,
cellH,
displayText,
scale
);
}
drawTableGridLines(ctx, elem, ex, ey, scale);
ctx.restore();
} }
function drawImagePlaceholder( function drawImagePlaceholder(
@@ -568,6 +657,10 @@ export interface RenderLabelOptions {
transparentBackground?: boolean; transparentBackground?: boolean;
variableDefaults?: Record<string, string> | null; variableDefaults?: Record<string, string> | null;
mode?: ContentResolveMode; mode?: ContentResolveMode;
/** 输出参考背景(生成/打印),绘制在元素下层 */
referenceBackground?: string;
referenceBackgroundOpacity?: number;
referenceBackgroundFit?: 'contain' | 'cover' | 'fill';
/** 渲染倍率,默认 16PDF 导出可用较低倍率以减轻内存与耗时 */ /** 渲染倍率,默认 16PDF 导出可用较低倍率以减轻内存与耗时 */
scale?: number; scale?: number;
/** 输出格式,默认 png */ /** 输出格式,默认 png */
@@ -575,6 +668,138 @@ export interface RenderLabelOptions {
jpegQuality?: number; jpegQuality?: number;
/** 为 false 时不裁剪到画布边界(用于拖动浮层完整显示元素) */ /** 为 false 时不裁剪到画布边界(用于拖动浮层完整显示元素) */
clipToCanvas?: boolean; clipToCanvas?: boolean;
/** 排版/印刷色彩模式 */
printColorMode?: PrintColorMode;
}
export type ReferenceBackgroundRenderOptions = Pick<
RenderLabelOptions,
'referenceBackground' | 'referenceBackgroundOpacity' | 'referenceBackgroundFit'
>;
type ReferenceBackgroundTemplate = Pick<
LabelTemplate,
| 'previewReferenceBackground'
| 'previewReferenceBackgroundOpacity'
| 'previewReferenceBackgroundFit'
| 'enablePreviewReferenceBackground'
>;
function buildReferenceBackgroundRenderOptions(
template: ReferenceBackgroundTemplate
): ReferenceBackgroundRenderOptions {
if (!template.previewReferenceBackground) {
return {};
}
if (template.enablePreviewReferenceBackground === false) {
return {};
}
return {
referenceBackground: template.previewReferenceBackground,
referenceBackgroundOpacity: template.previewReferenceBackgroundOpacity ?? 100,
referenceBackgroundFit: template.previewReferenceBackgroundFit ?? 'cover',
};
}
/** 模板列表 / 设计预览等:参考图且启用预览时绘制 */
export function resolveReferenceBackgroundForPreview(
template: ReferenceBackgroundTemplate
): ReferenceBackgroundRenderOptions {
return buildReferenceBackgroundRenderOptions(template);
}
/** 模板开启「输出参考背景」时,供 PDF / 打印 / 排版预览使用的渲染参数 */
export function resolveReferenceBackgroundForOutput(
template: ReferenceBackgroundTemplate &
Pick<LabelTemplate, 'includeReferenceBackgroundInOutput'>
): ReferenceBackgroundRenderOptions {
if (!template.includeReferenceBackgroundInOutput) {
return {};
}
if (!template.previewReferenceBackground) {
return {};
}
return {
referenceBackground: template.previewReferenceBackground,
referenceBackgroundOpacity: template.previewReferenceBackgroundOpacity ?? 100,
referenceBackgroundFit: template.previewReferenceBackgroundFit ?? 'cover',
};
}
export type LayoutPageBackgroundRenderOptions = {
background: string;
opacity: number;
fit: 'contain' | 'cover' | 'fill';
};
type LayoutPageBackgroundTemplate = Pick<
LabelTemplate,
| 'layoutPageBackground'
| 'layoutPageBackgroundOpacity'
| 'layoutPageBackgroundFit'
| 'enableLayoutPageBackground'
| 'includeLayoutPageBackgroundInOutput'
>;
function buildLayoutPageBackgroundRenderOptions(
template: LayoutPageBackgroundTemplate,
forOutput: boolean
): LayoutPageBackgroundRenderOptions | null {
if (!template.layoutPageBackground) return null;
if (!forOutput && template.enableLayoutPageBackground === false) return null;
if (forOutput && !template.includeLayoutPageBackgroundInOutput) return null;
return {
background: template.layoutPageBackground,
opacity: template.layoutPageBackgroundOpacity ?? 100,
fit: template.layoutPageBackgroundFit ?? 'cover',
};
}
/** 排版预览:整页背景渲染参数 */
export function resolveLayoutPageBackgroundForPreview(
template: LayoutPageBackgroundTemplate
): LayoutPageBackgroundRenderOptions | null {
return buildLayoutPageBackgroundRenderOptions(template, false);
}
/** PDF / 打印:整页背景渲染参数 */
export function resolveLayoutPageBackgroundForOutput(
template: LayoutPageBackgroundTemplate
): LayoutPageBackgroundRenderOptions | null {
return buildLayoutPageBackgroundRenderOptions(template, true);
}
/** 将整页背景渲染为 data URL白底 + 背景图) */
export async function renderLayoutPageBackgroundDataUrl(
paperWidthMm: number,
paperHeightMm: number,
options: LayoutPageBackgroundRenderOptions,
scale = 4,
imageFormat: 'png' | 'jpeg' = 'png',
printColorMode: PrintColorMode = 'rgb'
): Promise<string | null> {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(paperWidthMm * scale));
canvas.height = Math.max(1, Math.round(paperHeightMm * scale));
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const img = await loadImageOrNull(options.background);
if (!img) return null;
ctx.save();
ctx.globalAlpha = Math.min(1, Math.max(0, options.opacity / 100));
drawImageWithFit(ctx, img, 0, 0, canvas.width, canvas.height, options.fit);
ctx.restore();
applyPrintColorModeToCanvas(canvas, printColorMode);
return imageFormat === 'jpeg'
? canvas.toDataURL('image/jpeg', 0.95)
: canvas.toDataURL('image/png');
} }
/** 离屏渲染标签为 PNG data URL */ /** 离屏渲染标签为 PNG data URL */
@@ -589,12 +814,24 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
transparentBackground = false, transparentBackground = false,
variableDefaults = null, variableDefaults = null,
mode = 'design', mode = 'design',
referenceBackground,
referenceBackgroundOpacity = 100,
referenceBackgroundFit = 'cover',
scale: scaleOption = 16, scale: scaleOption = 16,
imageFormat = 'png', imageFormat = 'png',
jpegQuality = 0.92, jpegQuality = 0.92,
clipToCanvas = true, clipToCanvas = true,
printColorMode = 'rgb',
} = options; } = options;
const colorCtx: RenderColorContext = {
printColorMode,
row: rowData,
variableDefaults,
mode,
rowIndex,
};
const scale = scaleOption; const scale = scaleOption;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(widthMm * scale)); canvas.width = Math.max(1, Math.round(widthMm * scale));
@@ -618,6 +855,16 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
ctx.setLineDash([]); ctx.setLineDash([]);
} }
if (referenceBackground) {
const refImg = await loadImageOrNull(referenceBackground);
if (refImg) {
ctx.save();
ctx.globalAlpha = Math.min(1, Math.max(0, referenceBackgroundOpacity / 100));
drawImageWithFit(ctx, refImg, 0, 0, canvas.width, canvas.height, referenceBackgroundFit);
ctx.restore();
}
}
ctx.save(); ctx.save();
if (clipToCanvas) { if (clipToCanvas) {
ctx.beginPath(); ctx.beginPath();
@@ -641,47 +888,50 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
continue; continue;
} }
const renderElem = resolveElementColorsForRender(elem, colorCtx);
ctx.save(); ctx.save();
applyElementRotation(ctx, ex, ey, ew, eh, elem.rotation); applyElementRotation(ctx, ex, ey, ew, eh, elem.rotation);
if (elem.type === 'text') { if (elem.type === 'text') {
const radii = resolveCornerRadiiPx(elem, scale, ew, eh); const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
const sides = resolveBorderSides(elem); const sides = resolveBorderSides(renderElem);
ctx.save(); await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
traceRoundedRect(ctx, ex, ey, ew, eh, radii); drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
ctx.clip(); drawTextContentInBox(layerCtx, renderElem, ox, oy, ow, oh, String(value), scale);
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
drawElementFill(ctx, ex, ey, ew, eh, elem, radii); });
drawTextContentInBox(ctx, elem, ex, ey, ew, eh, String(value), scale);
ctx.restore();
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
} else if (elem.type === 'barcode') { } else if (elem.type === 'barcode') {
try { try {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale); const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); const sides = resolveBorderSides(renderElem);
const tempCanvas = document.createElement('canvas');
const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : ''; const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : '';
if (barcodeValue) { await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
JsBarcode(tempCanvas, barcodeValue, { drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
format: elem.barcodeFormat || 'CODE128', if (!barcodeValue) {
width: 2, drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
height: 40, return;
displayValue: elem.showText ?? false, }
fontSize: 14, const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale);
margin: 2, const tempCanvas = document.createElement('canvas');
background: GENERATOR_TRANSPARENT_BG, JsBarcode(tempCanvas, barcodeValue, {
format: elem.barcodeFormat || 'CODE128',
width: 2,
height: 40,
displayValue: elem.showText ?? false,
fontSize: 14,
margin: 2,
background: GENERATOR_TRANSPARENT_BG,
});
layerCtx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
}); });
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
}
} catch (error) { } catch (error) {
console.warn('Barcode drawing failed', error); console.warn('Barcode drawing failed', error);
if (mode === 'design') { if (mode === 'design') {
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); const inner = getPaddedRectPx(ex, ey, ew, eh, renderElem, scale);
ctx.fillStyle = '#f3f4f6'; ctx.fillStyle = '#f3f4f6';
ctx.fillRect(inner.x, inner.y, inner.w, inner.h); ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
ctx.strokeStyle = '#ef4444'; ctx.strokeStyle = '#ef4444';
@@ -695,27 +945,33 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
} }
} else if (elem.type === 'qrcode') { } else if (elem.type === 'qrcode') {
try { try {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale); const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); const sides = resolveBorderSides(renderElem);
const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : ''; const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : '';
if (qrValue) { await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
const tempCanvas = document.createElement('canvas'); drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
await QRCode.toCanvas(tempCanvas, qrValue, { if (!qrValue) {
width: Math.max(64, inner.w), drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
margin: 0, return;
color: { }
dark: '#000000', const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale);
light: GENERATOR_TRANSPARENT_BG, const tempCanvas = document.createElement('canvas');
}, await QRCode.toCanvas(tempCanvas, qrValue, {
width: Math.max(64, inner.w),
margin: 0,
color: {
dark: '#000000',
light: GENERATOR_TRANSPARENT_BG,
},
});
layerCtx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
}); });
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
}
} catch (error) { } catch (error) {
console.warn('QR code drawing failed', error); console.warn('QR code drawing failed', error);
if (mode === 'design') { if (mode === 'design') {
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); const inner = getPaddedRectPx(ex, ey, ew, eh, renderElem, scale);
ctx.fillStyle = '#f3f4f6'; ctx.fillStyle = '#f3f4f6';
ctx.fillRect(inner.x, inner.y, inner.w, inner.h); ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
ctx.strokeStyle = '#ef4444'; ctx.strokeStyle = '#ef4444';
@@ -732,12 +988,37 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
continue; continue;
} }
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale); const radii = resolveCornerRadiiPx(renderElem, scale, ew, eh);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale); const sides = resolveBorderSides(renderElem);
if (img) { const hasMask = isElementMaskActive(elem.mask);
drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain');
if (hasMask) {
await composeElementLayer(ctx, renderElem, ex, ey, ew, eh, scale, async (layerCtx, ox, oy, ow, oh) => {
drawElementFill(layerCtx, ox, oy, ow, oh, renderElem, radii);
const inner = getPaddedRectPx(ox, oy, ow, oh, renderElem, scale);
if (img) {
drawImageWithFit(
layerCtx,
img,
inner.x,
inner.y,
inner.w,
inner.h,
elem.imageFit ?? 'contain'
);
} else {
drawImagePlaceholder(layerCtx, inner.x, inner.y, inner.w, inner.h, '图片');
}
drawElementBorders(layerCtx, ox, oy, ow, oh, radii, sides, renderElem, scale);
});
} else { } else {
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片'); drawElementChrome(ctx, ex, ey, ew, eh, renderElem, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, renderElem, scale);
if (img) {
drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain');
} else {
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
}
} }
} else if (elem.type === 'table') { } else if (elem.type === 'table') {
await drawTableElement( await drawTableElement(
@@ -751,7 +1032,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
rowData, rowData,
rowIndex, rowIndex,
variableDefaults, variableDefaults,
mode mode,
colorCtx
); );
} }
@@ -760,6 +1042,8 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
ctx.restore(); ctx.restore();
applyPrintColorModeToCanvas(canvas, printColorMode);
return imageFormat === 'jpeg' return imageFormat === 'jpeg'
? canvas.toDataURL('image/jpeg', jpegQuality) ? canvas.toDataURL('image/jpeg', jpegQuality)
: canvas.toDataURL('image/png'); : canvas.toDataURL('image/png');

View File

@@ -1,6 +1,10 @@
import { jsPDF } from 'jspdf'; import { jsPDF } from 'jspdf';
import type { PaperConfig, LabelTemplate, CutLineConfig } from './types'; import type { PaperConfig, LabelTemplate, CutLineConfig } from './types';
import { collectGridCutLinePositions, getCutLineChannel, getCutLineDashSegmentMm, getCutLineStrokeWidthMm, type PrintableLabel } from './utils'; import { collectGridCutLinePositions, getCutLineChannel, getCutLineDashSegmentMm, getCutLineStrokeWidthMm, type PrintableLabel } from './utils';
import {
resolveLayoutPageBackgroundForOutput,
renderLayoutPageBackgroundDataUrl,
} from './labelRenderer';
export interface PdfExportProgress { export interface PdfExportProgress {
done: number; done: number;
@@ -26,6 +30,39 @@ const yieldToMain = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
const PAGE_RENDER_CONCURRENCY = 3; const PAGE_RENDER_CONCURRENCY = 3;
type PageBackgroundCache = { dataUrl: string | null; key: string | null };
async function addPageBackgroundIfNeeded(
pdf: jsPDF,
template: LabelTemplate,
paper: PaperConfig,
paperW: number,
paperH: number,
imageFormat: 'PNG' | 'JPEG',
cache: PageBackgroundCache
) {
const options = resolveLayoutPageBackgroundForOutput(template);
if (!options) return;
const printColorMode = paper.printColorMode ?? 'cmyk';
const key = `${options.background}|${options.opacity}|${options.fit}|${paperW}|${paperH}|${imageFormat}|${printColorMode}`;
if (cache.key !== key) {
cache.key = key;
cache.dataUrl = await renderLayoutPageBackgroundDataUrl(
paperW,
paperH,
options,
4,
imageFormat === 'JPEG' ? 'jpeg' : 'png',
printColorMode
);
}
if (cache.dataUrl) {
pdf.addImage(cache.dataUrl, imageFormat, 0, 0, paperW, paperH, undefined, 'FAST');
}
}
function parseColorToRgb(color: string): [number, number, number] { function parseColorToRgb(color: string): [number, number, number] {
const c = color.trim(); const c = color.trim();
if (c.startsWith('#')) { if (c.startsWith('#')) {
@@ -141,6 +178,7 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
let done = 0; let done = 0;
let progressTick = 0; let progressTick = 0;
const pageBgCache: PageBackgroundCache = { dataUrl: null, key: null };
const report = (page: number, phase: PdfExportProgress['phase']) => { const report = (page: number, phase: PdfExportProgress['phase']) => {
onProgress?.({ done, total: totalLabels, page, totalPages, phase }); onProgress?.({ done, total: totalLabels, page, totalPages, phase });
@@ -151,6 +189,8 @@ export async function exportLabelsPdfAsync(options: AsyncExportLabelsPdfOptions)
pdf.addPage([paperW, paperH], orientation); pdf.addPage([paperW, paperH], orientation);
} }
await addPageBackgroundIfNeeded(pdf, template, paper, paperW, paperH, imageFormat, pageBgCache);
const pageRows = printablePages[pIdx]; const pageRows = printablePages[pIdx];
const gridRows = Math.ceil(pageRows.length / gridCols); const gridRows = Math.ceil(pageRows.length / gridCols);
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine); const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);
@@ -242,6 +282,7 @@ export async function exportLabelsPdfBlobAsync(
let done = 0; let done = 0;
let progressTick = 0; let progressTick = 0;
const pageBgCache: PageBackgroundCache = { dataUrl: null, key: null };
const report = (page: number, phase: PdfExportProgress['phase']) => { const report = (page: number, phase: PdfExportProgress['phase']) => {
onProgress?.({ done, total: totalLabels, page, totalPages, phase }); onProgress?.({ done, total: totalLabels, page, totalPages, phase });
@@ -252,6 +293,8 @@ export async function exportLabelsPdfBlobAsync(
pdf.addPage([paperW, paperH], orientation); pdf.addPage([paperW, paperH], orientation);
} }
await addPageBackgroundIfNeeded(pdf, template, paper, paperW, paperH, imageFormat, pageBgCache);
const pageRows = printablePages[pIdx]; const pageRows = printablePages[pIdx];
const gridRows = Math.ceil(pageRows.length / gridCols); const gridRows = Math.ceil(pageRows.length / gridCols);
const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine); const cutChannel = getCutLineChannel(paper.columnGap, paper.rowGap, cutLine);

View File

@@ -56,6 +56,36 @@ export function getTableColWidths(elem: TemplateElement): number[] {
return Array.from({ length: cols }, () => even); return Array.from({ length: cols }, () => even);
} }
/** 调整末项(必要时向前)使轨道总和精确等于 targetTotal */
function absorbTrackSumDiff(tracks: number[], targetTotal: number, min = 0.5): number[] {
const result = tracks.map((t) => round1(Math.max(min, t)));
let diff = round1(targetTotal - result.reduce((a, b) => a + b, 0));
for (let i = result.length - 1; i >= 0 && diff !== 0; i--) {
const adjusted = round1(Math.max(min, result[i] + diff));
const applied = round1(adjusted - result[i]);
result[i] = adjusted;
diff = round1(diff - applied);
}
return result;
}
/** 按比例缩放轨道,严格使总和等于 targetTotal不放大目标总量 */
function scaleTracksToExactTotal(tracks: number[], targetTotal: number, min = 0.5): number[] {
if (tracks.length === 0) return [];
const count = tracks.length;
const floor = count * min;
if (targetTotal <= floor + 0.01) {
return absorbTrackSumDiff(Array.from({ length: count }, () => min), targetTotal, min);
}
const current = tracks.reduce((a, b) => a + b, 0);
if (current <= 0) {
const even = round1(targetTotal / count);
return absorbTrackSumDiff(Array.from({ length: count }, () => even), targetTotal, min);
}
const scaled = tracks.map((t) => round1(Math.max(min, (t / current) * targetTotal)));
return absorbTrackSumDiff(scaled, targetTotal, min);
}
/** 将轨道尺寸总和调整为 targetTotal保持相对比例末项吸收取整误差 */ /** 将轨道尺寸总和调整为 targetTotal保持相对比例末项吸收取整误差 */
function scaleTracksToTotal(tracks: number[], targetTotal: number, min = 0.5): number[] { function scaleTracksToTotal(tracks: number[], targetTotal: number, min = 0.5): number[] {
if (tracks.length === 0) return []; if (tracks.length === 0) return [];
@@ -73,6 +103,48 @@ function scaleTracksToTotal(tracks: number[], targetTotal: number, min = 0.5): n
return scaled; return scaled;
} }
/** 调整单条轨道尺寸,保持轨道总和不变;优先由 index 之后的轨道吸收差值 */
function resizeTrackKeepingTotal(
tracks: number[],
index: number,
sizeMm: number,
min = 0.5
): number[] {
if (index < 0 || index >= tracks.length) return tracks;
const total = round1(tracks.reduce((a, b) => a + b, 0));
if (tracks.length === 1) {
return [round1(Math.max(min, Math.min(total, sizeMm)))];
}
const beforeSum = round1(tracks.slice(0, index).reduce((a, b) => a + b, 0));
const afterCount = tracks.length - index - 1;
const beforeCount = index;
const maxForIndex =
afterCount > 0
? round1(total - beforeSum - afterCount * min)
: round1(total - beforeCount * min);
const newSize = round1(Math.max(min, Math.min(maxForIndex, sizeMm)));
const result = [...tracks];
result[index] = newSize;
if (afterCount > 0) {
const afterRemaining = round1(total - beforeSum - newSize);
const scaledAfter = scaleTracksToExactTotal(tracks.slice(index + 1), afterRemaining, min);
for (let i = 0; i < scaledAfter.length; i++) {
result[index + 1 + i] = scaledAfter[i];
}
} else if (beforeCount > 0) {
const beforeRemaining = round1(total - newSize);
const scaledBefore = scaleTracksToExactTotal(tracks.slice(0, index), beforeRemaining, min);
for (let i = 0; i < scaledBefore.length; i++) {
result[i] = scaledBefore[i];
}
}
return absorbTrackSumDiff(result, total, min);
}
/** 将 inner 区域均分给 count 条轨道(末项吸收取整误差) */ /** 将 inner 区域均分给 count 条轨道(末项吸收取整误差) */
function distributeTracks(total: number, count: number, min = 0.5): number[] { function distributeTracks(total: number, count: number, min = 0.5): number[] {
if (count <= 0) return []; if (count <= 0) return [];
@@ -132,8 +204,14 @@ export function scaleTableTracks(
const scaleW = newInnerW / startInnerW; const scaleW = newInnerW / startInnerW;
const scaleH = newInnerH / startInnerH; const scaleH = newInnerH / startInnerH;
const tableRowHeights = startRowHeights.map((h) => round1(Math.max(0.5, h * scaleH))); const tableRowHeights = scaleTracksToExactTotal(
const tableColWidths = startColWidths.map((w) => round1(Math.max(0.5, w * scaleW))); startRowHeights.map((h) => h * scaleH),
newInnerH
);
const tableColWidths = scaleTracksToExactTotal(
startColWidths.map((w) => w * scaleW),
newInnerW
);
return { return {
...elem, ...elem,
@@ -456,14 +534,27 @@ export function updateTableRowHeight(
rowIndex: number, rowIndex: number,
heightMm: number heightMm: number
): TemplateElement { ): TemplateElement {
const rowHeights = [...getTableRowHeights(elem)]; if (rowIndex < 0 || rowIndex >= getTableRows(elem)) return elem;
if (rowIndex < 0 || rowIndex >= rowHeights.length) return elem;
rowHeights[rowIndex] = Math.max(0.5, round1(heightMm));
const pad = resolvePaddingMm(elem); const pad = resolvePaddingMm(elem);
const innerH = getTableInnerSizeMm(elem).height;
const rowHeights = scaleTracksToExactTotal(getTableRowHeights(elem), innerH);
const isLast = rowIndex === rowHeights.length - 1;
if (isLast) {
const nextHeights = [...rowHeights];
nextHeights[rowIndex] = round1(Math.max(0.5, heightMm));
const newInnerH = round1(nextHeights.reduce((a, b) => a + b, 0));
return {
...elem,
tableRowHeights: nextHeights,
height: round1(newInnerH + pad.top + pad.bottom),
};
}
return { return {
...elem, ...elem,
tableRowHeights: rowHeights, tableRowHeights: resizeTrackKeepingTotal(rowHeights, rowIndex, heightMm),
height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom), height: elem.height,
}; };
} }
@@ -472,14 +563,55 @@ export function updateTableColWidth(
colIndex: number, colIndex: number,
widthMm: number widthMm: number
): TemplateElement { ): TemplateElement {
const colWidths = [...getTableColWidths(elem)]; if (colIndex < 0 || colIndex >= getTableCols(elem)) return elem;
if (colIndex < 0 || colIndex >= colWidths.length) return elem;
colWidths[colIndex] = Math.max(0.5, round1(widthMm));
const pad = resolvePaddingMm(elem); const pad = resolvePaddingMm(elem);
const innerW = getTableInnerSizeMm(elem).width;
const colWidths = scaleTracksToExactTotal(getTableColWidths(elem), innerW);
const isLast = colIndex === colWidths.length - 1;
if (isLast) {
const nextWidths = [...colWidths];
nextWidths[colIndex] = round1(Math.max(0.5, widthMm));
const newInnerW = round1(nextWidths.reduce((a, b) => a + b, 0));
return {
...elem,
tableColWidths: nextWidths,
width: round1(newInnerW + pad.left + pad.right),
};
}
return { return {
...elem, ...elem,
tableColWidths: colWidths, tableColWidths: resizeTrackKeepingTotal(colWidths, colIndex, widthMm),
width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right), width: elem.width,
};
}
/** 在现有元素高度内均分行高 */
export function equalizeTableRowHeights(elem: TemplateElement): TemplateElement {
const rows = getTableRows(elem);
const innerH = getTableInnerSizeMm(elem).height;
return {
...elem,
tableRowHeights: scaleTracksToExactTotal(
Array.from({ length: rows }, () => innerH / rows),
innerH
),
height: elem.height,
};
}
/** 在现有元素宽度内均分列宽 */
export function equalizeTableColWidths(elem: TemplateElement): TemplateElement {
const cols = getTableCols(elem);
const innerW = getTableInnerSizeMm(elem).width;
return {
...elem,
tableColWidths: scaleTracksToExactTotal(
Array.from({ length: cols }, () => innerW / cols),
innerW
),
width: elem.width,
}; };
} }
@@ -531,6 +663,8 @@ export function getEffectiveCellStyle(
textWritingMode: pick('textWritingMode', elem.textWritingMode ?? 'horizontal'), textWritingMode: pick('textWritingMode', elem.textWritingMode ?? 'horizontal'),
textColor: pick('textColor', elem.textColor ?? '#111827'), textColor: pick('textColor', elem.textColor ?? '#111827'),
wordWrap: cell?.wordWrap ?? elem.wordWrap !== false, wordWrap: cell?.wordWrap ?? elem.wordWrap !== false,
textMaxLines: pick('textMaxLines', elem.textMaxLines),
textEllipsis: pick('textEllipsis', elem.textEllipsis ?? false),
fontFamily: pick('fontFamily', elem.fontFamily), fontFamily: pick('fontFamily', elem.fontFamily),
textFormat: pick('textFormat', elem.textFormat ?? 'text'), textFormat: pick('textFormat', elem.textFormat ?? 'text'),
decimalPlaces: pick('decimalPlaces', elem.decimalPlaces ?? 2), decimalPlaces: pick('decimalPlaces', elem.decimalPlaces ?? 2),
@@ -539,6 +673,7 @@ export function getEffectiveCellStyle(
textSplitDelimiter: cell?.textSplitDelimiter ?? elem.textSplitDelimiter, textSplitDelimiter: cell?.textSplitDelimiter ?? elem.textSplitDelimiter,
textSplitIndex: cell?.textSplitIndex ?? elem.textSplitIndex, textSplitIndex: cell?.textSplitIndex ?? elem.textSplitIndex,
textExtractLength: cell?.textExtractLength ?? elem.textExtractLength, textExtractLength: cell?.textExtractLength ?? elem.textExtractLength,
textExtractInverse: cell?.textExtractInverse ?? elem.textExtractInverse,
textDisplayPrefix: cell?.textDisplayPrefix ?? elem.textDisplayPrefix, textDisplayPrefix: cell?.textDisplayPrefix ?? elem.textDisplayPrefix,
textDisplaySuffix: cell?.textDisplaySuffix ?? elem.textDisplaySuffix, textDisplaySuffix: cell?.textDisplaySuffix ?? elem.textDisplaySuffix,
backgroundColor: cell?.backgroundColor, backgroundColor: cell?.backgroundColor,
@@ -561,6 +696,8 @@ const CELL_TEXT_PATCH_KEYS: (keyof TableCellData)[] = [
'textColor', 'textColor',
'backgroundColor', 'backgroundColor',
'wordWrap', 'wordWrap',
'textMaxLines',
'textEllipsis',
'fontFamily', 'fontFamily',
'textFormat', 'textFormat',
'decimalPlaces', 'decimalPlaces',
@@ -569,6 +706,7 @@ const CELL_TEXT_PATCH_KEYS: (keyof TableCellData)[] = [
'textSplitDelimiter', 'textSplitDelimiter',
'textSplitIndex', 'textSplitIndex',
'textExtractLength', 'textExtractLength',
'textExtractInverse',
'textDisplayPrefix', 'textDisplayPrefix',
'textDisplaySuffix', 'textDisplaySuffix',
]; ];

View File

@@ -10,18 +10,41 @@ export const TEXT_EXTRACT_MODE_LABELS: Record<TextExtractMode, string> = {
prefix: '前 N 位', prefix: '前 N 位',
suffix: '后 N 位', suffix: '后 N 位',
}; };
/** 变量/内容值提取配置(元素、表格单元格、显示条件等共用) */
export interface ValueExtractConfig {
textExtractMode?: TextExtractMode;
textSplitDelimiter?: string;
textSplitIndex?: number;
textExtractLength?: number;
/** 反选:从原值中去掉「显示前缀 + 提取段 + 显示后缀」 */
textExtractInverse?: boolean;
textDisplayPrefix?: string;
textDisplaySuffix?: string;
}
export type ElementVisibilityMode = 'always' | 'conditional'; export type ElementVisibilityMode = 'always' | 'conditional';
export type VisibilityOperator = 'empty' | 'not_empty' | 'gt' | 'lt' | 'eq' | 'neq'; export type VisibilityOperator =
| 'empty'
| 'not_empty'
| 'gt'
| 'lt'
| 'eq'
| 'neq'
| 'contains'
| 'not_contains';
/** 显示条件判断对象:变量值 或 本元素解析后的内容值 */ /** 显示条件判断对象:变量值 或 本元素解析后的内容值 */
export type VisibilityRuleTarget = 'variable' | 'content'; export type VisibilityRuleTarget = 'variable' | 'content';
export interface VisibilityRule { export interface VisibilityRule extends ValueExtractConfig {
/** 条件对象,默认 variable */ /** 条件对象,默认 variable */
target?: VisibilityRuleTarget; target?: VisibilityRuleTarget;
variable: string; variable: string;
operator: VisibilityOperator; operator: VisibilityOperator;
/** gt / lt / eq / neq 时的比较目标值 */ /** gt / lt / eq / neq / contains / not_contains 时的比较目标值,支持 {变量名} */
value?: string; value?: string;
/** 为 false 时跳过该条件;默认启用 */
enabled?: boolean;
} }
export const VISIBILITY_RULE_TARGET_LABELS: Record<VisibilityRuleTarget, string> = { export const VISIBILITY_RULE_TARGET_LABELS: Record<VisibilityRuleTarget, string> = {
@@ -36,6 +59,8 @@ export const VISIBILITY_OPERATOR_LABELS: Record<VisibilityOperator, string> = {
lt: '小于', lt: '小于',
eq: '等于', eq: '等于',
neq: '不等于', neq: '不等于',
contains: '包含',
not_contains: '不包含',
}; };
export type ElementSide = 'top' | 'right' | 'bottom' | 'left'; export type ElementSide = 'top' | 'right' | 'bottom' | 'left';
export type ElementCorner = 'tl' | 'tr' | 'br' | 'bl'; export type ElementCorner = 'tl' | 'tr' | 'br' | 'bl';
@@ -63,6 +88,10 @@ export interface TableCellData {
textColor?: string; textColor?: string;
backgroundColor?: string; backgroundColor?: string;
wordWrap?: boolean; wordWrap?: boolean;
/** 最大显示行数(横排为行、竖排为列);未设置或 0 表示不限制 */
textMaxLines?: number;
/** 超出最大行数时末行/末列显示省略号 */
textEllipsis?: boolean;
fontFamily?: 'sans' | 'serif' | 'mono'; fontFamily?: 'sans' | 'serif' | 'mono';
textFormat?: TextFormat; textFormat?: TextFormat;
decimalPlaces?: number; decimalPlaces?: number;
@@ -71,6 +100,8 @@ export interface TableCellData {
textSplitDelimiter?: string; textSplitDelimiter?: string;
textSplitIndex?: number; textSplitIndex?: number;
textExtractLength?: number; textExtractLength?: number;
/** 反选:从原值中去掉「显示前缀 + 提取段 + 显示后缀」 */
textExtractInverse?: boolean;
textDisplayPrefix?: string; textDisplayPrefix?: string;
textDisplaySuffix?: string; textDisplaySuffix?: string;
} }
@@ -79,6 +110,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
@@ -102,6 +135,10 @@ export interface TemplateElement {
verticalAlign?: 'top' | 'middle' | 'bottom'; verticalAlign?: 'top' | 'middle' | 'bottom';
/** 是否自动换行,仅 type=text默认 true */ /** 是否自动换行,仅 type=text默认 true */
wordWrap?: boolean; wordWrap?: boolean;
/** 最大显示行数(横排为行、竖排为列);未设置或 0 表示不限制,仅 type=text */
textMaxLines?: number;
/** 超出最大行数时显示省略号,仅 type=text */
textEllipsis?: boolean;
/** 文本排列方向,仅 type=text默认 horizontal */ /** 文本排列方向,仅 type=text默认 horizontal */
textWritingMode?: 'horizontal' | 'vertical'; textWritingMode?: 'horizontal' | 'vertical';
fontWeight: 'normal' | 'bold'; fontWeight: 'normal' | 'bold';
@@ -131,6 +168,8 @@ export interface TemplateElement {
paddingBottom?: number; paddingBottom?: number;
paddingLeft?: number; paddingLeft?: number;
locked?: boolean; locked?: boolean;
/** 图层是否隐藏;隐藏后在预览、导出、打印及画布中均不显示 */
hidden?: boolean;
/** 图片缩放方式,仅 type=image 时有效 */ /** 图片缩放方式,仅 type=image 时有效 */
imageFit?: 'contain' | 'cover' | 'fill'; imageFit?: 'contain' | 'cover' | 'fill';
/** 默认图片 URL 或 data URI主图加载失败时使用仅 type=image 时有效 */ /** 默认图片 URL 或 data URI主图加载失败时使用仅 type=image 时有效 */
@@ -151,6 +190,8 @@ export interface TemplateElement {
textSplitIndex?: number; textSplitIndex?: number;
/** 前 N / 后 N 位提取时的位数 */ /** 前 N / 后 N 位提取时的位数 */
textExtractLength?: number; textExtractLength?: number;
/** 反选:从原值中去掉「显示前缀 + 提取段 + 显示后缀」 */
textExtractInverse?: boolean;
/** 提取后在显示文本前追加的前缀字符,仅 type=text不参与内容值条件 */ /** 提取后在显示文本前追加的前缀字符,仅 type=text不参与内容值条件 */
textDisplayPrefix?: string; textDisplayPrefix?: string;
/** 提取后在显示文本后追加的后缀字符,仅 type=text不参与内容值条件 */ /** 提取后在显示文本后追加的后缀字符,仅 type=text不参与内容值条件 */
@@ -190,6 +231,37 @@ export interface TemplateElement {
tableBorderWidth?: number; tableBorderWidth?: number;
/** 单元格网格线颜色,仅 type=table */ /** 单元格网格线颜色,仅 type=table */
tableBorderColor?: string; tableBorderColor?: string;
/** 图层蒙版;在预览与导出时裁剪元素可见区域 */
mask?: ElementMaskConfig;
}
export type ElementMaskShape = 'rect' | 'image';
export interface ElementMaskConfig {
/** 是否启用蒙版 */
enabled?: boolean;
/** 蒙版形状:矩形矢量区域或图片 alpha */
type?: ElementMaskShape;
/** 相对元素左上角的 X 偏移 (mm) */
x?: number;
/** 相对元素左上角的 Y 偏移 (mm) */
y?: number;
/** 蒙版区域宽度 (mm),默认与元素同宽 */
width?: number;
/** 蒙版区域高度 (mm),默认与元素同高 */
height?: number;
/** 圆角半径 (mm) */
borderRadius?: number;
borderRadiusTL?: number;
borderRadiusTR?: number;
borderRadiusBR?: number;
borderRadiusBL?: number;
/** 图片蒙版data URI / URLtype=image 时使用 */
image?: string;
/** 图片蒙版在区域内的适应方式 */
imageFit?: 'contain' | 'cover' | 'fill';
/** include=保留蒙版区域内exclude=保留区域外;默认 include */
mode?: 'include' | 'exclude';
} }
export interface DataSourceMeta { export interface DataSourceMeta {
@@ -214,7 +286,7 @@ export type ExportRangeMode = 'all' | 'odd' | 'even' | 'custom';
export interface ExportRangeConfig { export interface ExportRangeConfig {
mode: ExportRangeMode; mode: ExportRangeMode;
/** 自定义范围1-based 序号,如 "1,3,5-10" */ /** 自定义范围1-based 序号,如 "1,3,5-10"(排版过滤,不写入模板) */
custom?: string; custom?: string;
} }
@@ -228,14 +300,51 @@ export interface LabelTemplate {
variableList?: string[]; variableList?: string[];
/** 设计预览用:变量名 → 默认展示值 */ /** 设计预览用:变量名 → 默认展示值 */
variableDefaults?: Record<string, string>; variableDefaults?: Record<string, string>;
/** 数据过滤条件(写入模板);全部满足的数据行才参与排版/导出 */
dataFilterRules?: VisibilityRule[];
/** 整页排版配置(每模板独立) */ /** 整页排版配置(每模板独立) */
paper?: PaperConfig; paper?: PaperConfig;
/** PDF 裁切参考线配置 */ /** PDF 裁切参考线配置 */
cutLine?: CutLineConfig; cutLine?: CutLineConfig;
/** 设计画布预览参考背景URL / data URI */
previewReferenceBackground?: string;
/** 预览参考背景不透明度 0100默认 100 */
previewReferenceBackgroundOpacity?: number;
/** 预览参考背景适应方式,默认 cover */
previewReferenceBackgroundFit?: 'contain' | 'cover' | 'fill';
/** 设计预览(画布、模板列表缩略图)是否显示参考背景,默认 true */
enablePreviewReferenceBackground?: boolean;
/** 生成 PDF / 打印 / 排版预览输出时是否绘制参考背景,默认 false */
includeReferenceBackgroundInOutput?: boolean;
/** 排版整页背景图URL / data URI */
layoutPageBackground?: string;
/** 排版整页背景不透明度 0100默认 100 */
layoutPageBackgroundOpacity?: number;
/** 排版整页背景适应方式,默认 cover */
layoutPageBackgroundFit?: 'contain' | 'cover' | 'fill';
/** 排版预览是否显示整页背景,默认 true */
enableLayoutPageBackground?: boolean;
/** PDF / 打印输出时是否绘制整页背景,默认 false */
includeLayoutPageBackgroundInOutput?: boolean;
/** 批量导出图片时的文件名模板,支持 {#SEQ}、{#INDEX}、{变量名};留空则按序号补零命名 */
exportImageFileNamePattern?: string;
/** 批量导出图片all=全部导出conditional=按条件过滤 */
exportImageFilterMode?: ElementVisibilityMode;
/** 批量导出图片过滤条件;全部满足时才导出该枚标签 */
exportImageFilterRules?: VisibilityRule[];
} }
export type PaperType = 'A4' | 'A5' | 'A6' | 'custom'; export type PaperType = 'A4' | 'A5' | 'A6' | 'custom';
/** 排版/印刷输出色彩模式 */
export type PrintColorMode = 'rgb' | 'grayscale' | 'cmyk';
export const PRINT_COLOR_MODE_LABELS: Record<PrintColorMode, string> = {
rgb: '标准(轻度印刷补偿)',
grayscale: '黑白(灰度)',
cmyk: 'CMYK 印刷模拟(推荐)',
};
export interface PaperConfig { export interface PaperConfig {
type: PaperType; type: PaperType;
width: number; // in mm width: number; // in mm
@@ -248,6 +357,8 @@ export interface PaperConfig {
columnGap: number; // in mm (horizontal spacing between labels) columnGap: number; // in mm (horizontal spacing between labels)
rowGap: number; // in mm (vertical spacing between labels) rowGap: number; // in mm (vertical spacing between labels)
flow: 'top-bottom-left-right' | 'left-right-top-bottom'; // how cells flow flow: 'top-bottom-left-right' | 'left-right-top-bottom'; // how cells flow
/** 排版预览与导出时的色彩模式,默认 rgb */
printColorMode?: PrintColorMode;
} }
export interface RecordRow { export interface RecordRow {

View File

@@ -13,6 +13,7 @@ import {
VisibilityRule, VisibilityRule,
VisibilityOperator, VisibilityOperator,
VISIBILITY_OPERATOR_LABELS, VISIBILITY_OPERATOR_LABELS,
ValueExtractConfig,
} from './types'; } from './types';
const IMPORT_DATA_STORAGE_PREFIX = 'label_import_data_v2_'; const IMPORT_DATA_STORAGE_PREFIX = 'label_import_data_v2_';
@@ -27,6 +28,105 @@ export const VARIABLE_MAPPING_USE_ROW_INDEX = '__use_row_index__';
export const VARIABLE_MAPPING_ROW_INDEX_LABEL = '数据序号'; export const VARIABLE_MAPPING_ROW_INDEX_LABEL = '数据序号';
/** 粘贴副本时相对原位置的偏移 (mm) */
export const PASTE_ELEMENT_OFFSET_MM = 2;
/** 在标签画布上居中放置元素mm保留 1 位小数) */
export function centerElementOnLabel(
labelWidth: number,
labelHeight: number,
elementWidth: number,
elementHeight: number
): { x: number; y: number } {
const x = Math.round(((labelWidth - elementWidth) / 2) * 10) / 10;
const y = Math.round(((labelHeight - elementHeight) / 2) * 10) / 10;
return {
x: Math.max(0, x),
y: Math.max(0, y),
};
}
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[],
pasteRepeat: number
): TemplateElement[] {
const offset = PASTE_ELEMENT_OFFSET_MM * Math.max(1, pasteRepeat);
const stamp = Date.now();
return elements.map((element, index) => {
const cloned = JSON.parse(JSON.stringify(element)) as TemplateElement;
cloned.id = `${element.type}_${stamp}_${index}`;
cloned.name = `${element.name} 副本`;
cloned.x = Math.round((element.x + offset) * 10) / 10;
cloned.y = Math.round((element.y + offset) * 10) / 10;
return cloned;
});
}
export function toDataSourceMeta(data: ImportData): DataSourceMeta { export function toDataSourceMeta(data: ImportData): DataSourceMeta {
return { return {
fileName: data.fileName, fileName: data.fileName,
@@ -157,11 +257,67 @@ export const DEFAULT_EXPORT_RANGE: ExportRangeConfig = { mode: 'all' };
export const exportRangeEquals = (a: ExportRangeConfig, b: ExportRangeConfig) => export const exportRangeEquals = (a: ExportRangeConfig, b: ExportRangeConfig) =>
a.mode === b.mode && (a.custom ?? '') === (b.custom ?? ''); a.mode === b.mode && (a.custom ?? '') === (b.custom ?? '');
/** 模板数据过滤规则(仅 variable 目标) */
export function resolveDataFilterRules(
template: Pick<LabelTemplate, 'dataFilterRules'>
): VisibilityRule[] {
return resolveVariableFilterRules(template.dataFilterRules);
}
/** 按模板数据过滤条件筛选 0-based 行索引 */
export function getDataFilteredIndices(
rows: RecordRow[],
template: Pick<LabelTemplate, 'dataFilterRules'>,
variableDefaults?: Record<string, string> | null
): number[] {
const total = rows.length;
if (total <= 0) return [];
const rules = getActiveVisibilityRules(resolveDataFilterRules(template));
if (rules.length === 0) return Array.from({ length: total }, (_, i) => i);
return Array.from({ length: total }, (_, i) => i).filter((i) =>
rowMatchesVariableFilterRules(rows[i], i, rules, variableDefaults)
);
}
/** 在候选行上应用排版过滤(奇偶/自定义序号,基于原始 1-based 行号) */
export function getLayoutRangeIndices(
candidateIndices: number[],
totalRows: number,
range: ExportRangeConfig
): number[] {
if (candidateIndices.length === 0) return [];
switch (range.mode) {
case 'odd':
return candidateIndices.filter((i) => (i + 1) % 2 === 1);
case 'even':
return candidateIndices.filter((i) => (i + 1) % 2 === 0);
case 'custom': {
const parsed = parseCustomExportRange(range.custom ?? '', totalRows);
const candidateSet = new Set(candidateIndices);
return parsed.filter((i) => candidateSet.has(i));
}
case 'all':
default:
return candidateIndices;
}
}
/** 按数据过滤 + 排版过滤得到最终 0-based 行索引(保持升序) */
export function getExportRangeIndices(
rows: RecordRow[],
range: ExportRangeConfig,
template: Pick<LabelTemplate, 'dataFilterRules'>,
variableDefaults?: Record<string, string> | null
): number[] {
const dataFiltered = getDataFilteredIndices(rows, template, variableDefaults);
return getLayoutRangeIndices(dataFiltered, rows.length, range);
}
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 共用 */
@@ -245,22 +401,6 @@ export interface PrintableLabel {
sourceIndex: number; sourceIndex: number;
} }
/** 按导出范围筛选 0-based 行索引(保持升序) */
export function getExportRangeIndices(total: number, range: ExportRangeConfig): number[] {
if (total <= 0) return [];
switch (range.mode) {
case 'odd':
return Array.from({ length: total }, (_, i) => i).filter((i) => (i + 1) % 2 === 1);
case 'even':
return Array.from({ length: total }, (_, i) => i).filter((i) => (i + 1) % 2 === 0);
case 'custom':
return parseCustomExportRange(range.custom ?? '', total);
case 'all':
default:
return Array.from({ length: total }, (_, i) => i);
}
}
/** 解析自定义范围字符串1-based 序号) */ /** 解析自定义范围字符串1-based 序号) */
export function parseCustomExportRange(input: string, total: number): number[] { export function parseCustomExportRange(input: string, total: number): number[] {
const trimmed = input.trim(); const trimmed = input.trim();
@@ -286,12 +426,13 @@ export function parseCustomExportRange(input: string, total: number): number[] {
export function buildPrintablePages( export function buildPrintablePages(
rows: RecordRow[], rows: RecordRow[],
paper: PaperConfig, paper: PaperConfig,
labelSize: { width: number; height: number }, template: Pick<LabelTemplate, 'width' | 'height' | 'dataFilterRules'>,
exportRange: ExportRangeConfig exportRange: ExportRangeConfig,
variableDefaults?: Record<string, string> | null
) { ) {
const gridInfo = calculateGrid(paper, labelSize); const gridInfo = calculateGrid(paper, template);
const labelsPerPage = gridInfo.cols * gridInfo.rows; const labelsPerPage = gridInfo.cols * gridInfo.rows;
const indices = getExportRangeIndices(rows.length, exportRange); const indices = getExportRangeIndices(rows, exportRange, template, variableDefaults);
const selected: PrintableLabel[] = indices.map((i) => ({ row: rows[i], sourceIndex: i })); const selected: PrintableLabel[] = indices.map((i) => ({ row: rows[i], sourceIndex: i }));
const totalPages = Math.ceil(selected.length / labelsPerPage) || 1; const totalPages = Math.ceil(selected.length / labelsPerPage) || 1;
const pages: (PrintableLabel | null)[][] = []; const pages: (PrintableLabel | null)[][] = [];
@@ -393,11 +534,36 @@ 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',
printColorMode: 'cmyk',
}; };
/** 根据纸张排版参数反算单个标签尺寸 (mm) */
export function computeLabelSizeFromLayout(
paper: PaperConfig,
cols: number,
rows: number
): { width: number; height: number } | null {
if (cols < 1 || rows < 1) return null;
const paperW = paper.orientation === 'portrait' ? paper.width : paper.height;
const paperH = paper.orientation === 'portrait' ? paper.height : paper.width;
const usableW = paperW - paper.marginLeft - paper.marginRight;
const usableH = paperH - paper.marginTop - paper.marginBottom;
if (usableW <= 0 || usableH <= 0) return null;
const width = (usableW - (cols - 1) * paper.columnGap) / cols;
const height = (usableH - (rows - 1) * paper.rowGap) / rows;
if (width < 10 || height < 10) return null;
return {
width: Math.round(width * 10) / 10,
height: Math.round(height * 10) / 10,
};
}
/** 补全模板默认排版字段,剥离已废弃的数据源字段 */ /** 补全模板默认排版字段,剥离已废弃的数据源字段 */
export function normalizeTemplate(tmpl: LabelTemplate): LabelTemplate { export function normalizeTemplate(tmpl: LabelTemplate): LabelTemplate {
const { ...rest } = tmpl; const { ...rest } = tmpl;
@@ -495,6 +661,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>();
@@ -517,32 +701,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);
} }
@@ -578,28 +766,53 @@ export function isConditionalVisibility(elem: TemplateElement): boolean {
} }
export function visibilityOperatorNeedsValue(operator: VisibilityOperator): boolean { export function visibilityOperatorNeedsValue(operator: VisibilityOperator): boolean {
return operator === 'gt' || operator === 'lt' || operator === 'eq' || operator === 'neq'; return (
operator === 'gt' ||
operator === 'lt' ||
operator === 'eq' ||
operator === 'neq' ||
operator === 'contains' ||
operator === 'not_contains'
);
}
export function isVisibilityRuleEnabled(rule: VisibilityRule): boolean {
return rule.enabled !== false;
}
export function getActiveVisibilityRules(rules: VisibilityRule[]): VisibilityRule[] {
return rules.filter(isVisibilityRuleEnabled);
}
export function toggleVisibilityRuleEnabled(rule: VisibilityRule): VisibilityRule {
return { ...rule, enabled: isVisibilityRuleEnabled(rule) ? false : undefined };
} }
/** 将显示条件格式化为可读说明 */ /** 将显示条件格式化为可读说明 */
export function formatVisibilityRuleDescription(rule: VisibilityRule): string { export function formatVisibilityRuleDescription(rule: VisibilityRule): string {
const extractNote = formatValueExtractSummary(rule);
const subjectLabel = const subjectLabel =
rule.target === 'content' ? '内容值' : `{${rule.variable}}`; rule.target === 'content' ? '内容值' : `{${rule.variable}}`;
const subject = extractNote ? `${subjectLabel}${extractNote}` : subjectLabel;
switch (rule.operator) { switch (rule.operator) {
case 'empty': case 'empty':
return `${subjectLabel} 为空`; return `${subject} 为空`;
case 'not_empty': case 'not_empty':
return `${subjectLabel} 不为空`; return `${subject} 不为空`;
case 'gt': case 'gt':
return `${subjectLabel} 大于 ${rule.value ?? '—'}`; return `${subject} 大于 ${rule.value ?? '—'}`;
case 'lt': case 'lt':
return `${subjectLabel} 小于 ${rule.value ?? '—'}`; return `${subject} 小于 ${rule.value ?? '—'}`;
case 'eq': case 'eq':
return `${subjectLabel} 等于 ${rule.value ?? '—'}`; return `${subject} 等于 ${rule.value ?? '—'}`;
case 'neq': case 'neq':
return `${subjectLabel} 不等于 ${rule.value ?? '—'}`; return `${subject} 不等于 ${rule.value ?? '—'}`;
case 'contains':
return `${subject} 包含 ${rule.value ?? '—'}`;
case 'not_contains':
return `${subject} 不包含 ${rule.value ?? '—'}`;
default: default:
return `${subjectLabel} ${VISIBILITY_OPERATOR_LABELS[rule.operator]}`; return `${subject} ${VISIBILITY_OPERATOR_LABELS[rule.operator]}`;
} }
} }
@@ -616,6 +829,14 @@ export function resolveVisibilityRules(elem: TemplateElement): VisibilityRule[]
variable: (r.variable ?? '').trim(), variable: (r.variable ?? '').trim(),
operator: r.operator, operator: r.operator,
value: r.value, value: r.value,
enabled: r.enabled,
textExtractMode: r.textExtractMode,
textSplitDelimiter: r.textSplitDelimiter,
textSplitIndex: r.textSplitIndex,
textExtractLength: r.textExtractLength,
textExtractInverse: r.textExtractInverse,
textDisplayPrefix: r.textDisplayPrefix,
textDisplaySuffix: r.textDisplaySuffix,
})) }))
.filter((r) => r.target === 'content' || r.variable); .filter((r) => r.target === 'content' || r.variable);
} }
@@ -661,7 +882,7 @@ function compareVariableToTarget(
return false; return false;
} }
/** 读取显示条件所判断的原始值 */ /** 读取显示条件所判断的原始值(含条件专用值提取) */
function getVisibilityRuleRawValue( function getVisibilityRuleRawValue(
rule: VisibilityRule, rule: VisibilityRule,
elem: TemplateElement | undefined, elem: TemplateElement | undefined,
@@ -670,15 +891,43 @@ function getVisibilityRuleRawValue(
defaults?: Record<string, string> | null, defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'output' mode: ContentResolveMode = 'output'
): string | undefined { ): string | undefined {
let resolved: string | undefined;
if (isVisibilityContentTarget(rule)) { if (isVisibilityContentTarget(rule)) {
if (!elem) return undefined; if (!elem) return undefined;
const effectiveRow = const effectiveRow =
row ?? (mode === 'design' && defaults ? (defaults as RecordRow) : null); row ?? (mode === 'design' && defaults ? (defaults as RecordRow) : null);
const resolved = resolveElementCoreValue(elem, effectiveRow, rowIndex, defaults, 'output'); const formatSubst = textElementNeedsNumberFormatSubst(elem)
const trimmed = resolved.trim(); ? (raw: string) => formatTextNumberSubstValue(elem, raw)
return trimmed || undefined; : undefined;
const trimmed = resolveContent(elem.content, effectiveRow, rowIndex, defaults, {
mode: 'output',
formatSubst,
}).trim();
resolved = trimmed || undefined;
} else {
resolved = getVariableRawValue(rule.variable, row, defaults, mode);
} }
return getVariableRawValue(rule.variable, row, defaults, mode);
if (resolved === undefined) return undefined;
if (!valueExtractConfigHasExtract(rule)) return resolved;
const extracted = applyValueExtractConfig(resolved, rule);
const trimmed = extracted.trim();
return trimmed || undefined;
}
/** 解析条件目标值(支持 {变量名} */
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();
} }
/** 单条显示条件是否满足 */ /** 单条显示条件是否满足 */
@@ -692,6 +941,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':
@@ -702,12 +958,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': {
if (!compareTarget || isEmpty) return false;
return raw!.includes(compareTarget);
}
case 'not_contains': {
if (!compareTarget) return true;
if (isEmpty) return true;
return !raw!.includes(compareTarget);
} }
default: default:
return true; return true;
@@ -749,6 +1013,138 @@ export function resolveContent(
}); });
} }
/** 清理为安全的文件名(不含扩展名) */
export function sanitizeFileName(name: string, fallback = 'label'): string {
const cleaned = name
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/[\u0000-\u001f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/g, '')
.slice(0, 120);
return cleaned || fallback;
}
/**
* 解析批量导出图片的文件名模板。
* - {#SEQ}导出顺序1-based{#SEQ:4} 可补零
* - {#INDEX}数据行序号1-based{#INDEX:4} 可补零
* - {变量名}:当前行数据或 variableDefaults
*/
export function resolveExportImageFileName(
pattern: string | undefined,
label: PrintableLabel,
seq: number,
padWidth: number,
variableDefaults?: Record<string, string> | null
): string {
if (!pattern?.trim()) {
return String(seq).padStart(padWidth, '0');
}
const normalizedPattern = pattern.replace(/\r?\n/g, '').trim();
if (!normalizedPattern) {
return String(seq).padStart(padWidth, '0');
}
const resolved = normalizedPattern.replace(/\{([^}]+)\}/g, (_, key: string) => {
const special = key.match(/^(#SEQ|#INDEX)(?::(\d+))?$/);
if (special) {
const width = special[2] ? parseInt(special[2], 10) : 0;
const raw = special[1] === '#SEQ' ? String(seq) : String(label.sourceIndex + 1);
return width > 0 ? raw.padStart(width, '0') : raw;
}
if (key === '#SEQ') return String(seq);
if (key === '#INDEX') return String(label.sourceIndex + 1);
let val: string | undefined;
if (label.row && label.row[key] !== undefined && String(label.row[key]).trim() !== '') {
val = String(label.row[key]);
} else if (
variableDefaults &&
variableDefaults[key] !== undefined &&
String(variableDefaults[key]).trim() !== ''
) {
val = String(variableDefaults[key]);
}
return val ?? '';
});
return sanitizeFileName(resolved);
}
/** 解析变量过滤规则(仅 variable 目标) */
export function resolveVariableFilterRules(rules?: VisibilityRule[]): VisibilityRule[] {
return (rules ?? [])
.map((rule) => ({
target: rule.target ?? 'variable',
variable: (rule.variable ?? '').trim(),
operator: rule.operator,
value: rule.value,
enabled: rule.enabled,
}))
.filter((rule) => rule.target === 'variable' && rule.variable);
}
/** 数据行是否满足全部变量过滤条件 */
export function rowMatchesVariableFilterRules(
row: RecordRow,
rowIndex: number,
rules: VisibilityRule[],
variableDefaults?: Record<string, string> | null
): boolean {
const activeRules = getActiveVisibilityRules(rules);
if (activeRules.length === 0) return true;
return activeRules.every((rule) =>
evaluateVisibilityRule(rule, row, variableDefaults, 'output', undefined, rowIndex)
);
}
export function isExportImageFilterConditional(
template: Pick<LabelTemplate, 'exportImageFilterMode'>
): boolean {
return template.exportImageFilterMode === 'conditional';
}
export function resolveExportImageFilterRules(
template: Pick<LabelTemplate, 'exportImageFilterRules'>
): VisibilityRule[] {
return resolveVariableFilterRules(template.exportImageFilterRules);
}
/** 批量导出图片时,当前数据行是否应导出 */
export function shouldExportLabelImage(
template: Pick<LabelTemplate, 'exportImageFilterMode' | 'exportImageFilterRules'>,
label: PrintableLabel,
variableDefaults?: Record<string, string> | null
): boolean {
if (!isExportImageFilterConditional(template)) return true;
const rules = resolveExportImageFilterRules(template);
return rowMatchesVariableFilterRules(label.row, label.sourceIndex, rules, variableDefaults);
}
export function filterLabelsForImageExport(
printablePages: (PrintableLabel | null)[][],
template: Pick<LabelTemplate, 'exportImageFilterMode' | 'exportImageFilterRules'>,
variableDefaults?: Record<string, string> | null
): PrintableLabel[] {
return flattenPrintableLabelsForExport(printablePages).filter((label) =>
shouldExportLabelImage(template, label, variableDefaults)
);
}
function flattenPrintableLabelsForExport(
printablePages: (PrintableLabel | null)[][]
): PrintableLabel[] {
const labels: PrintableLabel[] = [];
for (const page of printablePages) {
for (const item of page) {
if (item) labels.push(item);
}
}
return labels;
}
/** 格式化单个数值用于文本显示 */ /** 格式化单个数值用于文本显示 */
export function formatDisplayValue( export function formatDisplayValue(
value: string, value: string,
@@ -836,41 +1232,65 @@ export function applyTextSplitSegment(
} }
/** 解析元素有效的文本提取方式(兼容旧版仅配置分隔符) */ /** 解析元素有效的文本提取方式(兼容旧版仅配置分隔符) */
export function getTextExtractMode(elem: Pick< export function getTextExtractMode(
TemplateElement, config: Pick<ValueExtractConfig, 'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex'>
'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex' ): TextExtractMode {
>): TextExtractMode { if (config.textExtractMode !== undefined) return config.textExtractMode;
if (elem.textExtractMode !== undefined) return elem.textExtractMode; const delimiter = config.textSplitDelimiter?.trim();
const delimiter = elem.textSplitDelimiter?.trim(); const splitIndex = config.textSplitIndex ?? 0;
const splitIndex = elem.textSplitIndex ?? 0;
if (delimiter && splitIndex >= 1) return 'split'; if (delimiter && splitIndex >= 1) return 'split';
return 'none'; return 'none';
} }
/** 值提取配置是否生效 */
export function valueExtractConfigHasExtract(config: ValueExtractConfig): boolean {
const mode = getTextExtractMode(config);
if (mode === 'split') {
const delimiter = config.textSplitDelimiter?.trim();
const index = config.textSplitIndex ?? 0;
return !!(delimiter && index >= 1);
}
if (mode === 'prefix' || mode === 'suffix') {
return (config.textExtractLength ?? 0) >= 1;
}
return false;
}
/** 值提取配置的可读摘要 */
export function formatValueExtractSummary(config: ValueExtractConfig): string | null {
if (!valueExtractConfigHasExtract(config)) return null;
const mode = getTextExtractMode(config);
let base: string;
if (mode === 'split') {
const delimiter = config.textSplitDelimiter?.trim() ?? '';
const index = config.textSplitIndex ?? 1;
base = `按「${delimiter}」第 ${index}`;
} else if (mode === 'prefix') {
base = `${config.textExtractLength ?? 1}`;
} else {
base = `${config.textExtractLength ?? 1}`;
}
return config.textExtractInverse ? `反选 ${base}` : `提取 ${base}`;
}
/** 按配置提取变量值片段 */ /** 按配置提取变量值片段 */
export function applyTextExtract( export function applyTextExtract(value: string, config: ValueExtractConfig): string {
value: string,
elem: Pick<
TemplateElement,
'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex' | 'textExtractLength'
>
): string {
const trimmed = value.trim(); const trimmed = value.trim();
const mode = getTextExtractMode(elem); const mode = getTextExtractMode(config);
switch (mode) { switch (mode) {
case 'split': { case 'split': {
const delimiter = elem.textSplitDelimiter?.trim(); const delimiter = config.textSplitDelimiter?.trim();
const index = elem.textSplitIndex ?? 0; const index = config.textSplitIndex ?? 0;
if (!delimiter || index < 1) return trimmed; if (!delimiter || index < 1) return trimmed;
return applyTextSplitSegment(trimmed, delimiter, index); return applyTextSplitSegment(trimmed, delimiter, index);
} }
case 'prefix': { case 'prefix': {
const n = elem.textExtractLength ?? 0; const n = config.textExtractLength ?? 0;
if (n < 1) return trimmed; if (n < 1) return trimmed;
return trimmed.slice(0, n); return trimmed.slice(0, n);
} }
case 'suffix': { case 'suffix': {
const n = elem.textExtractLength ?? 0; const n = config.textExtractLength ?? 0;
if (n < 1) return trimmed; if (n < 1) return trimmed;
return trimmed.slice(-n); return trimmed.slice(-n);
} }
@@ -879,6 +1299,44 @@ export function applyTextExtract(
} }
} }
/** 按配置应用值提取(含反选) */
export function applyValueExtractConfig(value: string, config: ValueExtractConfig): string {
if (!valueExtractConfigHasExtract(config)) return value;
if (config.textExtractInverse) {
return applyTextExtractInverse(value, { type: 'text', ...config });
}
return applyTextExtract(value, config);
}
function removeFirstOccurrence(value: string, needle: string): string {
if (!needle) return value;
const index = value.indexOf(needle);
if (index === -1) return value;
return value.slice(0, index) + value.slice(index + needle.length);
}
/** 反选:从原值中去掉「显示前缀 + 提取段 + 显示后缀」 */
export function applyTextExtractInverse(
value: string,
elem: Pick<
ValueExtractConfig,
| 'textExtractMode'
| 'textSplitDelimiter'
| 'textSplitIndex'
| 'textExtractLength'
| 'textDisplayPrefix'
| 'textDisplaySuffix'
> & { type?: TemplateElement['type'] }
): string {
const full = value.trim();
const extracted = applyTextExtract(full, elem);
const needle =
elem.type === 'text' || elem.textDisplayPrefix || elem.textDisplaySuffix
? applyTextDisplayAffix(extracted, elem)
: extracted;
return removeFirstOccurrence(full, needle);
}
/** 元素是否启用了变量值提取(文本/条码/二维码/图片内容均有效) */ /** 元素是否启用了变量值提取(文本/条码/二维码/图片内容均有效) */
export function elementHasValueExtract( export function elementHasValueExtract(
elem: Pick< elem: Pick<
@@ -886,16 +1344,7 @@ export function elementHasValueExtract(
'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex' | 'textExtractLength' 'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex' | 'textExtractLength'
> >
): boolean { ): boolean {
const mode = getTextExtractMode(elem); return valueExtractConfigHasExtract(elem);
if (mode === 'split') {
const delimiter = elem.textSplitDelimiter?.trim();
const index = elem.textSplitIndex ?? 0;
return !!(delimiter && index >= 1);
}
if (mode === 'prefix' || mode === 'suffix') {
return (elem.textExtractLength ?? 0) >= 1;
}
return false;
} }
/** @deprecated 使用 elementHasValueExtract */ /** @deprecated 使用 elementHasValueExtract */
@@ -962,6 +1411,11 @@ export function getVariableRawValue(
return undefined; return undefined;
} }
/** 图层是否在画布与输出中显示 */
export function isElementLayerVisible(elem: TemplateElement): boolean {
return elem.hidden !== true;
}
/** 是否应渲染元素(显示控制为 conditional 时按关联变量判断;设计模式使用变量默认值) */ /** 是否应渲染元素(显示控制为 conditional 时按关联变量判断;设计模式使用变量默认值) */
export function shouldRenderElement( export function shouldRenderElement(
elem: TemplateElement, elem: TemplateElement,
@@ -970,11 +1424,15 @@ export function shouldRenderElement(
defaults?: Record<string, string> | null, defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'output' mode: ContentResolveMode = 'output'
): boolean { ): boolean {
if (!isElementLayerVisible(elem)) return false;
if (!isConditionalVisibility(elem)) return true; if (!isConditionalVisibility(elem)) return true;
const rules = resolveVisibilityRules(elem); const rules = resolveVisibilityRules(elem);
if (rules.length > 0) { if (rules.length > 0) {
return rules.every((rule) => const activeRules = getActiveVisibilityRules(rules);
if (activeRules.length === 0) return true;
return activeRules.every((rule) =>
evaluateVisibilityRule(rule, row, defaults, mode, elem, rowIndex) evaluateVisibilityRule(rule, row, defaults, mode, elem, rowIndex)
); );
} }
@@ -1002,7 +1460,9 @@ export function resolveElementCoreValue(
let resolved = resolveContent(elem.content, row, rowIndex, defaults, { mode, formatSubst }); let resolved = resolveContent(elem.content, row, rowIndex, defaults, { mode, formatSubst });
if (elementHasValueExtract(elem)) { if (elementHasValueExtract(elem)) {
resolved = applyTextExtract(resolved, elem); resolved = elem.textExtractInverse
? applyTextExtractInverse(resolved, elem)
: applyTextExtract(resolved, elem);
} }
return resolved; return resolved;
@@ -1018,6 +1478,7 @@ export function resolveElementValue(
): string { ): string {
const core = resolveElementCoreValue(elem, row, rowIndex, defaults, mode); const core = resolveElementCoreValue(elem, row, rowIndex, defaults, mode);
if (elem.type !== 'text') return core; if (elem.type !== 'text') return core;
if (elem.textExtractInverse && elementHasValueExtract(elem)) return core;
return applyTextDisplayAffix(core, elem); return applyTextDisplayAffix(core, elem);
} }