导出
This commit is contained in:
@@ -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
100
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
250
src/App.tsx
250
src/App.tsx
@@ -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';
|
||||||
@@ -21,8 +22,12 @@ 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 { 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,
|
||||||
@@ -142,6 +147,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 +155,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[]>([]);
|
||||||
@@ -533,7 +544,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 +585,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]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -688,7 +713,8 @@ export default function App() {
|
|||||||
appliedMappedRows,
|
appliedMappedRows,
|
||||||
paper,
|
paper,
|
||||||
workingTemplate,
|
workingTemplate,
|
||||||
appliedExportData.exportRange
|
appliedExportData.exportRange,
|
||||||
|
workingTemplate.variableDefaults ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
setPreviewSnapshot({
|
setPreviewSnapshot({
|
||||||
@@ -719,6 +745,49 @@ export default function App() {
|
|||||||
: { imageFormat: 'jpeg' as const, jsPdfFormat: 'JPEG' as const, scale: 10 };
|
: { imageFormat: 'jpeg' as const, jsPdfFormat: 'JPEG' as const, scale: 10 };
|
||||||
}, [workingTemplate.elements]);
|
}, [workingTemplate.elements]);
|
||||||
|
|
||||||
|
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 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: 0.9,
|
||||||
|
...resolveReferenceBackgroundForOutput(activeTemplate!),
|
||||||
|
}),
|
||||||
|
[activeTemplate, pdfExportImage]
|
||||||
|
);
|
||||||
|
|
||||||
const exportPdf = async () => {
|
const exportPdf = async () => {
|
||||||
if (!activeTemplate) return;
|
if (!activeTemplate) return;
|
||||||
if (!appliedExportData) {
|
if (!appliedExportData) {
|
||||||
@@ -754,19 +823,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 +841,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 +928,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);
|
||||||
@@ -843,14 +952,24 @@ export default function App() {
|
|||||||
? '排版已变更,请重新绘制预览'
|
? '排版已变更,请重新绘制预览'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const isDarkWorkspace =
|
||||||
|
workflowStep === 'template-design' || workflowStep === 'print-view';
|
||||||
|
const isMobileDarkShell = isMobile && isDarkWorkspace;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle('app-dark-shell', isMobileDarkShell);
|
||||||
|
return () => {
|
||||||
|
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 +1203,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>
|
||||||
@@ -1335,6 +1455,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 +1465,15 @@ export default function App() {
|
|||||||
appliedLayoutResult.selectedCount === 0 ||
|
appliedLayoutResult.selectedCount === 0 ||
|
||||||
previewLayoutStale ||
|
previewLayoutStale ||
|
||||||
printing ||
|
printing ||
|
||||||
pdfGenerating
|
pdfGenerating ||
|
||||||
|
imagesZipGenerating
|
||||||
}
|
}
|
||||||
printDisabledReason={printBlockedReason}
|
printDisabledReason={printBlockedReason}
|
||||||
|
onExportImages={exportLabelsImagesZip}
|
||||||
|
imagesExporting={imagesZipGenerating}
|
||||||
|
onChangeExportImageFileNamePattern={handleExportImageFileNamePatternChange}
|
||||||
|
onChangeExportImageFilterMode={handleExportImageFilterModeChange}
|
||||||
|
onChangeExportImageFilterRules={handleExportImageFilterRulesChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1382,9 +1509,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'
|
||||||
}`}
|
}`}
|
||||||
@@ -1442,12 +1572,21 @@ 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}
|
printDisabledReason={printBlockedReason}
|
||||||
/>
|
/>
|
||||||
@@ -1598,11 +1737,13 @@ 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">
|
||||||
|
{pdfGenerating ? (
|
||||||
|
<>
|
||||||
<p className="text-sm font-bold text-gray-900">
|
<p className="text-sm font-bold text-gray-900">
|
||||||
{pdfProgress.phase === 'save' ? '正在保存 PDF 文件…' : '正在生成 PDF'}
|
{pdfProgress.phase === 'save' ? '正在保存 PDF 文件…' : '正在生成 PDF'}
|
||||||
</p>
|
</p>
|
||||||
@@ -1611,13 +1752,40 @@ export default function App() {
|
|||||||
? '即将完成,请稍候'
|
? '即将完成,请稍候'
|
||||||
: `第 ${pdfProgress.page} / ${pdfProgress.totalPages} 页 · ${pdfProgress.done} / ${pdfProgress.total} 张标签`}
|
: `第 ${pdfProgress.page} / ${pdfProgress.totalPages} 页 · ${pdfProgress.done} / ${pdfProgress.total} 张标签`}
|
||||||
</p>
|
</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.total
|
||||||
? `${(pdfProgress.done / pdfProgress.total) * 100}%`
|
? `${(pdfProgress.done / pdfProgress.total) * 100}%`
|
||||||
|
: '0%'
|
||||||
|
: imagesZipProgress.phase === 'zip'
|
||||||
|
? `${imagesZipProgress.zipPercent ?? 0}%`
|
||||||
|
: imagesZipProgress.phase === 'save'
|
||||||
|
? '100%'
|
||||||
|
: imagesZipProgress.total
|
||||||
|
? `${(imagesZipProgress.done / imagesZipProgress.total) * 100}%`
|
||||||
: '0%',
|
: '0%',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -28,6 +31,9 @@ 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,
|
||||||
}) => {
|
}) => {
|
||||||
const [dataUrl, setDataUrl] = useState<string>('');
|
const [dataUrl, setDataUrl] = useState<string>('');
|
||||||
@@ -54,6 +60,9 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
|||||||
variableDefaults,
|
variableDefaults,
|
||||||
mode,
|
mode,
|
||||||
scale: renderScale,
|
scale: renderScale,
|
||||||
|
referenceBackground,
|
||||||
|
referenceBackgroundOpacity,
|
||||||
|
referenceBackgroundFit,
|
||||||
})
|
})
|
||||||
.then((url) => {
|
.then((url) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -88,6 +97,9 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
|||||||
transparentBackground,
|
transparentBackground,
|
||||||
mode,
|
mode,
|
||||||
renderScale,
|
renderScale,
|
||||||
|
referenceBackground,
|
||||||
|
referenceBackgroundOpacity,
|
||||||
|
referenceBackgroundFit,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (initialLoading && !dataUrl) {
|
if (initialLoading && !dataUrl) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Filter } from 'lucide-react';
|
|
||||||
import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow } from '../types';
|
import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow } from '../types';
|
||||||
import { buildPrintablePages } from '../utils';
|
import { buildPrintablePages, extractTemplateVariables } 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';
|
||||||
@@ -25,7 +25,9 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
|
|||||||
disabled = false,
|
disabled = 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 rangeStats = buildPrintablePages(rows, paper, template, exportRange, variableDefaults);
|
||||||
|
|
||||||
const patchRange = (patch: Partial<ExportRangeConfig>) => {
|
const patchRange = (patch: Partial<ExportRangeConfig>) => {
|
||||||
onChangeExportRange({ ...exportRange, ...patch });
|
onChangeExportRange({ ...exportRange, ...patch });
|
||||||
@@ -36,7 +38,6 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
|
|||||||
{!isMobile && <div className="ps-section-title">数据范围</div>}
|
{!isMobile && <div className="ps-section-title">数据范围</div>}
|
||||||
<div>
|
<div>
|
||||||
{isMobile && <label className="mobile-field-label">范围</label>}
|
{isMobile && <label className="mobile-field-label">范围</label>}
|
||||||
{/* {!isMobile && <label className="ps-field-label">范围</label>} */}
|
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
value={exportRange.mode}
|
value={exportRange.mode}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -47,6 +48,7 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
|
|||||||
<option value="odd">仅限奇数序号的行</option>
|
<option value="odd">仅限奇数序号的行</option>
|
||||||
<option value="even">仅限偶数序号的行</option>
|
<option value="even">仅限偶数序号的行</option>
|
||||||
<option value="custom">自定义行序号</option>
|
<option value="custom">自定义行序号</option>
|
||||||
|
<option value="conditional">按条件过滤</option>
|
||||||
</FieldSelect>
|
</FieldSelect>
|
||||||
</div>
|
</div>
|
||||||
{exportRange.mode === 'custom' && (
|
{exportRange.mode === 'custom' && (
|
||||||
@@ -66,6 +68,23 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{exportRange.mode === 'conditional' && (
|
||||||
|
<VariableFilterRulesEditor
|
||||||
|
rules={exportRange.filterRules ?? []}
|
||||||
|
templateVariables={templateVariables}
|
||||||
|
disabled={disabled}
|
||||||
|
emptyHint="尚未添加过滤条件,将使用全部数据行"
|
||||||
|
description="全部条件满足的数据行才纳入排版与导出;按数据行与变量默认值判断。"
|
||||||
|
addTitle="添加数据范围条件"
|
||||||
|
editTitle="编辑数据范围条件"
|
||||||
|
onChangeRules={(filterRules) => patchRange({ filterRules })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<p className={isMobile ? 'text-[10px] text-slate-500' : 'text-[10px] text-[#888]'}>
|
||||||
|
当前范围:{rangeStats.selectedCount} / {rows.length} 行
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
66
src/components/ExportImageFilterFields.tsx
Normal file
66
src/components/ExportImageFilterFields.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
@@ -35,6 +35,14 @@ 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;
|
printDisabledReason?: string | null;
|
||||||
/** 移动端精简:仅数据、映射、数据范围 */
|
/** 移动端精简:仅数据、映射、数据范围 */
|
||||||
@@ -67,6 +75,14 @@ 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,
|
printDisabledReason = null,
|
||||||
compact = false,
|
compact = false,
|
||||||
@@ -307,6 +323,15 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
|
|||||||
disabledReason={printDisabledReason}
|
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>
|
||||||
|
|||||||
@@ -1653,20 +1653,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 ${
|
||||||
|
template.previewReferenceBackground ? '' : 'bg-white'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* <div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]">
|
{template.previewReferenceBackground && (
|
||||||
{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,6 +1681,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
rowData={null}
|
rowData={null}
|
||||||
rowIndex={0}
|
rowIndex={0}
|
||||||
showBorder={false}
|
showBorder={false}
|
||||||
|
transparentBackground={!!template.previewReferenceBackground}
|
||||||
variableDefaults={previewDefaults}
|
variableDefaults={previewDefaults}
|
||||||
renderScale={canvasRenderScale}
|
renderScale={canvasRenderScale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -66,12 +68,18 @@ 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;
|
printDisabledReason?: string | null;
|
||||||
|
imagesExporting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
||||||
@@ -104,12 +112,18 @@ 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,
|
printDisabledReason = null,
|
||||||
|
onExportImages,
|
||||||
|
imagesExporting = false,
|
||||||
|
onChangeExportImageFileNamePattern,
|
||||||
|
onChangeExportImageFilterMode,
|
||||||
|
onChangeExportImageFilterRules,
|
||||||
}) => {
|
}) => {
|
||||||
const [mobileModule, setMobileModule] = useState<MobileExportModule>('data');
|
const [mobileModule, setMobileModule] = useState<MobileExportModule>('data');
|
||||||
|
|
||||||
@@ -128,7 +142,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">
|
||||||
@@ -281,6 +295,15 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
|
|||||||
disabledReason={printDisabledReason}
|
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type PrintableLabel,
|
type PrintableLabel,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { CanvasLabelImage } from './CanvasLabelImage';
|
import { CanvasLabelImage } from './CanvasLabelImage';
|
||||||
|
import { resolveReferenceBackgroundForOutput } 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';
|
||||||
@@ -130,7 +131,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]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -588,6 +596,15 @@ 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 pageCutLines = useMemo(
|
const pageCutLines = useMemo(
|
||||||
() =>
|
() =>
|
||||||
collectGridCutLinePositions(
|
collectGridCutLinePositions(
|
||||||
@@ -705,16 +722,9 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
className={
|
className={
|
||||||
isMobileVariant
|
isMobileVariant
|
||||||
? 'flex flex-col items-center space-y-4 pb-4'
|
? 'flex flex-col items-center space-y-4 pb-4'
|
||||||
: 'grid pb-8 justify-items-center content-start w-full'
|
: 'flex flex-wrap justify-center content-start w-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
|
||||||
@@ -828,6 +838,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
|
|||||||
rowIndex={item.sourceIndex}
|
rowIndex={item.sourceIndex}
|
||||||
showBorder={false}
|
showBorder={false}
|
||||||
mode="output"
|
mode="output"
|
||||||
|
{...referenceBackgroundRender}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -871,7 +882,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
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -17,6 +19,15 @@ interface PrintModuleProps {
|
|||||||
disabledReason?: string | null;
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +36,15 @@ export const PrintModule: React.FC<PrintModuleProps> = ({
|
|||||||
disabledReason,
|
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 +93,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 +122,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 +141,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"
|
||||||
>
|
>
|
||||||
添加
|
添加
|
||||||
@@ -140,9 +170,9 @@ export const PrintModule: React.FC<PrintModuleProps> = ({
|
|||||||
<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 +184,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
isTemplateVariableInUse,
|
isTemplateVariableInUse,
|
||||||
removeUnusedTemplateVariable,
|
removeUnusedTemplateVariable,
|
||||||
resolveVisibilityRules,
|
resolveVisibilityRules,
|
||||||
|
toggleVisibilityRuleEnabled,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
|
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
|
||||||
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
|
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
|
||||||
@@ -821,6 +822,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 +851,127 @@ 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',
|
||||||
|
});
|
||||||
|
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.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,
|
||||||
|
includeReferenceBackgroundInOutput: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
清除参考背景
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const renderPropertiesPanelBody = () => (
|
const renderPropertiesPanelBody = () => (
|
||||||
<>
|
<>
|
||||||
|
<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">
|
||||||
|
|||||||
109
src/components/VariableFilterRulesEditor.tsx
Normal file
109
src/components/VariableFilterRulesEditor.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import React, { useMemo, 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 ruleDialogVariableOptions = useMemo(() => {
|
||||||
|
if (!ruleDialog) return templateVariables;
|
||||||
|
if (ruleDialog.mode === 'add') {
|
||||||
|
return templateVariables.filter((name) => !normalizedRules.some((rule) => rule.variable === name));
|
||||||
|
}
|
||||||
|
const usedByOthers = normalizedRules
|
||||||
|
.filter((_, index) => index !== ruleDialog.index)
|
||||||
|
.map((rule) => rule.variable);
|
||||||
|
return templateVariables.filter((name) => !usedByOthers.includes(name));
|
||||||
|
}, [ruleDialog, normalizedRules, templateVariables]);
|
||||||
|
|
||||||
|
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={ruleDialogVariableOptions}
|
||||||
|
variableOnly
|
||||||
|
addTitle={addTitle}
|
||||||
|
editTitle={editTitle}
|
||||||
|
onClose={() => setRuleDialog(null)}
|
||||||
|
onConfirm={handleConfirmRule}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,6 +16,9 @@ interface VisibilityRuleDialogProps {
|
|||||||
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 +28,9 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
|
|||||||
mode,
|
mode,
|
||||||
initialRule,
|
initialRule,
|
||||||
variableOptions,
|
variableOptions,
|
||||||
|
variableOnly = false,
|
||||||
|
addTitle = '添加显示条件',
|
||||||
|
editTitle = '编辑显示条件',
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -42,10 +48,10 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
|
|||||||
} 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;
|
||||||
@@ -62,9 +68,8 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
|
|||||||
if (!open) return null;
|
if (!open) 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) => {
|
||||||
@@ -109,6 +114,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">
|
||||||
|
|||||||
@@ -20,17 +20,21 @@ 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');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{!variableOnly && (
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">条件对象</label>
|
<label className="ps-field-label">条件对象</label>
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
@@ -51,6 +55,7 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
|
|||||||
))}
|
))}
|
||||||
</FieldSelect>
|
</FieldSelect>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{target === 'variable' ? (
|
{target === 'variable' ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">变量</label>
|
<label className="ps-field-label">变量</label>
|
||||||
@@ -68,14 +73,18 @@ 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>
|
||||||
) : (
|
) : (
|
||||||
|
!variableOnly && (
|
||||||
<p className="text-[9px] text-[#666] leading-relaxed">
|
<p className="text-[9px] text-[#666] leading-relaxed">
|
||||||
使用本元素绑定内容解析并提取后的显示文本(含数值格式等;不含前缀/后缀字符)
|
使用本元素绑定内容解析并提取后的显示文本(含数值格式等;不含前缀/后缀字符)
|
||||||
</p>
|
</p>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="ps-field-label">条件</label>
|
<label className="ps-field-label">条件</label>
|
||||||
|
|||||||
@@ -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,8 +38,22 @@ export const VisibilityRuleSummary: React.FC<VisibilityRuleSummaryProps> = ({
|
|||||||
rule,
|
rule,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}) => (
|
onToggleEnabled,
|
||||||
<div className="visibility-rule-summary">
|
}) => {
|
||||||
|
const enabled = isVisibilityRuleEnabled(rule);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`visibility-rule-summary${enabled ? '' : ' is-disabled'}`}>
|
||||||
|
{onToggleEnabled && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={onToggleEnabled}
|
||||||
|
className="ps-checkbox shrink-0"
|
||||||
|
title={enabled ? '禁用条件' : '启用条件'}
|
||||||
|
aria-label={enabled ? '禁用条件' : '启用条件'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
@@ -64,4 +79,5 @@ export const VisibilityRuleSummary: React.FC<VisibilityRuleSummaryProps> = ({
|
|||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
146
src/labelImagesExport.ts
Normal file
146
src/labelImagesExport.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -568,6 +568,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';
|
||||||
/** 渲染倍率,默认 16;PDF 导出可用较低倍率以减轻内存与耗时 */
|
/** 渲染倍率,默认 16;PDF 导出可用较低倍率以减轻内存与耗时 */
|
||||||
scale?: number;
|
scale?: number;
|
||||||
/** 输出格式,默认 png */
|
/** 输出格式,默认 png */
|
||||||
@@ -577,6 +581,47 @@ export interface RenderLabelOptions {
|
|||||||
clipToCanvas?: boolean;
|
clipToCanvas?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReferenceBackgroundRenderOptions = Pick<
|
||||||
|
RenderLabelOptions,
|
||||||
|
'referenceBackground' | 'referenceBackgroundOpacity' | 'referenceBackgroundFit'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ReferenceBackgroundTemplate = Pick<
|
||||||
|
LabelTemplate,
|
||||||
|
'previewReferenceBackground' | 'previewReferenceBackgroundOpacity' | 'previewReferenceBackgroundFit'
|
||||||
|
>;
|
||||||
|
|
||||||
|
function buildReferenceBackgroundRenderOptions(
|
||||||
|
template: ReferenceBackgroundTemplate
|
||||||
|
): ReferenceBackgroundRenderOptions {
|
||||||
|
if (!template.previewReferenceBackground) {
|
||||||
|
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 {};
|
||||||
|
}
|
||||||
|
return buildReferenceBackgroundRenderOptions(template);
|
||||||
|
}
|
||||||
|
|
||||||
/** 离屏渲染标签为 PNG data URL */
|
/** 离屏渲染标签为 PNG data URL */
|
||||||
export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise<string> {
|
export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise<string> {
|
||||||
const {
|
const {
|
||||||
@@ -589,6 +634,9 @@ 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,
|
||||||
@@ -618,6 +666,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();
|
||||||
|
|||||||
34
src/types.ts
34
src/types.ts
@@ -11,7 +11,15 @@ export const TEXT_EXTRACT_MODE_LABELS: Record<TextExtractMode, string> = {
|
|||||||
suffix: '后 N 位',
|
suffix: '后 N 位',
|
||||||
};
|
};
|
||||||
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';
|
||||||
|
|
||||||
@@ -20,8 +28,10 @@ export interface VisibilityRule {
|
|||||||
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 +46,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';
|
||||||
@@ -210,12 +222,14 @@ export interface CutLineConfig {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExportRangeMode = 'all' | 'odd' | 'even' | 'custom';
|
export type ExportRangeMode = 'all' | 'odd' | 'even' | 'custom' | 'conditional';
|
||||||
|
|
||||||
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;
|
||||||
|
/** mode=conditional 时,全部满足才纳入排版/导出范围 */
|
||||||
|
filterRules?: VisibilityRule[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LabelTemplate {
|
export interface LabelTemplate {
|
||||||
@@ -232,6 +246,20 @@ export interface LabelTemplate {
|
|||||||
paper?: PaperConfig;
|
paper?: PaperConfig;
|
||||||
/** PDF 裁切参考线配置 */
|
/** PDF 裁切参考线配置 */
|
||||||
cutLine?: CutLineConfig;
|
cutLine?: CutLineConfig;
|
||||||
|
/** 设计画布预览参考背景(URL / data URI) */
|
||||||
|
previewReferenceBackground?: string;
|
||||||
|
/** 预览参考背景不透明度 0–100,默认 100 */
|
||||||
|
previewReferenceBackgroundOpacity?: number;
|
||||||
|
/** 预览参考背景适应方式,默认 cover */
|
||||||
|
previewReferenceBackgroundFit?: 'contain' | 'cover' | 'fill';
|
||||||
|
/** 生成 PDF / 打印 / 排版预览输出时是否绘制参考背景,默认 false */
|
||||||
|
includeReferenceBackgroundInOutput?: 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';
|
||||||
|
|||||||
196
src/utils.ts
196
src/utils.ts
@@ -155,7 +155,9 @@ export function applyRowVariableMapping(
|
|||||||
export const DEFAULT_EXPORT_RANGE: ExportRangeConfig = { mode: 'all' };
|
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 ?? '') &&
|
||||||
|
JSON.stringify(a.filterRules ?? []) === JSON.stringify(b.filterRules ?? []);
|
||||||
|
|
||||||
export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = {
|
export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -246,7 +248,12 @@ export interface PrintableLabel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 按导出范围筛选 0-based 行索引(保持升序) */
|
/** 按导出范围筛选 0-based 行索引(保持升序) */
|
||||||
export function getExportRangeIndices(total: number, range: ExportRangeConfig): number[] {
|
export function getExportRangeIndices(
|
||||||
|
rows: RecordRow[],
|
||||||
|
range: ExportRangeConfig,
|
||||||
|
variableDefaults?: Record<string, string> | null
|
||||||
|
): number[] {
|
||||||
|
const total = rows.length;
|
||||||
if (total <= 0) return [];
|
if (total <= 0) return [];
|
||||||
switch (range.mode) {
|
switch (range.mode) {
|
||||||
case 'odd':
|
case 'odd':
|
||||||
@@ -255,6 +262,13 @@ export function getExportRangeIndices(total: number, range: ExportRangeConfig):
|
|||||||
return Array.from({ length: total }, (_, i) => i).filter((i) => (i + 1) % 2 === 0);
|
return Array.from({ length: total }, (_, i) => i).filter((i) => (i + 1) % 2 === 0);
|
||||||
case 'custom':
|
case 'custom':
|
||||||
return parseCustomExportRange(range.custom ?? '', total);
|
return parseCustomExportRange(range.custom ?? '', total);
|
||||||
|
case 'conditional': {
|
||||||
|
const rules = resolveVariableFilterRules(range.filterRules);
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
case 'all':
|
case 'all':
|
||||||
default:
|
default:
|
||||||
return Array.from({ length: total }, (_, i) => i);
|
return Array.from({ length: total }, (_, i) => i);
|
||||||
@@ -287,11 +301,12 @@ export function buildPrintablePages(
|
|||||||
rows: RecordRow[],
|
rows: RecordRow[],
|
||||||
paper: PaperConfig,
|
paper: PaperConfig,
|
||||||
labelSize: { width: number; height: number },
|
labelSize: { width: number; height: number },
|
||||||
exportRange: ExportRangeConfig
|
exportRange: ExportRangeConfig,
|
||||||
|
variableDefaults?: Record<string, string> | null
|
||||||
) {
|
) {
|
||||||
const gridInfo = calculateGrid(paper, labelSize);
|
const gridInfo = calculateGrid(paper, labelSize);
|
||||||
const labelsPerPage = gridInfo.cols * gridInfo.rows;
|
const labelsPerPage = gridInfo.cols * gridInfo.rows;
|
||||||
const indices = getExportRangeIndices(rows.length, exportRange);
|
const indices = getExportRangeIndices(rows, exportRange, 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)[][] = [];
|
||||||
@@ -578,7 +593,26 @@ 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将显示条件格式化为可读说明 */
|
/** 将显示条件格式化为可读说明 */
|
||||||
@@ -598,6 +632,10 @@ export function formatVisibilityRuleDescription(rule: VisibilityRule): string {
|
|||||||
return `${subjectLabel} 等于 ${rule.value ?? '—'}`;
|
return `${subjectLabel} 等于 ${rule.value ?? '—'}`;
|
||||||
case 'neq':
|
case 'neq':
|
||||||
return `${subjectLabel} 不等于 ${rule.value ?? '—'}`;
|
return `${subjectLabel} 不等于 ${rule.value ?? '—'}`;
|
||||||
|
case 'contains':
|
||||||
|
return `${subjectLabel} 包含 ${rule.value ?? '—'}`;
|
||||||
|
case 'not_contains':
|
||||||
|
return `${subjectLabel} 不包含 ${rule.value ?? '—'}`;
|
||||||
default:
|
default:
|
||||||
return `${subjectLabel} ${VISIBILITY_OPERATOR_LABELS[rule.operator]}`;
|
return `${subjectLabel} ${VISIBILITY_OPERATOR_LABELS[rule.operator]}`;
|
||||||
}
|
}
|
||||||
@@ -616,6 +654,7 @@ 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,
|
||||||
}))
|
}))
|
||||||
.filter((r) => r.target === 'content' || r.variable);
|
.filter((r) => r.target === 'content' || r.variable);
|
||||||
}
|
}
|
||||||
@@ -709,6 +748,17 @@ export function evaluateVisibilityRule(
|
|||||||
if (isEmpty) return target !== '';
|
if (isEmpty) return target !== '';
|
||||||
return compareVariableToTarget(raw!, target, 'neq');
|
return compareVariableToTarget(raw!, target, 'neq');
|
||||||
}
|
}
|
||||||
|
case 'contains': {
|
||||||
|
const target = (rule.value ?? '').trim();
|
||||||
|
if (!target || isEmpty) return false;
|
||||||
|
return raw!.includes(target);
|
||||||
|
}
|
||||||
|
case 'not_contains': {
|
||||||
|
const target = (rule.value ?? '').trim();
|
||||||
|
if (!target) return true;
|
||||||
|
if (isEmpty) return true;
|
||||||
|
return !raw!.includes(target);
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -749,6 +799,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,
|
||||||
@@ -974,7 +1156,9 @@ export function shouldRenderElement(
|
|||||||
|
|
||||||
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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user