From 28c6a76ead9b65a0b5861d70f0838ef2a46f5c45 Mon Sep 17 00:00:00 2001
From: zhou zhou <zozhouo3o@gmail.com>
Date: 星期二, 14 四月 2026 10:58:10 +0800
Subject: [PATCH] #barcode

---
 rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-print-dialog.vue                                  |  694 +++++++
 rsf-server/src/main/java/com/vincent/rsf/server/manager/mapper/MatnrPrintTemplateMapper.java            |   11 
 rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/MatnrPrintTemplateController.java    |  101 +
 rsf-design/src/views/basic-info/wh-mat/matnrPrintTemplate.helpers.js                                    |  827 ++++++++
 rsf-server/src/main/resources/sql/20260413_matnr_print_template_menu.sql                                |  380 ++++
 rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-property-panel.vue                           |  859 +++++++++
 rsf-server/src/main/resources/sql/20260413_matnr_print_template.sql                                     |   19 
 rsf-design/src/api/wh-mat.js                                                                            |   45 
 rsf-design/src/views/basic-info/matnr-print-template/index.vue                                          |   87 
 rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/MatnrPrintTemplateServiceImpl.java |  376 ++++
 rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-canvas.vue                                   |  571 ++++++
 rsf-design/pnpm-lock.yaml                                                                               |   81 
 rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/LocItemController.java               |    2 
 rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-field-panel.vue                              |   88 
 rsf-server/src/main/java/com/vincent/rsf/server/system/controller/AuthController.java                   |    2 
 rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-toolbar.vue                                  |   86 
 rsf-design/src/locales/langs/en.json                                                                    |  167 +
 rsf-design/src/locales/langs/zh.json                                                                    |  167 +
 rsf-design/package.json                                                                                 |    1 
 rsf-design/src/router/adapters/backendMenuAdapter.js                                                    |    5 
 rsf-server/src/main/java/com/vincent/rsf/server/manager/service/MatnrPrintTemplateService.java          |   23 
 rsf-design/src/utils/backend-menu-title.js                                                              |    5 
 rsf-server/src/main/java/com/vincent/rsf/server/manager/entity/MatnrPrintTemplate.java                  |   82 
 rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-print-template-workspace.vue                      |  749 ++++++++
 rsf-design/src/views/basic-info/wh-mat/index.vue                                                        |  140 
 25 files changed, 5,440 insertions(+), 128 deletions(-)

diff --git a/rsf-design/package.json b/rsf-design/package.json
index 8e22c1e..3473115 100644
--- a/rsf-design/package.json
+++ b/rsf-design/package.json
@@ -42,6 +42,7 @@
     "element-plus": "^2.11.2",
     "file-saver": "^2.0.5",
     "highlight.js": "^11.10.0",
+    "jsbarcode": "^3.12.3",
     "mitt": "^3.0.1",
     "nprogress": "^0.2.0",
     "ohash": "^2.0.11",
diff --git a/rsf-design/pnpm-lock.yaml b/rsf-design/pnpm-lock.yaml
index 6c53174..c146d17 100644
--- a/rsf-design/pnpm-lock.yaml
+++ b/rsf-design/pnpm-lock.yaml
@@ -77,6 +77,9 @@
       highlight.js:
         specifier: ^11.10.0
         version: 11.11.1
+      jsbarcode:
+        specifier: ^3.12.3
+        version: 3.12.3
       mitt:
         specifier: ^3.0.1
         version: 3.0.1
@@ -114,8 +117,8 @@
         specifier: ^3.0.20
         version: 3.0.23(core-js@3.45.1)
       xlsx:
-        specifier: ^0.18.5
-        version: 0.18.5
+        specifier: https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz
+        version: https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz
     devDependencies:
       '@eslint/js':
         specifier: ^9.9.1
@@ -1278,10 +1281,6 @@
     engines: {node: '>=0.4.0'}
     hasBin: true
 
-  adler-32@1.3.1:
-    resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
-    engines: {node: '>=0.8'}
-
   ajv@6.12.6:
     resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
 
@@ -1373,10 +1372,6 @@
   caniuse-lite@1.0.30001745:
     resolution: {integrity: sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==}
 
-  cfb@1.2.2:
-    resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
-    engines: {node: '>=0.8'}
-
   chalk@4.1.2:
     resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
     engines: {node: '>=10'}
@@ -1396,10 +1391,6 @@
   cliui@8.0.1:
     resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
     engines: {node: '>=12'}
-
-  codepage@1.15.0:
-    resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
-    engines: {node: '>=0.8'}
 
   color-convert@2.0.1:
     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
@@ -1448,11 +1439,6 @@
     peerDependenciesMeta:
       typescript:
         optional: true
-
-  crc-32@1.2.2:
-    resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
-    engines: {node: '>=0.8'}
-    hasBin: true
 
   cross-spawn@7.0.6:
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
@@ -1831,10 +1817,6 @@
     resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
     engines: {node: '>= 6'}
 
-  frac@1.1.2:
-    resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
-    engines: {node: '>=0.8'}
-
   fs-extra@10.1.0:
     resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
     engines: {node: '>=12'}
@@ -2077,6 +2059,9 @@
   js-yaml@4.1.0:
     resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
     hasBin: true
+
+  jsbarcode@3.12.3:
+    resolution: {integrity: sha512-CuHU9hC6dPsHF5oVFMo8NW76uQVjH4L22CsP4hW+dNnGywJHC/B0ThA1CTDVLnxKLrrpYdicBLnd2xsgTfRnvg==}
 
   jsesc@3.1.0:
     resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
@@ -2691,10 +2676,6 @@
     resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
     engines: {node: '>=0.10.0'}
 
-  ssf@0.11.2:
-    resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
-    engines: {node: '>=0.8'}
-
   ssr-window@3.0.0:
     resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==}
 
@@ -3063,17 +3044,9 @@
   wildcard@1.1.2:
     resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
 
-  wmf@1.0.2:
-    resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
-    engines: {node: '>=0.8'}
-
   word-wrap@1.2.5:
     resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
     engines: {node: '>=0.10.0'}
-
-  word@0.3.0:
-    resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
-    engines: {node: '>=0.8'}
 
   wrap-ansi@7.0.0:
     resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
@@ -3097,8 +3070,9 @@
     peerDependencies:
       core-js: '>=3.12.1'
 
-  xlsx@0.18.5:
-    resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
+  xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz:
+    resolution: {integrity: sha512-+nKZ39+nvK7Qq6i0PvWWRA4j/EkfWOtkP/YhMtupm+lJIiHxUrgTr1CcKv1nBk1rHtkRRQ3O2+Ih/q/sA+FXZA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz}
+    version: 0.20.2
     engines: {node: '>=0.8'}
     hasBin: true
 
@@ -4166,8 +4140,6 @@
 
   acorn@8.15.0: {}
 
-  adler-32@1.3.1: {}
-
   ajv@6.12.6:
     dependencies:
       fast-deep-equal: 3.1.3
@@ -4263,11 +4235,6 @@
 
   caniuse-lite@1.0.30001745: {}
 
-  cfb@1.2.2:
-    dependencies:
-      adler-32: 1.3.1
-      crc-32: 1.2.2
-
   chalk@4.1.2:
     dependencies:
       ansi-styles: 4.3.0
@@ -4296,8 +4263,6 @@
       string-width: 4.2.3
       strip-ansi: 6.0.1
       wrap-ansi: 7.0.0
-
-  codepage@1.15.0: {}
 
   color-convert@2.0.1:
     dependencies:
@@ -4337,8 +4302,6 @@
       parse-json: 5.2.0
     optionalDependencies:
       typescript: 5.6.3
-
-  crc-32@1.2.2: {}
 
   cross-spawn@7.0.6:
     dependencies:
@@ -4775,8 +4738,6 @@
       hasown: 2.0.2
       mime-types: 2.1.35
 
-  frac@1.1.2: {}
-
   fs-extra@10.1.0:
     dependencies:
       graceful-fs: 4.2.11
@@ -4979,6 +4940,8 @@
   js-yaml@4.1.0:
     dependencies:
       argparse: 2.0.1
+
+  jsbarcode@3.12.3: {}
 
   jsesc@3.1.0: {}
 
@@ -5497,10 +5460,6 @@
 
   speakingurl@14.0.1: {}
 
-  ssf@0.11.2:
-    dependencies:
-      frac: 1.1.2
-
   ssr-window@3.0.0: {}
 
   string-width@4.2.3:
@@ -5932,11 +5891,7 @@
 
   wildcard@1.1.2: {}
 
-  wmf@1.0.2: {}
-
   word-wrap@1.2.5: {}
-
-  word@0.3.0: {}
 
   wrap-ansi@7.0.0:
     dependencies:
@@ -5967,15 +5922,7 @@
       eventemitter3: 4.0.7
       xgplayer-subtitles: 3.0.23(core-js@3.45.1)
 
-  xlsx@0.18.5:
-    dependencies:
-      adler-32: 1.3.1
-      cfb: 1.2.2
-      codepage: 1.15.0
-      crc-32: 1.2.2
-      ssf: 0.11.2
-      wmf: 1.0.2
-      word: 0.3.0
+  xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz: {}
 
   xml-name-validator@4.0.0: {}
 
diff --git a/rsf-design/src/api/wh-mat.js b/rsf-design/src/api/wh-mat.js
index 2bc5a50..ef7e9fd 100644
--- a/rsf-design/src/api/wh-mat.js
+++ b/rsf-design/src/api/wh-mat.js
@@ -195,3 +195,48 @@
     }
   })
 }
+
+export function fetchMatnrPrintTemplateList(params = {}) {
+  return request.post({
+    url: '/matnrPrintTemplate/list',
+    params
+  })
+}
+
+export function fetchGetMatnrPrintTemplateDetail(id) {
+  return request.get({
+    url: `/matnrPrintTemplate/${id}`
+  })
+}
+
+export function fetchGetDefaultMatnrPrintTemplate() {
+  return request.get({
+    url: '/matnrPrintTemplate/default'
+  })
+}
+
+export function fetchSaveMatnrPrintTemplate(params = {}) {
+  return request.post({
+    url: '/matnrPrintTemplate/save',
+    params
+  })
+}
+
+export function fetchUpdateMatnrPrintTemplate(params = {}) {
+  return request.post({
+    url: '/matnrPrintTemplate/update',
+    params
+  })
+}
+
+export function fetchRemoveMatnrPrintTemplate(ids) {
+  return request.post({
+    url: `/matnrPrintTemplate/remove/${normalizeIds(ids)}`
+  })
+}
+
+export function fetchSetDefaultMatnrPrintTemplate(id) {
+  return request.post({
+    url: `/matnrPrintTemplate/default/${id}`
+  })
+}
diff --git a/rsf-design/src/locales/langs/en.json b/rsf-design/src/locales/langs/en.json
index 48b53ed..6d3187d 100644
--- a/rsf-design/src/locales/langs/en.json
+++ b/rsf-design/src/locales/langs/en.json
@@ -480,6 +480,7 @@
     "customer": "Customer",
     "shipper": "shipper",
     "matnr": "Matnr",
+    "matnrPrintTemplate": "Material Print Templates",
     "matnrGroup": "MatnrGroup",
     "warehouse": "Warehouse",
     "warehouseAreas": "WarehouseAreas",
@@ -2844,6 +2845,172 @@
             "groupId": "Please select material group"
           }
         },
+        "printTemplate": {
+          "page": {
+            "messages": {
+              "enabledFieldsTimeout": "Dynamic fields loading timed out",
+              "previewTimeout": "Sample material loading timed out",
+              "initFailed": "Failed to initialize the print template page"
+            }
+          },
+          "workspace": {
+            "sidebarTitle": "Template List",
+            "sidebarSubtitle": "Tenant-shared multi-template management",
+            "defaultTag": "Default",
+            "unnamedTemplate": "Untitled Template",
+            "previewRecord": "Preview Record: {name}",
+            "noPreviewRecord": "No sample data",
+            "emptyTemplate": "No templates yet. Please create one first.",
+            "actions": {
+              "copy": "Copy",
+              "setDefault": "Set Default",
+              "saveTemplate": "Save Template",
+              "delete": "Delete"
+            },
+            "messages": {
+              "loadFailed": "Failed to load templates",
+              "deleteConfirm": "Delete template \"{name}\"?",
+              "deleteSuccess": "Template deleted",
+              "deleteFailed": "Failed to delete template",
+              "defaultSuccess": "Default template updated",
+              "defaultFailed": "Failed to set default template",
+              "saveSuccess": "Template saved",
+              "saveFailed": "Failed to save template"
+            }
+          },
+          "toolbar": {
+            "zoomOut": "Zoom Out",
+            "zoomIn": "Zoom In",
+            "zoomFit": "Auto Fit",
+            "elements": {
+              "text": "Text",
+              "barcode": "Barcode",
+              "qrcode": "QR Code",
+              "line": "Line",
+              "rect": "Rectangle",
+              "table": "Field Table"
+            }
+          },
+          "fieldPanel": {
+            "title": "Available Fields",
+            "fixedCategory": "Fixed Fields",
+            "extendCategory": "Extended Fields"
+          },
+          "propertyPanel": {
+            "sections": {
+              "templateInfo": "Template Info",
+              "canvas": "Canvas Settings",
+              "element": "Element Properties",
+              "fieldList": "Field List"
+            },
+            "fields": {
+              "templateName": "Template Name",
+              "templateCode": "Template Code",
+              "defaultTemplate": "Default Template",
+              "widthMm": "Width (mm)",
+              "heightMm": "Height (mm)",
+              "backgroundColor": "Background Color",
+              "gridSizeMm": "Grid (mm)",
+              "elementType": "Element Type",
+              "visible": "Visible",
+              "zIndex": "Z-Index",
+              "contentMode": "Content Mode",
+              "textContent": "Text Content",
+              "fontSizeMm": "Font Size (mm)",
+              "fontWeight": "Font Weight",
+              "textAlign": "Alignment",
+              "textColor": "Text Color",
+              "valueTemplate": "Value Template",
+              "symbology": "Symbology",
+              "showText": "Show Text",
+              "direction": "Direction",
+              "lineWidthMm": "Line Width (mm)",
+              "lineColor": "Line Color",
+              "borderWidthMm": "Border Width (mm)",
+              "radiusMm": "Radius (mm)",
+              "borderColor": "Border Color",
+              "fillColor": "Fill Color",
+              "leftMode": "Left Column Mode",
+              "rightMode": "Right Column Mode",
+              "rowHeightMm": "Row Height (mm)",
+              "leftContent": "Left Content",
+              "rightContent": "Right Content"
+            },
+            "options": {
+              "staticText": "Static Text",
+              "templateText": "Template Placeholder",
+              "template": "Template",
+              "alignLeft": "Left",
+              "alignCenter": "Center",
+              "alignRight": "Right",
+              "horizontal": "Horizontal",
+              "vertical": "Vertical"
+            },
+            "actions": {
+              "removeElement": "Delete Element",
+              "addRow": "Add Row"
+            },
+            "empty": "Select an element on the canvas to edit its position, size, and content here.",
+            "rowTitle": "Row {index}",
+            "defaultFieldLabel": "Field Label"
+          },
+          "dialog": {
+            "title": "Print",
+            "templatePlaceholder": "Please select a template",
+            "copyCount": "Copies",
+            "labelCount": "{count} labels",
+            "noTemplate": "No print templates are available for the current tenant. Please create one from the left-side menu \"Material Print Templates\" first.",
+            "noData": "No printable data",
+            "canvasOverflow": "Some elements extend beyond the canvas bounds",
+            "canvasOverflowDescription": "Printing still uses the template canvas size as the source of truth. Anything outside the canvas will be clipped, so please adjust the layout in the template editor first.",
+            "confirmPrint": "Print",
+            "printDocumentTitle": "Material Print",
+            "messages": {
+              "loadFailed": "Failed to load templates",
+              "detailFailed": "Failed to load template details",
+              "popupBlocked": "The browser blocked the print window. Please check the popup settings."
+            }
+          },
+          "helpers": {
+            "defaultTemplateName": "Material Label Template",
+            "copySuffix": " - Copy",
+            "defaultText": {
+              "productCode": "Product Code",
+              "productName": "Product",
+              "staticText": "Static Text"
+            },
+            "tableLabels": {
+              "code": "Material Code",
+              "name": "Material Name",
+              "spec": "Specification",
+              "model": "Model"
+            },
+            "fixedFields": {
+              "id": "Material ID",
+              "code": "Material Code",
+              "name": "Material Name",
+              "groupName": "Material Group",
+              "barcode": "Barcode",
+              "spec": "Specification",
+              "model": "Model",
+              "color": "Color",
+              "size": "Size",
+              "unit": "Unit",
+              "purUnit": "Purchase Unit",
+              "stockUnit": "Stock Unit",
+              "stockLevelText": "Stock Level",
+              "flagLabelManageText": "Label Management",
+              "flagCheckText": "Exempt Inspection",
+              "statusText": "Status",
+              "describle": "Description",
+              "memo": "Memo",
+              "createByText": "Created By",
+              "createTimeText": "Created At",
+              "updateByText": "Updated By",
+              "updateTimeText": "Updated At"
+            }
+          }
+        },
         "batchDialog": {
           "titles": {
             "status": "Batch Update Status",
diff --git a/rsf-design/src/locales/langs/zh.json b/rsf-design/src/locales/langs/zh.json
index 5af6421..611f044 100644
--- a/rsf-design/src/locales/langs/zh.json
+++ b/rsf-design/src/locales/langs/zh.json
@@ -480,6 +480,7 @@
     "customer": "瀹㈡埛琛�",
     "shipper": "璐т富淇℃伅",
     "matnr": "鐗╂枡",
+    "matnrPrintTemplate": "鐗╂枡鎵撳嵃妯℃澘",
     "matnrGroup": "鐗╂枡鍒嗙粍",
     "warehouse": "浠撳簱",
     "warehouseAreas": "搴撳尯",
@@ -2852,6 +2853,172 @@
             "groupId": "璇烽�夋嫨鐗╂枡鍒嗙粍"
           }
         },
+        "printTemplate": {
+          "page": {
+            "messages": {
+              "enabledFieldsTimeout": "鎵╁睍瀛楁鍔犺浇瓒呮椂",
+              "previewTimeout": "鐗╂枡绀轰緥鏁版嵁鍔犺浇瓒呮椂",
+              "initFailed": "鎵撳嵃妯℃澘椤甸潰鍒濆鍖栧け璐�"
+            }
+          },
+          "workspace": {
+            "sidebarTitle": "妯℃澘鍒楄〃",
+            "sidebarSubtitle": "绉熸埛鍏变韩锛屽妯℃澘绠$悊",
+            "defaultTag": "榛樿",
+            "unnamedTemplate": "鏈懡鍚嶆ā鏉�",
+            "previewRecord": "棰勮璁板綍锛歿name}",
+            "noPreviewRecord": "鏆傛棤绀轰緥鏁版嵁",
+            "emptyTemplate": "鏆傛棤妯℃澘锛岃鍏堟柊澧炴ā鏉�",
+            "actions": {
+              "copy": "澶嶅埗",
+              "setDefault": "璁句负榛樿",
+              "saveTemplate": "淇濆瓨妯℃澘",
+              "delete": "鍒犻櫎"
+            },
+            "messages": {
+              "loadFailed": "妯℃澘鍔犺浇澶辫触",
+              "deleteConfirm": "纭鍒犻櫎妯℃澘銆寋name}銆嶅悧锛�",
+              "deleteSuccess": "妯℃澘宸插垹闄�",
+              "deleteFailed": "妯℃澘鍒犻櫎澶辫触",
+              "defaultSuccess": "榛樿妯℃澘宸叉洿鏂�",
+              "defaultFailed": "榛樿妯℃澘璁剧疆澶辫触",
+              "saveSuccess": "妯℃澘淇濆瓨鎴愬姛",
+              "saveFailed": "妯℃澘淇濆瓨澶辫触"
+            }
+          },
+          "toolbar": {
+            "zoomOut": "缂╁皬",
+            "zoomIn": "鏀惧ぇ",
+            "zoomFit": "鑷�傚簲",
+            "elements": {
+              "text": "鏂囨湰",
+              "barcode": "涓�缁寸爜",
+              "qrcode": "浜岀淮鐮�",
+              "line": "鐩寸嚎",
+              "rect": "鐭╁舰",
+              "table": "瀛楁琛ㄦ牸"
+            }
+          },
+          "fieldPanel": {
+            "title": "鍙敤瀛楁",
+            "fixedCategory": "鍥哄畾瀛楁",
+            "extendCategory": "鎵╁睍瀛楁"
+          },
+          "propertyPanel": {
+            "sections": {
+              "templateInfo": "妯℃澘淇℃伅",
+              "canvas": "鐢诲竷璁剧疆",
+              "element": "鍏冪礌灞炴��",
+              "fieldList": "瀛楁娓呭崟"
+            },
+            "fields": {
+              "templateName": "妯℃澘鍚嶇О",
+              "templateCode": "妯℃澘缂栫爜",
+              "defaultTemplate": "榛樿妯℃澘",
+              "widthMm": "瀹藉害(mm)",
+              "heightMm": "楂樺害(mm)",
+              "backgroundColor": "鑳屾櫙鑹�",
+              "gridSizeMm": "缃戞牸(mm)",
+              "elementType": "鍏冪礌绫诲瀷",
+              "visible": "鏄剧ず",
+              "zIndex": "灞傜骇",
+              "contentMode": "鍐呭妯″紡",
+              "textContent": "鏂囨湰鍐呭",
+              "fontSizeMm": "瀛楀彿(mm)",
+              "fontWeight": "瀛楅噸",
+              "textAlign": "瀵归綈",
+              "textColor": "鏂囧瓧棰滆壊",
+              "valueTemplate": "鐮佸�兼ā鏉�",
+              "symbology": "缂栫爜鍒跺紡",
+              "showText": "鏄剧ず鏂囧瓧",
+              "direction": "鏂瑰悜",
+              "lineWidthMm": "绮楃粏(mm)",
+              "lineColor": "绾挎潯棰滆壊",
+              "borderWidthMm": "杈规瀹藉害(mm)",
+              "radiusMm": "鍦嗚(mm)",
+              "borderColor": "杈规棰滆壊",
+              "fillColor": "濉厖棰滆壊",
+              "leftMode": "宸﹀垪妯″紡",
+              "rightMode": "鍙冲垪妯″紡",
+              "rowHeightMm": "琛岄珮(mm)",
+              "leftContent": "宸﹀垪鍐呭",
+              "rightContent": "鍙冲垪鍐呭"
+            },
+            "options": {
+              "staticText": "闈欐�佹枃鏈�",
+              "templateText": "鍗犱綅绗︽ā鏉�",
+              "template": "妯℃澘",
+              "alignLeft": "宸﹀榻�",
+              "alignCenter": "灞呬腑",
+              "alignRight": "鍙冲榻�",
+              "horizontal": "妯嚎",
+              "vertical": "绔栫嚎"
+            },
+            "actions": {
+              "removeElement": "鍒犻櫎鍏冪礌",
+              "addRow": "鏂板涓�琛�"
+            },
+            "empty": "閫夋嫨鐢诲竷涓殑鍏冪礌鍚庯紝鍦ㄨ繖閲岀紪杈戜綅缃�佸昂瀵稿拰瀛楁鍐呭銆�",
+            "rowTitle": "绗� {index} 琛�",
+            "defaultFieldLabel": "瀛楁鍚�"
+          },
+          "dialog": {
+            "title": "鎵撳嵃",
+            "templatePlaceholder": "璇烽�夋嫨妯℃澘",
+            "copyCount": "鎵撳嵃浠芥暟",
+            "labelCount": "鍏� {count} 鏉℃爣绛�",
+            "noTemplate": "褰撳墠绉熸埛杩樻病鏈夋墦鍗版ā鏉匡紝璇峰厛鍒板乏渚ц彍鍗曗�滅墿鏂欐墦鍗版ā鏉库�濋噷鍒涘缓銆�",
+            "noData": "鏆傛棤鍙墦鍗版暟鎹�",
+            "canvasOverflow": "褰撳墠妯℃澘鏈夊厓绱犺秴鍑轰簡鐢诲竷杈圭晫",
+            "canvasOverflowDescription": "鎵撳嵃浠嶄細涓ユ牸鎸夋ā鏉跨敾甯冨昂瀵歌緭鍑猴紝瓒呭嚭閮ㄥ垎浼氳瑁佸垏锛岃鍏堝洖鍒版ā鏉块〉璋冩暣甯冨眬銆�",
+            "confirmPrint": "纭鎵撳嵃",
+            "printDocumentTitle": "鐗╂枡鎵撳嵃",
+            "messages": {
+              "loadFailed": "妯℃澘鍔犺浇澶辫触",
+              "detailFailed": "妯℃澘璇︽儏鍔犺浇澶辫触",
+              "popupBlocked": "娴忚鍣ㄩ樆姝簡鎵撳嵃绐楀彛锛岃妫�鏌ュ脊绐楄缃�"
+            }
+          },
+          "helpers": {
+            "defaultTemplateName": "鐗╂枡鏍囩妯℃澘",
+            "copySuffix": "-鍓湰",
+            "defaultText": {
+              "productCode": "鍟嗗搧缂栫爜",
+              "productName": "鍟嗗搧",
+              "staticText": "闈欐�佹枃鏈�"
+            },
+            "tableLabels": {
+              "code": "鐗╂枡缂栫爜",
+              "name": "鐗╂枡鍚嶇О",
+              "spec": "瑙勬牸",
+              "model": "鍨嬪彿"
+            },
+            "fixedFields": {
+              "id": "鐗╂枡ID",
+              "code": "鐗╂枡缂栫爜",
+              "name": "鐗╂枡鍚嶇О",
+              "groupName": "鐗╂枡鍒嗙粍",
+              "barcode": "鏉$爜",
+              "spec": "瑙勬牸",
+              "model": "鍨嬪彿",
+              "color": "棰滆壊",
+              "size": "灏哄",
+              "unit": "鍗曚綅",
+              "purUnit": "閲囪喘鍗曚綅",
+              "stockUnit": "搴撲綅鍗曚綅",
+              "stockLevelText": "搴撳瓨绛夌骇",
+              "flagLabelManageText": "鏍囩绠$悊",
+              "flagCheckText": "鍏嶆",
+              "statusText": "鐘舵��",
+              "describle": "鎻忚堪",
+              "memo": "澶囨敞",
+              "createByText": "鍒涘缓浜�",
+              "createTimeText": "鍒涘缓鏃堕棿",
+              "updateByText": "鏇存柊浜�",
+              "updateTimeText": "鏇存柊鏃堕棿"
+            }
+          }
+        },
         "batchDialog": {
           "titles": {
             "status": "鎵归噺淇敼鐘舵��",
diff --git a/rsf-design/src/router/adapters/backendMenuAdapter.js b/rsf-design/src/router/adapters/backendMenuAdapter.js
index 5778434..211c1a4 100644
--- a/rsf-design/src/router/adapters/backendMenuAdapter.js
+++ b/rsf-design/src/router/adapters/backendMenuAdapter.js
@@ -19,6 +19,7 @@
   fieldsItem: '/system/fields-item',
   whMat: '/basic-info/wh-mat',
   matnr: '/basic-info/wh-mat',
+  matnrPrintTemplate: '/basic-info/matnr-print-template',
   matnrGroup: '/basic-info/matnr-group',
   locType: '/basic-info/loc-type',
   taskPathTemplate: '/basic-info/task-path-template',
@@ -171,7 +172,7 @@
     return null
   }
 
-  const path = resolvePath(node, { component, isFirstLevel, hasChildren, rawRoute })
+  const path = resolvePath(node, { component, isFirstLevel, rawRoute })
   const meta = buildMeta(node)
   const adapted = {
     id: normalizeId(node.id),
@@ -211,7 +212,7 @@
   return PHASE_1_COMPONENTS[normalizedKey] || normalizeComponentPath(fullRoutePath)
 }
 
-function resolvePath(node, { component, isFirstLevel, hasChildren, rawRoute }) {
+function resolvePath(node, { component, isFirstLevel, rawRoute }) {
   if (rawRoute) {
     if (!isFirstLevel && rawRoute.startsWith('/')) {
       return normalizeComponentPath(rawRoute)
diff --git a/rsf-design/src/utils/backend-menu-title.js b/rsf-design/src/utils/backend-menu-title.js
index 0f363bc..fe5e7c3 100644
--- a/rsf-design/src/utils/backend-menu-title.js
+++ b/rsf-design/src/utils/backend-menu-title.js
@@ -55,6 +55,7 @@
   'menu.locType': '搴撲綅绫诲瀷(搴�)',
   'menu.logs': '鏃ュ織',
   'menu.matnr': '鐗╂枡',
+  'menu.matnrPrintTemplate': '鐗╂枡鎵撳嵃妯℃澘',
   'menu.matnrGroup': '鐗╂枡鍒嗙粍',
   'menu.matnrRoleMenu': '鐗╂枡鏉冮檺',
   'menu.menu': '鑿滃崟绠$悊',
@@ -133,8 +134,8 @@
     return accumulator
   },
   {
-    '浠〃鐩�': 'menus.dashboard.title',
-    '宸ヤ綔鍙�': 'menus.dashboard.console',
+    浠〃鐩�: 'menus.dashboard.title',
+    宸ヤ綔鍙�: 'menus.dashboard.console',
     'menus.dashboard.title': 'menus.dashboard.title',
     'menus.dashboard.console': 'menus.dashboard.console'
   }
diff --git a/rsf-design/src/views/basic-info/matnr-print-template/index.vue b/rsf-design/src/views/basic-info/matnr-print-template/index.vue
new file mode 100644
index 0000000..19c1347
--- /dev/null
+++ b/rsf-design/src/views/basic-info/matnr-print-template/index.vue
@@ -0,0 +1,87 @@
+<template>
+  <div class="matnr-print-template-page art-full-height">
+    <WhMatPrintTemplateWorkspace
+      :enabled-fields="enabledFields"
+      :preview-record="previewRecord"
+      :auto-load="true"
+    />
+  </div>
+</template>
+
+<script setup>
+  import { onMounted, ref } from 'vue'
+  import { useI18n } from 'vue-i18n'
+  import { ElMessage } from 'element-plus'
+  import { fetchEnabledFields, fetchMatnrPage } from '@/api/wh-mat'
+  import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
+  import {
+    buildMatnrPageQueryParams,
+    normalizeMatnrRow,
+    normalizeWhMatEnabledFields
+  } from '../wh-mat/whMatPage.helpers'
+  import WhMatPrintTemplateWorkspace from '../wh-mat/modules/wh-mat-print-template-workspace.vue'
+
+  defineOptions({ name: 'MatnrPrintTemplate' })
+
+  const { t } = useI18n()
+  const enabledFields = ref([])
+  const previewRecord = ref({})
+
+  async function loadEnabledFields() {
+    const fields = await guardRequestWithMessage(fetchEnabledFields(), [], {
+      timeoutMessage: t('pages.basicInfo.whMat.printTemplate.page.messages.enabledFieldsTimeout')
+    })
+    enabledFields.value = normalizeWhMatEnabledFields(fields)
+  }
+
+  async function loadPreviewRecord() {
+    const response = await guardRequestWithMessage(
+      fetchMatnrPage(
+        buildMatnrPageQueryParams({
+          current: 1,
+          pageSize: 1,
+          orderBy: 'update_time desc'
+        })
+      ),
+      {
+        records: []
+      },
+      {
+        timeoutMessage: t('pages.basicInfo.whMat.printTemplate.page.messages.previewTimeout')
+      }
+    )
+
+    const firstRecord = Array.isArray(response?.records) ? response.records[0] : null
+    previewRecord.value = firstRecord
+      ? normalizeMatnrRow(firstRecord, () => '', enabledFields.value)
+      : {}
+  }
+
+  onMounted(async () => {
+    try {
+      await loadEnabledFields()
+      await loadPreviewRecord()
+    } catch (error) {
+      ElMessage.error(
+        error?.message || t('pages.basicInfo.whMat.printTemplate.page.messages.initFailed')
+      )
+    }
+  })
+</script>
+
+<style scoped>
+  .matnr-print-template-page {
+    height: calc(var(--art-full-height) - 40px);
+    max-height: calc(var(--art-full-height) - 40px);
+    min-height: 0;
+    overflow: hidden;
+  }
+
+  @media (max-width: 640px) {
+    .matnr-print-template-page {
+      height: auto;
+      max-height: none;
+      overflow: auto;
+    }
+  }
+</style>
diff --git a/rsf-design/src/views/basic-info/wh-mat/index.vue b/rsf-design/src/views/basic-info/wh-mat/index.vue
index 6d28ea1..ff0043a 100644
--- a/rsf-design/src/views/basic-info/wh-mat/index.vue
+++ b/rsf-design/src/views/basic-info/wh-mat/index.vue
@@ -155,21 +155,18 @@
                 <ElButton :loading="templateDownloading" @click="handleDownloadTemplate" v-ripple>
                   {{ t('pages.basicInfo.whMat.actions.downloadTemplate') }}
                 </ElButton>
-                <ListExportPrint
-                  class="inline-flex"
-                  :preview-visible="previewVisible"
-                  @update:previewVisible="handlePreviewVisibleChange"
-                  :report-title="reportTitle"
-                  :selected-rows="selectedRows"
-                  :query-params="reportQueryParams"
-                  :columns="columns"
-                  :preview-rows="previewRows"
-                  :preview-meta="resolvedPreviewMeta"
-                  :total="pagination.total"
-                  :disabled="loading"
-                  @export="handleExport"
-                  @print="handlePrint"
-                />
+                <ElButton :loading="exporting" :disabled="loading" @click="handleExport" v-ripple>
+                  {{ t('common.actions.export') }}
+                </ElButton>
+                <ElButton
+                  v-auth="'manager:matnrPrintTemplate:list'"
+                  :loading="printLoading"
+                  :disabled="loading || selectedRows.length === 0"
+                  @click="handlePrint()"
+                  v-ripple
+                >
+                  {{ t('common.actions.print') }}
+                </ElButton>
               </ElSpace>
             </template>
           </ArtTableHeader>
@@ -222,6 +219,8 @@
       :loading="detailLoading"
       :detail="detailData"
     />
+
+    <WhMatPrintDialog v-model:visible="printDialogVisible" :rows="printRows" />
   </div>
 </template>
 
@@ -229,7 +228,7 @@
   import { computed, onMounted, reactive, ref } from 'vue'
   import { ElMessage } from 'element-plus'
   import { useI18n } from 'vue-i18n'
-  import ListExportPrint from '@/components/biz/list-export-print/index.vue'
+  import { buildListExportPayload } from '@/components/biz/list-export-print/list-export-print.helpers.js'
   import { useAuth } from '@/hooks/core/useAuth'
   import { useTableColumns } from '@/hooks/core/useTableColumns'
   import { useUserStore } from '@/store/modules/user'
@@ -241,7 +240,6 @@
   import { defaultResponseAdapter } from '@/utils/table/tableUtils'
   import { guardRequestWithMessage } from '@/utils/sys/requestGuard'
   import { useCrudPage } from '@/views/system/common/useCrudPage'
-  import { usePrintExportPage } from '@/views/system/common/usePrintExportPage'
   import {
     fetchBatchUpdateMatnr,
     fetchBindMatnrGroup,
@@ -262,6 +260,7 @@
   import WhMatBindLocDialog from './modules/wh-mat-bind-loc-dialog.vue'
   import WhMatDialog from './modules/wh-mat-dialog.vue'
   import WhMatDetailDrawer from './modules/wh-mat-detail-drawer.vue'
+  import WhMatPrintDialog from './modules/wh-mat-print-dialog.vue'
   import { createWhMatTableColumns } from './whMatTable.columns'
   import {
     WH_MAT_REPORT_STYLE,
@@ -269,8 +268,6 @@
     buildMatnrGroupTreeQueryParams,
     buildMatnrPageQueryParams,
     buildWhMatDialogModel,
-    buildWhMatPrintRows,
-    buildWhMatReportMeta,
     buildWhMatSavePayload,
     createWhMatSearchState,
     getWhMatDynamicFieldKey,
@@ -292,6 +289,7 @@
   const { t } = useI18n()
   const { hasAuth } = useAuth()
   const userStore = useUserStore()
+  const canPrintMatnr = hasAuth('manager:matnrPrintTemplate:list')
 
   const showBatchActionButtons = false
   const loading = ref(false)
@@ -303,10 +301,14 @@
   const bindLocDialogVisible = ref(false)
   const bindLocOptionsLoading = ref(false)
   const importing = ref(false)
+  const exporting = ref(false)
+  const printLoading = ref(false)
+  const printDialogVisible = ref(false)
   const templateDownloading = ref(false)
   const tableData = ref([])
   const groupTreeData = ref([])
   const detailData = ref({})
+  const printRows = ref([])
   const enabledFields = ref([])
   const serialRuleOptions = ref([])
   const areaOptions = ref([])
@@ -643,7 +645,7 @@
       handleViewDetail: openDetailDrawer,
       handleEdit: hasAuth('update') ? openEditDialog : null,
       handleDelete: hasAuth('delete') ? (row) => handleDeleteAction?.(row) : null,
-      handlePrint: (row) => handlePrint({ ids: [row.id] }),
+      handlePrint: canPrintMatnr ? (row) => handlePrint({ ids: [row.id] }) : null,
       canEdit: hasAuth('update'),
       canDelete: hasAuth('delete'),
       t
@@ -1038,41 +1040,6 @@
     }
   }
 
-  const resolvePrintRecords = async (payload) => {
-    if (Array.isArray(payload?.ids) && payload.ids.length > 0) {
-      return defaultResponseAdapter(await fetchGetMatnrMany(payload.ids)).records
-    }
-    return tableData.value
-  }
-
-  const {
-    previewVisible,
-    previewRows,
-    previewMeta,
-    handlePreviewVisibleChange,
-    handleExport,
-    handlePrint
-  } = usePrintExportPage({
-    downloadFileName: 'matnr.xlsx',
-    requestExport: (payload) =>
-      fetchExportMatnrReport(payload, {
-        headers: {
-          Authorization: userStore.accessToken || ''
-        }
-      }),
-    resolvePrintRecords,
-    buildPreviewRows: (records) => buildWhMatPrintRows(records, t),
-    buildPreviewMeta
-  })
-
-  const resolvedPreviewMeta = computed(() =>
-    buildWhMatReportMeta({
-      previewMeta: previewMeta.value,
-      count: previewRows.value.length,
-      orientation: previewMeta.value?.reportStyle?.orientation || WH_MAT_REPORT_STYLE.orientation
-    })
-  )
-
   async function downloadFile(response, fallbackName) {
     if (!response?.ok) {
       throw new Error(
@@ -1169,6 +1136,69 @@
     }
   }
 
+  async function handleExport() {
+    exporting.value = true
+    try {
+      const payload = buildListExportPayload({
+        reportTitle,
+        selectedRows: selectedRows.value,
+        queryParams: reportQueryParams.value,
+        columns,
+        meta: buildPreviewMeta(tableData.value)
+      })
+      const response = await guardRequestWithMessage(
+        fetchExportMatnrReport(payload, {
+          headers: {
+            Authorization: userStore.accessToken || ''
+          }
+        }),
+        null,
+        {
+          timeoutMessage: t('message.exportTimeoutStopped')
+        }
+      )
+      if (!response) {
+        return
+      }
+      await downloadFile(response, 'matnr.xlsx')
+      ElMessage.success(t('crud.messages.exportSuccess'))
+    } catch (error) {
+      ElMessage.error(error?.message || t('crud.messages.exportFailed'))
+    } finally {
+      exporting.value = false
+    }
+  }
+
+  async function handlePrint(payload = {}) {
+    const ids =
+      Array.isArray(payload?.ids) && payload.ids.length > 0 ? payload.ids : getSelectedIds()
+
+    if (!ids.length) {
+      ElMessage.warning(t('pages.basicInfo.whMat.messages.selectAtLeastOne'))
+      return
+    }
+
+    printLoading.value = true
+    try {
+      const records = await guardRequestWithMessage(fetchGetMatnrMany(ids), [], {
+        timeoutMessage: t('message.printTimeoutStopped')
+      })
+      const normalizedRecords = defaultResponseAdapter(records).records
+      if (!normalizedRecords.length) {
+        ElMessage.warning(t('print.noData'))
+        return
+      }
+      printRows.value = normalizedRecords.map((record) =>
+        normalizeMatnrRow(record, t, enabledFields.value)
+      )
+      printDialogVisible.value = true
+    } catch (error) {
+      ElMessage.error(error?.message || t('crud.messages.printFailed'))
+    } finally {
+      printLoading.value = false
+    }
+  }
+
   async function handleDownloadTemplate() {
     templateDownloading.value = true
     try {
diff --git a/rsf-design/src/views/basic-info/wh-mat/matnrPrintTemplate.helpers.js b/rsf-design/src/views/basic-info/wh-mat/matnrPrintTemplate.helpers.js
new file mode 100644
index 0000000..df53418
--- /dev/null
+++ b/rsf-design/src/views/basic-info/wh-mat/matnrPrintTemplate.helpers.js
@@ -0,0 +1,827 @@
+import JsBarcode from 'jsbarcode'
+import { $t } from '@/locales'
+
+const MM_UNIT = 'mm'
+const DEFAULT_CANVAS = {
+  width: 70,
+  height: 40,
+  unit: MM_UNIT,
+  backgroundColor: '#FFFFFF',
+  gridSize: 1
+}
+
+const TEXT_STYLE_DEFAULTS = {
+  fontSize: 3.2,
+  fontWeight: 400,
+  textAlign: 'left',
+  color: '#111111',
+  lineHeight: 1.25
+}
+
+const TABLE_STYLE_DEFAULTS = {
+  fontSize: 2.8,
+  fontWeight: 400,
+  textAlign: 'left',
+  color: '#111111',
+  borderColor: '#111111',
+  borderWidth: 0.2,
+  backgroundColor: '#FFFFFF'
+}
+
+const FIXED_FIELD_OPTIONS = [
+  'id',
+  'code',
+  'name',
+  'groupName',
+  'barcode',
+  'spec',
+  'model',
+  'color',
+  'size',
+  'unit',
+  'purUnit',
+  'stockUnit',
+  'stockLevelText',
+  'flagLabelManageText',
+  'flagCheckText',
+  'statusText',
+  'describle',
+  'memo',
+  'createByText',
+  'createTimeText',
+  'updateByText',
+  'updateTimeText'
+]
+
+function cloneDeep(value) {
+  return JSON.parse(JSON.stringify(value ?? null))
+}
+
+function normalizeText(value) {
+  return String(value ?? '').trim()
+}
+
+function normalizeNumber(value, fallback = 0) {
+  const nextValue = Number(value)
+  return Number.isFinite(nextValue) ? nextValue : fallback
+}
+
+function normalizeInteger(value, fallback = 0) {
+  return Math.round(normalizeNumber(value, fallback))
+}
+
+function normalizeColor(value, fallback = '#111111') {
+  const color = normalizeText(value)
+  return color || fallback
+}
+
+function createElementId(prefix = 'el') {
+  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
+}
+
+export function mmToPx(mm, scale = 4) {
+  return `${normalizeNumber(mm) * scale}px`
+}
+
+export function buildMatnrPrintFieldOptions(enabledFields = []) {
+  const fixedFields = FIXED_FIELD_OPTIONS.map((field) => ({
+    label: $t(`pages.basicInfo.whMat.printTemplate.helpers.fixedFields.${field}`),
+    path: field,
+    placeholder: `{${field}}`,
+    category: $t('pages.basicInfo.whMat.printTemplate.fieldPanel.fixedCategory')
+  }))
+
+  const extendFields = (Array.isArray(enabledFields) ? enabledFields : [])
+    .map((field) => {
+      const key = normalizeText(field?.fields)
+      if (!key) {
+        return null
+      }
+      return {
+        label: normalizeText(field?.fieldsAlise || field?.fieldsAlias || key),
+        path: `extendFields.${key}`,
+        placeholder: `{extendFields.${key}}`,
+        category: $t('pages.basicInfo.whMat.printTemplate.fieldPanel.extendCategory')
+      }
+    })
+    .filter(Boolean)
+
+  return [...fixedFields, ...extendFields]
+}
+
+export function resolveTemplatePlaceholders(template, record = {}) {
+  const source = typeof template === 'string' ? template : String(template ?? '')
+  return source.replace(/\{([^{}]+)\}/g, (_, rawPath) => {
+    const value = getValueByPath(record, normalizeText(rawPath))
+    if (value === null || value === undefined) {
+      return ''
+    }
+    if (typeof value === 'object') {
+      return ''
+    }
+    return String(value)
+  })
+}
+
+export function renderMatnrPrintTemplate(template = {}, record = {}) {
+  const normalizedTemplate = getNormalizedTemplate(template)
+  const elements = normalizedTemplate.elements
+    .filter((element) => element.visible !== false)
+    .map((element) => resolveElement(element, record))
+    .sort((left, right) => normalizeNumber(left?.zIndex) - normalizeNumber(right?.zIndex))
+
+  return {
+    version: normalizedTemplate.version,
+    canvas: normalizedTemplate.canvas,
+    elements
+  }
+}
+
+export function measureMatnrPrintTemplateBounds(template = {}) {
+  return measureNormalizedTemplateBounds(getNormalizedTemplate(template))
+}
+
+export function detectMatnrPrintTemplateOverflow(template = {}) {
+  const normalizedTemplate = getNormalizedTemplate(template)
+  const bounds = measureNormalizedTemplateBounds(normalizedTemplate)
+  const canvasWidth = Math.max(10, normalizeNumber(normalizedTemplate?.canvas?.width, 70))
+  const canvasHeight = Math.max(10, normalizeNumber(normalizedTemplate?.canvas?.height, 40))
+  return {
+    hasOverflow: bounds.width > canvasWidth || bounds.height > canvasHeight,
+    overflowX: bounds.width > canvasWidth,
+    overflowY: bounds.height > canvasHeight,
+    bounds,
+    canvas: {
+      width: canvasWidth,
+      height: canvasHeight
+    }
+  }
+}
+
+function resolveElement(element, record) {
+  const base = {
+    ...cloneDeep(element),
+    x: normalizeNumber(element?.x),
+    y: normalizeNumber(element?.y),
+    w: Math.max(0.5, normalizeNumber(element?.w, 10)),
+    h: Math.max(0.5, normalizeNumber(element?.h, 6)),
+    zIndex: normalizeInteger(element?.zIndex, 1),
+    visible: element?.visible !== false
+  }
+
+  if (base.type === 'text') {
+    const contentTemplate = normalizeText(base.contentTemplate)
+    base.resolvedText =
+      base.contentMode === 'template'
+        ? resolveTemplatePlaceholders(contentTemplate, record)
+        : contentTemplate
+    base.style = {
+      ...TEXT_STYLE_DEFAULTS,
+      ...(base.style || {})
+    }
+    return base
+  }
+
+  if (base.type === 'barcode') {
+    const value = resolveTemplatePlaceholders(base.valueTemplate, record)
+    base.resolvedValue = value
+    base.showText = base.showText !== false
+    base.symbology = 'CODE128'
+    base.svgMarkup = buildBarcodeSvgMarkup(value, {
+      showText: base.showText,
+      widthMm: base.w,
+      heightMm: base.h
+    })
+    return base
+  }
+
+  if (base.type === 'qrcode') {
+    base.resolvedValue = resolveTemplatePlaceholders(base.valueTemplate, record)
+    return base
+  }
+
+  if (base.type === 'line') {
+    base.direction = base.direction === 'vertical' ? 'vertical' : 'horizontal'
+    base.borderWidth = Math.max(0.2, normalizeNumber(base.borderWidth, 0.4))
+    base.color = normalizeColor(base.color, '#111111')
+    return base
+  }
+
+  if (base.type === 'rect') {
+    base.borderWidth = Math.max(0.2, normalizeNumber(base.borderWidth, 0.4))
+    base.borderColor = normalizeColor(base.borderColor, '#111111')
+    base.backgroundColor = normalizeColor(base.backgroundColor, '#FFFFFF')
+    base.radius = Math.max(0, normalizeNumber(base.radius, 0))
+    return base
+  }
+
+  if (base.type === 'table') {
+    base.style = {
+      ...TABLE_STYLE_DEFAULTS,
+      ...(base.style || {})
+    }
+    base.columns = Array.isArray(base.columns) ? base.columns : []
+    base.rows = Array.isArray(base.rows) ? base.rows : []
+    base.cells = Array.isArray(base.cells) ? base.cells : []
+    base.resolvedCells = base.cells.map((cell) => ({
+      ...cell,
+      row: normalizeInteger(cell?.row),
+      col: normalizeInteger(cell?.col),
+      rowspan: Math.max(1, normalizeInteger(cell?.rowspan, 1)),
+      colspan: Math.max(1, normalizeInteger(cell?.colspan, 1)),
+      resolvedText:
+        cell?.contentMode === 'template'
+          ? resolveTemplatePlaceholders(cell?.contentTemplate, record)
+          : normalizeText(cell?.contentTemplate),
+      style: {
+        ...TABLE_STYLE_DEFAULTS,
+        ...(base.style || {}),
+        ...(cell?.style || {})
+      }
+    }))
+    return base
+  }
+
+  return base
+}
+
+function getNormalizedTemplate(template = {}) {
+  if (
+    template &&
+    typeof template === 'object' &&
+    template.canvas &&
+    template.canvas.unit === MM_UNIT &&
+    Array.isArray(template.elements)
+  ) {
+    return template
+  }
+  return normalizeMatnrPrintTemplate(template)
+}
+
+function measureNormalizedTemplateBounds(template = {}) {
+  const canvasWidth = Math.max(10, normalizeNumber(template?.canvas?.width, 70))
+  const canvasHeight = Math.max(10, normalizeNumber(template?.canvas?.height, 40))
+  const bounds = (Array.isArray(template?.elements) ? template.elements : []).reduce(
+    (result, element) => {
+      if (element?.visible === false) {
+        return result
+      }
+      const right = normalizeNumber(element?.x) + normalizeNumber(element?.w)
+      const bottom = normalizeNumber(element?.y) + normalizeNumber(element?.h)
+      return {
+        width: Math.max(result.width, right),
+        height: Math.max(result.height, bottom)
+      }
+    },
+    {
+      width: canvasWidth,
+      height: canvasHeight
+    }
+  )
+  return {
+    width: Number(bounds.width.toFixed(2)),
+    height: Number(bounds.height.toFixed(2))
+  }
+}
+
+export function normalizeMatnrPrintTemplate(template = {}) {
+  const canvasSource =
+    template?.canvas || template?.canvasJson?.canvas
+      ? template?.canvas || template?.canvasJson?.canvas
+      : {}
+  const rawCanvasJson =
+    template?.canvasJson &&
+    typeof template.canvasJson === 'object' &&
+    !Array.isArray(template.canvasJson)
+      ? template.canvasJson
+      : null
+  const source =
+    rawCanvasJson && Array.isArray(rawCanvasJson.elements)
+      ? rawCanvasJson
+      : {
+          version: template?.version,
+          canvas: canvasSource,
+          elements: Array.isArray(template?.elements) ? template.elements : []
+        }
+
+  return {
+    id: template?.id,
+    tenantId: template?.tenantId,
+    name:
+      normalizeText(template?.name) ||
+      $t('pages.basicInfo.whMat.printTemplate.workspace.unnamedTemplate'),
+    code: normalizeText(template?.code) || `MATNR_${Date.now()}`,
+    isDefault: normalizeInteger(template?.isDefault, 0),
+    status: normalizeInteger(template?.status, 1),
+    memo: normalizeText(template?.memo),
+    version: normalizeInteger(source?.version, 1),
+    canvas: {
+      ...DEFAULT_CANVAS,
+      ...(source?.canvas || {}),
+      width: Math.max(10, normalizeNumber(source?.canvas?.width, DEFAULT_CANVAS.width)),
+      height: Math.max(10, normalizeNumber(source?.canvas?.height, DEFAULT_CANVAS.height)),
+      unit: MM_UNIT,
+      backgroundColor: normalizeColor(
+        source?.canvas?.backgroundColor,
+        DEFAULT_CANVAS.backgroundColor
+      ),
+      gridSize: Math.max(1, normalizeNumber(source?.canvas?.gridSize, DEFAULT_CANVAS.gridSize))
+    },
+    elements: (Array.isArray(source?.elements) ? source.elements : []).map((element, index) =>
+      normalizeElement(element, index)
+    )
+  }
+}
+
+function normalizeElement(element = {}, index = 0) {
+  const normalized = {
+    id: normalizeText(element?.id) || createElementId(),
+    type: normalizeText(element?.type) || 'text',
+    x: normalizeNumber(element?.x, 2 + index),
+    y: normalizeNumber(element?.y, 2 + index),
+    w: Math.max(1, normalizeNumber(element?.w, 20)),
+    h: Math.max(1, normalizeNumber(element?.h, 8)),
+    zIndex: normalizeInteger(element?.zIndex, index + 1),
+    visible: element?.visible !== false
+  }
+
+  if (normalized.type === 'text') {
+    normalized.contentMode = element?.contentMode === 'template' ? 'template' : 'static'
+    normalized.contentTemplate = normalizeText(element?.contentTemplate)
+    normalized.style = {
+      ...TEXT_STYLE_DEFAULTS,
+      ...(element?.style || {}),
+      fontSize: Math.max(
+        1.8,
+        normalizeNumber(element?.style?.fontSize, TEXT_STYLE_DEFAULTS.fontSize)
+      ),
+      fontWeight: normalizeInteger(element?.style?.fontWeight, TEXT_STYLE_DEFAULTS.fontWeight),
+      textAlign: ['left', 'center', 'right'].includes(element?.style?.textAlign)
+        ? element.style.textAlign
+        : TEXT_STYLE_DEFAULTS.textAlign,
+      color: normalizeColor(element?.style?.color, TEXT_STYLE_DEFAULTS.color),
+      lineHeight: Math.max(
+        1,
+        normalizeNumber(element?.style?.lineHeight, TEXT_STYLE_DEFAULTS.lineHeight)
+      )
+    }
+    return normalized
+  }
+
+  if (normalized.type === 'barcode') {
+    normalized.valueTemplate = normalizeText(element?.valueTemplate)
+    normalized.showText = element?.showText !== false
+    normalized.symbology = 'CODE128'
+    normalized.h = Math.max(8, normalizeNumber(element?.h, 14))
+    return normalized
+  }
+
+  if (normalized.type === 'qrcode') {
+    normalized.valueTemplate = normalizeText(element?.valueTemplate)
+    normalized.w = Math.max(8, normalizeNumber(element?.w, 18))
+    normalized.h = Math.max(8, normalizeNumber(element?.h, 18))
+    return normalized
+  }
+
+  if (normalized.type === 'line') {
+    normalized.direction = element?.direction === 'vertical' ? 'vertical' : 'horizontal'
+    normalized.borderWidth = Math.max(0.2, normalizeNumber(element?.borderWidth, 0.4))
+    normalized.color = normalizeColor(element?.color, '#111111')
+    normalized.w =
+      normalized.direction === 'horizontal'
+        ? Math.max(6, normalizeNumber(element?.w, 20))
+        : Math.max(0.4, normalizeNumber(element?.w, 0.4))
+    normalized.h =
+      normalized.direction === 'vertical'
+        ? Math.max(6, normalizeNumber(element?.h, 20))
+        : Math.max(0.4, normalizeNumber(element?.h, 0.4))
+    return normalized
+  }
+
+  if (normalized.type === 'rect') {
+    normalized.borderWidth = Math.max(0.2, normalizeNumber(element?.borderWidth, 0.4))
+    normalized.borderColor = normalizeColor(element?.borderColor, '#111111')
+    normalized.backgroundColor = normalizeColor(element?.backgroundColor, '#FFFFFF')
+    normalized.radius = Math.max(0, normalizeNumber(element?.radius, 0))
+    return normalized
+  }
+
+  if (normalized.type === 'table') {
+    normalized.style = {
+      ...TABLE_STYLE_DEFAULTS,
+      ...(element?.style || {}),
+      fontSize: Math.max(
+        1.8,
+        normalizeNumber(element?.style?.fontSize, TABLE_STYLE_DEFAULTS.fontSize)
+      ),
+      fontWeight: normalizeInteger(element?.style?.fontWeight, TABLE_STYLE_DEFAULTS.fontWeight),
+      textAlign: ['left', 'center', 'right'].includes(element?.style?.textAlign)
+        ? element.style.textAlign
+        : TABLE_STYLE_DEFAULTS.textAlign,
+      color: normalizeColor(element?.style?.color, TABLE_STYLE_DEFAULTS.color),
+      borderColor: normalizeColor(element?.style?.borderColor, TABLE_STYLE_DEFAULTS.borderColor),
+      borderWidth: Math.max(
+        0.2,
+        normalizeNumber(element?.style?.borderWidth, TABLE_STYLE_DEFAULTS.borderWidth)
+      ),
+      backgroundColor: normalizeColor(
+        element?.style?.backgroundColor,
+        TABLE_STYLE_DEFAULTS.backgroundColor
+      )
+    }
+    normalized.columns = normalizeTableColumns(element?.columns, normalized.w)
+    normalized.rows = normalizeTableRows(element?.rows)
+    normalized.h = Math.max(
+      normalizeNumber(normalized.h, 0),
+      normalized.rows.reduce((total, row) => total + normalizeNumber(row?.height, 0), 0)
+    )
+    normalized.cells = normalizeTableCells(element?.cells)
+    return normalized
+  }
+
+  return normalized
+}
+
+function normalizeTableColumns(columns = [], totalWidth = 40) {
+  const source =
+    Array.isArray(columns) && columns.length
+      ? columns
+      : [{ width: totalWidth * 0.36 }, { width: totalWidth * 0.64 }]
+  return source.map((column) => ({
+    width: Math.max(6, normalizeNumber(column?.width, 18))
+  }))
+}
+
+function normalizeTableRows(rows = []) {
+  const source = Array.isArray(rows) && rows.length ? rows : [{ height: 6 }]
+  return source.map((row) => ({
+    height: Math.max(4, normalizeNumber(row?.height, 6))
+  }))
+}
+
+function normalizeTableCells(cells = []) {
+  return (Array.isArray(cells) ? cells : []).map((cell) => ({
+    row: normalizeInteger(cell?.row),
+    col: normalizeInteger(cell?.col),
+    rowspan: Math.max(1, normalizeInteger(cell?.rowspan, 1)),
+    colspan: Math.max(1, normalizeInteger(cell?.colspan, 1)),
+    contentMode: cell?.contentMode === 'template' ? 'template' : 'static',
+    contentTemplate: normalizeText(cell?.contentTemplate),
+    style: {
+      ...TABLE_STYLE_DEFAULTS,
+      ...(cell?.style || {})
+    }
+  }))
+}
+
+export function buildMatnrPrintTemplatePayload(template = {}) {
+  const normalized = normalizeMatnrPrintTemplate(template)
+  return {
+    ...(normalized.id ? { id: normalized.id } : {}),
+    name: normalized.name,
+    code: normalized.code,
+    isDefault: normalized.isDefault,
+    status: normalized.status,
+    memo: normalized.memo,
+    canvasJson: {
+      version: normalized.version,
+      canvas: normalized.canvas,
+      elements: normalized.elements
+    }
+  }
+}
+
+export function createDefaultMatnrPrintTemplate(seed = {}) {
+  const name =
+    normalizeText(seed?.name) ||
+    $t('pages.basicInfo.whMat.printTemplate.helpers.defaultTemplateName')
+  const code = normalizeText(seed?.code) || `MATNR_${Date.now()}`
+  return normalizeMatnrPrintTemplate({
+    name,
+    code,
+    status: 1,
+    isDefault: seed?.isDefault ? 1 : 0,
+    memo: '',
+    canvasJson: {
+      version: 1,
+      canvas: {
+        ...DEFAULT_CANVAS
+      },
+      elements: [
+        {
+          id: createElementId(),
+          type: 'text',
+          x: 4,
+          y: 3,
+          w: 18,
+          h: 5,
+          zIndex: 1,
+          visible: true,
+          contentMode: 'static',
+          contentTemplate: $t(
+            'pages.basicInfo.whMat.printTemplate.helpers.defaultText.productCode'
+          ),
+          style: {
+            ...TEXT_STYLE_DEFAULTS,
+            fontSize: 3.1,
+            fontWeight: 600,
+            textAlign: 'center'
+          }
+        },
+        {
+          id: createElementId(),
+          type: 'barcode',
+          x: 24,
+          y: 3,
+          w: 40,
+          h: 14,
+          zIndex: 2,
+          visible: true,
+          valueTemplate: '{code}',
+          showText: true,
+          symbology: 'CODE128'
+        },
+        {
+          id: createElementId(),
+          type: 'text',
+          x: 4,
+          y: 20,
+          w: 12,
+          h: 5,
+          zIndex: 3,
+          visible: true,
+          contentMode: 'static',
+          contentTemplate: $t(
+            'pages.basicInfo.whMat.printTemplate.helpers.defaultText.productName'
+          ),
+          style: {
+            ...TEXT_STYLE_DEFAULTS,
+            fontSize: 3,
+            fontWeight: 600
+          }
+        },
+        {
+          id: createElementId(),
+          type: 'text',
+          x: 17,
+          y: 20,
+          w: 47,
+          h: 5,
+          zIndex: 4,
+          visible: true,
+          contentMode: 'template',
+          contentTemplate: '{name}',
+          style: {
+            ...TEXT_STYLE_DEFAULTS,
+            fontSize: 3,
+            fontWeight: 500
+          }
+        }
+      ]
+    }
+  })
+}
+
+export function duplicateMatnrPrintTemplate(template = {}) {
+  const normalized = normalizeMatnrPrintTemplate(template)
+  return normalizeMatnrPrintTemplate({
+    ...cloneDeep(normalized),
+    id: null,
+    name: `${normalized.name}${$t('pages.basicInfo.whMat.printTemplate.helpers.copySuffix')}`,
+    code: `${normalized.code}_COPY_${Date.now()}`,
+    isDefault: 0,
+    canvasJson: {
+      version: normalized.version,
+      canvas: normalized.canvas,
+      elements: normalized.elements.map((element) => ({
+        ...cloneDeep(element),
+        id: createElementId()
+      }))
+    }
+  })
+}
+
+export function createElementByType(type, offsetIndex = 0) {
+  const offset = offsetIndex * 2
+  const common = {
+    id: createElementId(),
+    type,
+    x: 4 + offset,
+    y: 4 + offset,
+    zIndex: offsetIndex + 1,
+    visible: true
+  }
+
+  if (type === 'text') {
+    return normalizeElement({
+      ...common,
+      w: 24,
+      h: 6,
+      contentMode: 'static',
+      contentTemplate: $t('pages.basicInfo.whMat.printTemplate.helpers.defaultText.staticText'),
+      style: {
+        ...TEXT_STYLE_DEFAULTS
+      }
+    })
+  }
+
+  if (type === 'barcode') {
+    return normalizeElement({
+      ...common,
+      w: 34,
+      h: 14,
+      valueTemplate: '{code}',
+      showText: true,
+      symbology: 'CODE128'
+    })
+  }
+
+  if (type === 'qrcode') {
+    return normalizeElement({
+      ...common,
+      w: 20,
+      h: 20,
+      valueTemplate: '{barcode}'
+    })
+  }
+
+  if (type === 'line') {
+    return normalizeElement({
+      ...common,
+      w: 24,
+      h: 0.4,
+      direction: 'horizontal',
+      borderWidth: 0.4,
+      color: '#111111'
+    })
+  }
+
+  if (type === 'rect') {
+    return normalizeElement({
+      ...common,
+      w: 24,
+      h: 12,
+      borderWidth: 0.4,
+      borderColor: '#111111',
+      backgroundColor: '#FFFFFF',
+      radius: 0
+    })
+  }
+
+  if (type === 'table') {
+    return createFieldListTableElement(offsetIndex)
+  }
+
+  return normalizeElement(common)
+}
+
+export function createFieldListTableElement(offsetIndex = 0) {
+  const x = 4 + offsetIndex * 2
+  const y = 4 + offsetIndex * 2
+  return normalizeElement({
+    id: createElementId(),
+    type: 'table',
+    x,
+    y,
+    w: 58,
+    h: 24,
+    zIndex: offsetIndex + 1,
+    visible: true,
+    style: {
+      ...TABLE_STYLE_DEFAULTS,
+      fontSize: 2.8
+    },
+    columns: [{ width: 18 }, { width: 40 }],
+    rows: [{ height: 6 }, { height: 6 }, { height: 6 }, { height: 6 }],
+    cells: [
+      {
+        row: 0,
+        col: 0,
+        contentMode: 'static',
+        contentTemplate: $t('pages.basicInfo.whMat.printTemplate.helpers.tableLabels.code')
+      },
+      { row: 0, col: 1, contentMode: 'template', contentTemplate: '{code}' },
+      {
+        row: 1,
+        col: 0,
+        contentMode: 'static',
+        contentTemplate: $t('pages.basicInfo.whMat.printTemplate.helpers.tableLabels.name')
+      },
+      { row: 1, col: 1, contentMode: 'template', contentTemplate: '{name}' },
+      {
+        row: 2,
+        col: 0,
+        contentMode: 'static',
+        contentTemplate: $t('pages.basicInfo.whMat.printTemplate.helpers.tableLabels.spec')
+      },
+      { row: 2, col: 1, contentMode: 'template', contentTemplate: '{spec}' },
+      {
+        row: 3,
+        col: 0,
+        contentMode: 'static',
+        contentTemplate: $t('pages.basicInfo.whMat.printTemplate.helpers.tableLabels.model')
+      },
+      { row: 3, col: 1, contentMode: 'template', contentTemplate: '{model}' }
+    ]
+  })
+}
+
+export function getFieldListTableRows(element = {}) {
+  if (element?.type !== 'table') {
+    return []
+  }
+  const rows = Array.isArray(element?.rows) ? element.rows : []
+  const cells = Array.isArray(element?.cells) ? element.cells : []
+  return rows.map((row, rowIndex) => {
+    const labelCell = cells.find(
+      (cell) => normalizeInteger(cell?.row) === rowIndex && normalizeInteger(cell?.col) === 0
+    )
+    const valueCell = cells.find(
+      (cell) => normalizeInteger(cell?.row) === rowIndex && normalizeInteger(cell?.col) === 1
+    )
+    return {
+      id: `${element.id}_row_${rowIndex}`,
+      height: Math.max(4, normalizeNumber(row?.height, 6)),
+      labelTemplate: normalizeText(labelCell?.contentTemplate),
+      labelMode: labelCell?.contentMode === 'template' ? 'template' : 'static',
+      valueTemplate: normalizeText(valueCell?.contentTemplate),
+      valueMode: valueCell?.contentMode === 'static' ? 'static' : 'template'
+    }
+  })
+}
+
+export function updateFieldListTableRows(element = {}, rowConfigs = []) {
+  const sourceRows = Array.isArray(rowConfigs) && rowConfigs.length ? rowConfigs : []
+  return normalizeElement({
+    ...cloneDeep(element),
+    rows: sourceRows.map((row) => ({
+      height: Math.max(4, normalizeNumber(row?.height, 6))
+    })),
+    columns: normalizeTableColumns(element?.columns, element?.w),
+    cells: sourceRows.flatMap((row, index) => [
+      {
+        row: index,
+        col: 0,
+        contentMode: row?.labelMode === 'template' ? 'template' : 'static',
+        contentTemplate: normalizeText(row?.labelTemplate)
+      },
+      {
+        row: index,
+        col: 1,
+        contentMode: row?.valueMode === 'static' ? 'static' : 'template',
+        contentTemplate: normalizeText(row?.valueTemplate)
+      }
+    ])
+  })
+}
+
+export function appendFieldPlaceholder(originValue, placeholder) {
+  const currentValue = String(originValue ?? '')
+  return `${currentValue}${placeholder}`
+}
+
+function getValueByPath(source, path) {
+  if (!path) {
+    return ''
+  }
+  return path.split('.').reduce((current, key) => {
+    if (current === null || current === undefined) {
+      return ''
+    }
+    return current[key]
+  }, source)
+}
+
+export function buildBarcodeSvgMarkup(value, options = {}) {
+  const codeValue = normalizeText(value)
+  if (!codeValue || typeof document === 'undefined') {
+    return ''
+  }
+  try {
+    const svgNode = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+    const widthMm = Math.max(8, normalizeNumber(options?.widthMm, 20))
+    const heightMm = Math.max(8, normalizeNumber(options?.heightMm, 12))
+    const fontSize = Math.max(12, normalizeNumber(options?.fontSize, 12))
+    const targetHeightPx = Math.max(32, heightMm * 4)
+    const textReservePx = options?.showText !== false ? Math.max(fontSize + 8, 18) : 0
+    JsBarcode(svgNode, codeValue, {
+      format: 'CODE128',
+      displayValue: options?.showText !== false,
+      width: Math.max(1, normalizeNumber(options?.lineWidth, 1.4)),
+      height: Math.max(12, targetHeightPx - textReservePx),
+      margin: 0,
+      background: '#FFFFFF',
+      lineColor: '#111111',
+      font: 'Arial',
+      fontSize,
+      textMargin: 2
+    })
+    svgNode.setAttribute('width', `${widthMm}mm`)
+    svgNode.setAttribute('height', `${heightMm}mm`)
+    svgNode.setAttribute('preserveAspectRatio', 'none')
+    return svgNode.outerHTML
+  } catch (error) {
+    console.warn('[matnr-print] barcode render failed', error)
+    return ''
+  }
+}
diff --git a/rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-canvas.vue b/rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-canvas.vue
new file mode 100644
index 0000000..9a3f530
--- /dev/null
+++ b/rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-canvas.vue
@@ -0,0 +1,571 @@
+<template>
+  <div class="matnr-print-canvas-shell">
+    <div
+      ref="canvasRef"
+      class="matnr-print-canvas"
+      :class="{
+        'matnr-print-canvas--editor': editorMode,
+        'matnr-print-canvas--interactive': interactive,
+        'matnr-print-canvas--grid': showGrid && editorMode
+      }"
+      :style="canvasStyle"
+      @mousedown.self="handleCanvasMouseDown"
+    >
+      <div
+        v-for="element in renderedTemplate.elements"
+        :key="element.id"
+        class="matnr-print-element"
+        :class="{
+          'is-selected': interactive && selectedElementId === element.id,
+          'is-hidden': element.visible === false
+        }"
+        :style="getElementBoxStyle(element)"
+        @mousedown.stop="handleElementMouseDown($event, element)"
+      >
+        <div
+          v-if="element.type === 'text'"
+          class="matnr-print-element__text"
+          :style="getTextStyle(element)"
+        >
+          {{ element.resolvedText }}
+        </div>
+
+        <div
+          v-else-if="element.type === 'barcode'"
+          class="matnr-print-element__barcode"
+          v-html="element.svgMarkup"
+        ></div>
+
+        <div v-else-if="element.type === 'qrcode'" class="matnr-print-element__qrcode">
+          <QrcodeVue
+            :value="element.resolvedValue || ' '"
+            render-as="svg"
+            level="M"
+            :size="getQrcodeSize(element)"
+            :margin="0"
+          />
+        </div>
+
+        <div
+          v-else-if="element.type === 'line'"
+          class="matnr-print-element__line"
+          :style="getLineStyle(element)"
+        ></div>
+
+        <div
+          v-else-if="element.type === 'rect'"
+          class="matnr-print-element__rect"
+          :style="getRectStyle(element)"
+        ></div>
+
+        <table
+          v-else-if="element.type === 'table'"
+          class="matnr-print-element__table"
+          :style="getTableStyle(element)"
+        >
+          <colgroup>
+            <col
+              v-for="(column, columnIndex) in element.columns"
+              :key="`${element.id}_col_${columnIndex}`"
+              :style="{ width: getUnitValue(column.width || 10) }"
+            />
+          </colgroup>
+          <tbody>
+            <tr
+              v-for="(row, rowIndex) in element.rows"
+              :key="`${element.id}_row_${rowIndex}`"
+              :style="{ height: getUnitValue(row.height || 6) }"
+            >
+              <template
+                v-for="cell in getTableCellsForRow(element, rowIndex)"
+                :key="`${element.id}_${rowIndex}_${cell.col}`"
+              >
+                <td
+                  :colspan="cell.colspan || 1"
+                  :rowspan="cell.rowspan || 1"
+                  :style="getTableCellStyle(cell, element)"
+                >
+                  <div
+                    class="matnr-print-element__table-cell-content"
+                    :style="getTableCellContentStyle(cell, element)"
+                  >
+                    {{ cell.resolvedText }}
+                  </div>
+                </td>
+              </template>
+            </tr>
+          </tbody>
+        </table>
+
+        <template v-if="interactive && selectedElementId === element.id && element.type !== 'line'">
+          <span
+            v-for="handle in resizeHandles"
+            :key="handle.direction"
+            class="matnr-print-element__handle"
+            :class="`is-${handle.direction}`"
+            @mousedown.stop="handleResizeMouseDown($event, element, handle.direction)"
+          ></span>
+        </template>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+  import { computed, onBeforeUnmount, ref } from 'vue'
+  import QrcodeVue from 'qrcode.vue'
+  import {
+    mmToPx,
+    normalizeMatnrPrintTemplate,
+    renderMatnrPrintTemplate
+  } from '../matnrPrintTemplate.helpers'
+
+  defineOptions({ name: 'MatnrPrintCanvas' })
+
+  const props = defineProps({
+    template: {
+      type: Object,
+      default: () => ({})
+    },
+    activeRecord: {
+      type: Object,
+      default: () => ({})
+    },
+    selectedElementId: {
+      type: String,
+      default: ''
+    },
+    mode: {
+      type: String,
+      default: 'editor'
+    },
+    scale: {
+      type: Number,
+      default: 4
+    },
+    interactive: {
+      type: Boolean,
+      default: false
+    },
+    showGrid: {
+      type: Boolean,
+      default: false
+    }
+  })
+
+  const emit = defineEmits(['select-element', 'update-element'])
+
+  const canvasRef = ref(null)
+  const interactionState = ref(null)
+  const resizeHandles = [
+    { direction: 'top-left' },
+    { direction: 'top' },
+    { direction: 'top-right' },
+    { direction: 'right' },
+    { direction: 'bottom-right' },
+    { direction: 'bottom' },
+    { direction: 'bottom-left' },
+    { direction: 'left' }
+  ]
+
+  const editorMode = computed(() => props.mode === 'editor')
+  const normalizedTemplate = computed(() => normalizeMatnrPrintTemplate(props.template))
+  const renderedTemplate = computed(() =>
+    renderMatnrPrintTemplate(normalizedTemplate.value, props.activeRecord || {})
+  )
+
+  const canvasStyle = computed(() => {
+    const canvas = renderedTemplate.value.canvas
+    const width = editorMode.value ? mmToPx(canvas.width, props.scale) : `${canvas.width}mm`
+    const height = editorMode.value ? mmToPx(canvas.height, props.scale) : `${canvas.height}mm`
+    const gridSize = `${canvas.gridSize * props.scale}px`
+    return {
+      width,
+      height,
+      backgroundColor: canvas.backgroundColor || '#FFFFFF',
+      '--matnr-print-grid-size': gridSize
+    }
+  })
+
+  function getUnitValue(value) {
+    return editorMode.value ? mmToPx(value, props.scale) : `${value}mm`
+  }
+
+  function getElementBoxStyle(element) {
+    return {
+      left: getUnitValue(element.x),
+      top: getUnitValue(element.y),
+      width: getUnitValue(element.w),
+      height: getUnitValue(element.h),
+      zIndex: element.zIndex
+    }
+  }
+
+  function getTextStyle(element) {
+    const style = element.style || {}
+    return {
+      fontSize: editorMode.value ? mmToPx(style.fontSize, props.scale) : `${style.fontSize}mm`,
+      fontWeight: style.fontWeight,
+      color: style.color,
+      textAlign: style.textAlign,
+      lineHeight: style.lineHeight
+    }
+  }
+
+  function getLineStyle(element) {
+    const borderSize = editorMode.value
+      ? mmToPx(element.borderWidth || 0.4, props.scale)
+      : `${element.borderWidth || 0.4}mm`
+    return {
+      backgroundColor: element.color || '#111111',
+      height: element.direction === 'horizontal' ? borderSize : editorMode.value ? '100%' : '100%',
+      width: element.direction === 'vertical' ? borderSize : editorMode.value ? '100%' : '100%'
+    }
+  }
+
+  function getRectStyle(element) {
+    return {
+      borderWidth: editorMode.value
+        ? mmToPx(element.borderWidth || 0.4, props.scale)
+        : `${element.borderWidth || 0.4}mm`,
+      borderStyle: 'solid',
+      borderColor: element.borderColor || '#111111',
+      backgroundColor: element.backgroundColor || '#FFFFFF',
+      borderRadius: editorMode.value
+        ? mmToPx(element.radius || 0, props.scale)
+        : `${element.radius || 0}mm`
+    }
+  }
+
+  function getTableStyle(element) {
+    const style = element.style || {}
+    return {
+      fontSize: editorMode.value ? mmToPx(style.fontSize, props.scale) : `${style.fontSize}mm`,
+      color: style.color,
+      tableLayout: 'fixed'
+    }
+  }
+
+  function getTableCellSpanHeight(element, cell) {
+    const rows = Array.isArray(element?.rows) ? element.rows : []
+    const startRow = Number(cell?.row) || 0
+    const rowspan = Math.max(1, Number(cell?.rowspan) || 1)
+    return rows
+      .slice(startRow, startRow + rowspan)
+      .reduce((total, row) => total + (Number(row?.height) || 6), 0)
+  }
+
+  function getTableCellStyle(cell, element) {
+    const style = cell.style || {}
+    return {
+      border: `${editorMode.value ? mmToPx(style.borderWidth || 0.2, props.scale) : `${style.borderWidth || 0.2}mm`} solid ${style.borderColor || '#111111'}`,
+      backgroundColor: style.backgroundColor || '#FFFFFF',
+      textAlign: style.textAlign || 'left',
+      fontWeight: style.fontWeight || 400,
+      height: getUnitValue(getTableCellSpanHeight(element, cell)),
+      padding: 0,
+      overflow: 'hidden'
+    }
+  }
+
+  function getTableCellContentStyle(cell, element) {
+    const paddingY = editorMode.value ? `${0.4 * props.scale}px` : '0.4mm'
+    const paddingX = editorMode.value ? `${0.8 * props.scale}px` : '0.8mm'
+    return {
+      width: '100%',
+      height: getUnitValue(getTableCellSpanHeight(element, cell)),
+      boxSizing: 'border-box',
+      padding: `${paddingY} ${paddingX}`,
+      overflow: 'hidden',
+      display: 'flex',
+      alignItems: 'center',
+      whiteSpace: 'pre-wrap',
+      wordBreak: 'break-word'
+    }
+  }
+
+  function getTableCellsForRow(element, rowIndex) {
+    return (Array.isArray(element?.resolvedCells) ? element.resolvedCells : []).filter(
+      (cell) => Number(cell?.row) === rowIndex
+    )
+  }
+
+  function getQrcodeSize(element) {
+    const sizeMm = Math.max(element.w || 12, element.h || 12)
+    return Math.max(48, sizeMm * (editorMode.value ? props.scale : 4))
+  }
+
+  function handleCanvasMouseDown() {
+    if (!props.interactive) {
+      return
+    }
+    emit('select-element', '')
+  }
+
+  function handleElementMouseDown(event, element) {
+    if (!props.interactive) {
+      return
+    }
+    emit('select-element', element.id)
+    if (element.type === 'line') {
+      return startInteraction(event, element, 'drag')
+    }
+    if (event.target?.classList?.contains('matnr-print-element__handle')) {
+      return
+    }
+    startInteraction(event, element, 'drag')
+  }
+
+  function handleResizeMouseDown(event, element, direction) {
+    if (!props.interactive) {
+      return
+    }
+    emit('select-element', element.id)
+    startInteraction(event, element, 'resize', direction)
+  }
+
+  function startInteraction(event, element, type, direction = '') {
+    interactionState.value = {
+      type,
+      direction,
+      elementId: element.id,
+      startX: event.clientX,
+      startY: event.clientY,
+      origin: {
+        x: Number(element.x) || 0,
+        y: Number(element.y) || 0,
+        w: Number(element.w) || 0,
+        h: Number(element.h) || 0
+      }
+    }
+    window.addEventListener('mousemove', handleWindowMouseMove)
+    window.addEventListener('mouseup', stopInteraction)
+  }
+
+  function handleWindowMouseMove(event) {
+    if (!interactionState.value) {
+      return
+    }
+    const dx = snapMm((event.clientX - interactionState.value.startX) / props.scale)
+    const dy = snapMm((event.clientY - interactionState.value.startY) / props.scale)
+    const origin = interactionState.value.origin
+    let patch = {}
+
+    if (interactionState.value.type === 'drag') {
+      patch = {
+        x: Math.max(0, origin.x + dx),
+        y: Math.max(0, origin.y + dy)
+      }
+    } else if (interactionState.value.type === 'resize') {
+      patch = buildResizePatch(origin, dx, dy, interactionState.value.direction)
+    }
+
+    emit('update-element', {
+      id: interactionState.value.elementId,
+      patch
+    })
+  }
+
+  function buildResizePatch(origin, dx, dy, direction) {
+    const next = {
+      x: origin.x,
+      y: origin.y,
+      w: origin.w,
+      h: origin.h
+    }
+
+    if (direction.includes('left')) {
+      next.x = Math.max(0, origin.x + dx)
+      next.w = Math.max(2, origin.w - dx)
+    }
+    if (direction.includes('right')) {
+      next.w = Math.max(2, origin.w + dx)
+    }
+    if (direction.includes('top')) {
+      next.y = Math.max(0, origin.y + dy)
+      next.h = Math.max(2, origin.h - dy)
+    }
+    if (direction.includes('bottom')) {
+      next.h = Math.max(2, origin.h + dy)
+    }
+
+    if (next.w === 2 && direction.includes('left')) {
+      next.x = origin.x + origin.w - 2
+    }
+    if (next.h === 2 && direction.includes('top')) {
+      next.y = origin.y + origin.h - 2
+    }
+
+    return next
+  }
+
+  function stopInteraction() {
+    interactionState.value = null
+    window.removeEventListener('mousemove', handleWindowMouseMove)
+    window.removeEventListener('mouseup', stopInteraction)
+  }
+
+  function snapMm(value) {
+    return Math.round(Number(value || 0))
+  }
+
+  onBeforeUnmount(() => {
+    stopInteraction()
+  })
+</script>
+
+<style scoped>
+  .matnr-print-canvas-shell {
+    width: 100%;
+    min-width: 100%;
+    min-height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    overflow: auto;
+  }
+
+  .matnr-print-canvas {
+    position: relative;
+    box-sizing: border-box;
+    flex: none;
+    margin: 0 auto;
+    box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
+  }
+
+  .matnr-print-canvas--editor {
+    border: 1px solid rgba(148, 163, 184, 0.45);
+  }
+
+  .matnr-print-canvas--grid {
+    background-image:
+      linear-gradient(to right, rgba(148, 163, 184, 0.2) 1px, transparent 1px),
+      linear-gradient(to bottom, rgba(148, 163, 184, 0.2) 1px, transparent 1px);
+    background-size: var(--matnr-print-grid-size) var(--matnr-print-grid-size);
+  }
+
+  .matnr-print-element {
+    position: absolute;
+    box-sizing: border-box;
+    overflow: hidden;
+    user-select: none;
+  }
+
+  .matnr-print-element.is-selected {
+    outline: 1px solid #2563eb;
+    outline-offset: 0;
+  }
+
+  .matnr-print-element__text,
+  .matnr-print-element__barcode,
+  .matnr-print-element__qrcode,
+  .matnr-print-element__rect,
+  .matnr-print-element__table {
+    width: 100%;
+    height: 100%;
+  }
+
+  .matnr-print-element__text {
+    display: flex;
+    align-items: center;
+    word-break: break-word;
+    white-space: pre-wrap;
+  }
+
+  .matnr-print-element__barcode :deep(svg) {
+    width: 100%;
+    height: 100%;
+    display: block;
+  }
+
+  .matnr-print-element__qrcode {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .matnr-print-element__qrcode :deep(svg) {
+    width: 100%;
+    height: 100%;
+  }
+
+  .matnr-print-element__line {
+    position: absolute;
+    inset: 0;
+  }
+
+  .matnr-print-element__rect {
+    box-sizing: border-box;
+  }
+
+  .matnr-print-element__table {
+    border-collapse: collapse;
+  }
+
+  .matnr-print-element__table td {
+    box-sizing: border-box;
+    vertical-align: middle;
+  }
+
+  .matnr-print-element__table-cell-content {
+    box-sizing: border-box;
+  }
+
+  .matnr-print-element__handle {
+    position: absolute;
+    width: 10px;
+    height: 10px;
+    border: 1px solid #2563eb;
+    background: #ffffff;
+    border-radius: 50%;
+    z-index: 3;
+  }
+
+  .matnr-print-element__handle.is-top-left {
+    top: -5px;
+    left: -5px;
+    cursor: nwse-resize;
+  }
+
+  .matnr-print-element__handle.is-top {
+    top: -5px;
+    left: calc(50% - 5px);
+    cursor: ns-resize;
+  }
+
+  .matnr-print-element__handle.is-top-right {
+    top: -5px;
+    right: -5px;
+    cursor: nesw-resize;
+  }
+
+  .matnr-print-element__handle.is-right {
+    top: calc(50% - 5px);
+    right: -5px;
+    cursor: ew-resize;
+  }
+
+  .matnr-print-element__handle.is-bottom-right {
+    right: -5px;
+    bottom: -5px;
+    cursor: nwse-resize;
+  }
+
+  .matnr-print-element__handle.is-bottom {
+    left: calc(50% - 5px);
+    bottom: -5px;
+    cursor: ns-resize;
+  }
+
+  .matnr-print-element__handle.is-bottom-left {
+    left: -5px;
+    bottom: -5px;
+    cursor: nesw-resize;
+  }
+
+  .matnr-print-element__handle.is-left {
+    top: calc(50% - 5px);
+    left: -5px;
+    cursor: ew-resize;
+  }
+</style>
diff --git a/rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-field-panel.vue b/rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-field-panel.vue
new file mode 100644
index 0000000..3f5315a
--- /dev/null
+++ b/rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-field-panel.vue
@@ -0,0 +1,88 @@
+<template>
+  <div class="matnr-print-field-panel">
+    <div class="matnr-print-field-panel__title">{{
+      t('pages.basicInfo.whMat.printTemplate.fieldPanel.title')
+    }}</div>
+    <ElScrollbar max-height="220px">
+      <div class="matnr-print-field-panel__list">
+        <button
+          v-for="field in fields"
+          :key="field.path"
+          type="button"
+          class="matnr-print-field-panel__item"
+          @click="$emit('insert-field', field.placeholder)"
+        >
+          <span>{{ field.label }}</span>
+          <small>{{ field.placeholder }}</small>
+        </button>
+      </div>
+    </ElScrollbar>
+  </div>
+</template>
+
+<script setup>
+  import { useI18n } from 'vue-i18n'
+
+  defineOptions({ name: 'MatnrPrintFieldPanel' })
+
+  const { t } = useI18n()
+
+  defineProps({
+    fields: {
+      type: Array,
+      default: () => []
+    }
+  })
+
+  defineEmits(['insert-field'])
+</script>
+
+<style scoped>
+  .matnr-print-field-panel {
+    display: flex;
+    flex-direction: column;
+    flex-shrink: 0;
+    gap: 10px;
+    padding: 12px;
+    border-top: 1px solid rgba(148, 163, 184, 0.18);
+  }
+
+  .matnr-print-field-panel__title {
+    font-size: 13px;
+    font-weight: 600;
+    color: var(--art-text-primary);
+  }
+
+  .matnr-print-field-panel__list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+  }
+
+  .matnr-print-field-panel__item {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 4px;
+    width: 100%;
+    padding: 8px 10px;
+    border: 1px solid rgba(148, 163, 184, 0.24);
+    border-radius: 10px;
+    background: #ffffff;
+    color: var(--art-text-primary);
+    cursor: pointer;
+    transition:
+      border-color 0.2s ease,
+      transform 0.2s ease;
+  }
+
+  .matnr-print-field-panel__item:hover {
+    border-color: rgba(37, 99, 235, 0.45);
+    transform: translateY(-1px);
+  }
+
+  .matnr-print-field-panel__item small {
+    color: var(--art-text-secondary);
+    font-size: 12px;
+  }
+</style>
diff --git a/rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-property-panel.vue b/rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-property-panel.vue
new file mode 100644
index 0000000..cec4625
--- /dev/null
+++ b/rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-property-panel.vue
@@ -0,0 +1,859 @@
+<template>
+  <div class="matnr-print-property-panel">
+    <ElScrollbar height="100%">
+      <div class="matnr-print-property-panel__content">
+        <section class="matnr-print-property-panel__section">
+          <div class="matnr-print-property-panel__section-title">
+            {{ t('pages.basicInfo.whMat.printTemplate.propertyPanel.sections.templateInfo') }}
+          </div>
+          <div class="matnr-print-property-panel__grid">
+            <label>
+              <span>{{
+                t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.templateName')
+              }}</span>
+              <ElInput
+                :model-value="template.name"
+                @update:model-value="updateTemplateMeta('name', $event)"
+              />
+            </label>
+            <label>
+              <span>{{
+                t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.templateCode')
+              }}</span>
+              <ElInput
+                :model-value="template.code"
+                @update:model-value="updateTemplateMeta('code', $event)"
+              />
+            </label>
+            <label>
+              <span>{{ t('table.status') }}</span>
+              <ElSelect
+                :model-value="template.status"
+                @update:model-value="updateTemplateMeta('status', Number($event))"
+              >
+                <ElOption :value="1" :label="t('common.status.normal')" />
+                <ElOption :value="0" :label="t('common.status.frozen')" />
+              </ElSelect>
+            </label>
+            <label class="matnr-print-property-panel__switch">
+              <span>{{
+                t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.defaultTemplate')
+              }}</span>
+              <ElSwitch
+                :model-value="template.isDefault === 1"
+                @update:model-value="updateTemplateMeta('isDefault', $event ? 1 : 0)"
+              />
+            </label>
+            <label class="matnr-print-property-panel__span-2">
+              <span>{{ t('table.memo') }}</span>
+              <ElInput
+                type="textarea"
+                :rows="2"
+                :model-value="template.memo"
+                @update:model-value="updateTemplateMeta('memo', $event)"
+              />
+            </label>
+          </div>
+        </section>
+
+        <section class="matnr-print-property-panel__section">
+          <div class="matnr-print-property-panel__section-title">
+            {{ t('pages.basicInfo.whMat.printTemplate.propertyPanel.sections.canvas') }}
+          </div>
+          <div class="matnr-print-property-panel__grid">
+            <label>
+              <span>{{
+                t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.widthMm')
+              }}</span>
+              <ElInputNumber
+                :model-value="template.canvas?.width"
+                :min="10"
+                :step="1"
+                controls-position="right"
+                @update:model-value="updateCanvas('width', $event)"
+              />
+            </label>
+            <label>
+              <span>{{
+                t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.heightMm')
+              }}</span>
+              <ElInputNumber
+                :model-value="template.canvas?.height"
+                :min="10"
+                :step="1"
+                controls-position="right"
+                @update:model-value="updateCanvas('height', $event)"
+              />
+            </label>
+            <label>
+              <span>{{
+                t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.backgroundColor')
+              }}</span>
+              <ElColorPicker
+                :model-value="template.canvas?.backgroundColor"
+                @change="updateCanvas('backgroundColor', $event)"
+              />
+            </label>
+            <label>
+              <span>{{
+                t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.gridSizeMm')
+              }}</span>
+              <ElInputNumber
+                :model-value="template.canvas?.gridSize"
+                :min="1"
+                :step="1"
+                controls-position="right"
+                @update:model-value="updateCanvas('gridSize', $event)"
+              />
+            </label>
+          </div>
+        </section>
+
+        <section class="matnr-print-property-panel__section">
+          <div class="matnr-print-property-panel__section-header">
+            <div class="matnr-print-property-panel__section-title">
+              {{ t('pages.basicInfo.whMat.printTemplate.propertyPanel.sections.element') }}
+            </div>
+            <ElButton
+              v-if="selectedElement"
+              type="danger"
+              text
+              @click="$emit('remove-element', selectedElement.id)"
+            >
+              {{ t('pages.basicInfo.whMat.printTemplate.propertyPanel.actions.removeElement') }}
+            </ElButton>
+          </div>
+
+          <div v-if="!selectedElement" class="matnr-print-property-panel__empty">
+            {{ t('pages.basicInfo.whMat.printTemplate.propertyPanel.empty') }}
+          </div>
+
+          <template v-else>
+            <div class="matnr-print-property-panel__grid">
+              <label>
+                <span>{{
+                  t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.elementType')
+                }}</span>
+                <ElInput :model-value="selectedElement.type" disabled />
+              </label>
+              <label class="matnr-print-property-panel__switch">
+                <span>{{
+                  t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.visible')
+                }}</span>
+                <ElSwitch
+                  :model-value="selectedElement.visible !== false"
+                  @update:model-value="updateElement('visible', $event)"
+                />
+              </label>
+              <label>
+                <span>X(mm)</span>
+                <ElInputNumber
+                  :model-value="selectedElement.x"
+                  :min="0"
+                  controls-position="right"
+                  @update:model-value="updateElement('x', $event)"
+                />
+              </label>
+              <label>
+                <span>Y(mm)</span>
+                <ElInputNumber
+                  :model-value="selectedElement.y"
+                  :min="0"
+                  controls-position="right"
+                  @update:model-value="updateElement('y', $event)"
+                />
+              </label>
+              <label>
+                <span>{{
+                  t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.widthMm')
+                }}</span>
+                <ElInputNumber
+                  :model-value="selectedElement.w"
+                  :min="1"
+                  controls-position="right"
+                  @update:model-value="updateElement('w', $event)"
+                />
+              </label>
+              <label>
+                <span>{{
+                  t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.heightMm')
+                }}</span>
+                <ElInputNumber
+                  :model-value="selectedElement.h"
+                  :min="0.4"
+                  :step="0.2"
+                  controls-position="right"
+                  @update:model-value="updateElement('h', $event)"
+                />
+              </label>
+              <label>
+                <span>{{
+                  t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.zIndex')
+                }}</span>
+                <ElInputNumber
+                  :model-value="selectedElement.zIndex"
+                  :min="1"
+                  controls-position="right"
+                  @update:model-value="updateElement('zIndex', $event)"
+                />
+              </label>
+            </div>
+
+            <template v-if="selectedElement.type === 'text'">
+              <div class="matnr-print-property-panel__grid">
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.contentMode')
+                  }}</span>
+                  <ElSelect
+                    :model-value="selectedElement.contentMode"
+                    @update:model-value="updateElement('contentMode', $event)"
+                  >
+                    <ElOption
+                      value="static"
+                      :label="
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.options.staticText')
+                      "
+                    />
+                    <ElOption
+                      value="template"
+                      :label="
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.options.templateText')
+                      "
+                    />
+                  </ElSelect>
+                </label>
+                <label class="matnr-print-property-panel__span-2">
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.textContent')
+                  }}</span>
+                  <ElInput
+                    type="textarea"
+                    :rows="3"
+                    :model-value="selectedElement.contentTemplate"
+                    @focus="setPlaceholderTarget('contentTemplate')"
+                    @update:model-value="updateElement('contentTemplate', $event)"
+                  />
+                </label>
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.fontSizeMm')
+                  }}</span>
+                  <ElInputNumber
+                    :model-value="selectedElement.style?.fontSize"
+                    :min="1.8"
+                    :step="0.2"
+                    controls-position="right"
+                    @update:model-value="updateElementStyle('fontSize', $event)"
+                  />
+                </label>
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.fontWeight')
+                  }}</span>
+                  <ElInputNumber
+                    :model-value="selectedElement.style?.fontWeight"
+                    :min="100"
+                    :max="900"
+                    :step="100"
+                    controls-position="right"
+                    @update:model-value="updateElementStyle('fontWeight', $event)"
+                  />
+                </label>
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.textAlign')
+                  }}</span>
+                  <ElSelect
+                    :model-value="selectedElement.style?.textAlign"
+                    @update:model-value="updateElementStyle('textAlign', $event)"
+                  >
+                    <ElOption
+                      value="left"
+                      :label="
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.options.alignLeft')
+                      "
+                    />
+                    <ElOption
+                      value="center"
+                      :label="
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.options.alignCenter')
+                      "
+                    />
+                    <ElOption
+                      value="right"
+                      :label="
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.options.alignRight')
+                      "
+                    />
+                  </ElSelect>
+                </label>
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.textColor')
+                  }}</span>
+                  <ElColorPicker
+                    :model-value="selectedElement.style?.color"
+                    @change="updateElementStyle('color', $event)"
+                  />
+                </label>
+              </div>
+            </template>
+
+            <template v-else-if="selectedElement.type === 'barcode'">
+              <div class="matnr-print-property-panel__grid">
+                <label class="matnr-print-property-panel__span-2">
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.valueTemplate')
+                  }}</span>
+                  <ElInput
+                    :model-value="selectedElement.valueTemplate"
+                    @focus="setPlaceholderTarget('valueTemplate')"
+                    @update:model-value="updateElement('valueTemplate', $event)"
+                  />
+                </label>
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.symbology')
+                  }}</span>
+                  <ElInput model-value="CODE128" disabled />
+                </label>
+                <label class="matnr-print-property-panel__switch">
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.showText')
+                  }}</span>
+                  <ElSwitch
+                    :model-value="selectedElement.showText !== false"
+                    @update:model-value="updateElement('showText', $event)"
+                  />
+                </label>
+              </div>
+            </template>
+
+            <template v-else-if="selectedElement.type === 'qrcode'">
+              <div class="matnr-print-property-panel__grid">
+                <label class="matnr-print-property-panel__span-2">
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.valueTemplate')
+                  }}</span>
+                  <ElInput
+                    :model-value="selectedElement.valueTemplate"
+                    @focus="setPlaceholderTarget('valueTemplate')"
+                    @update:model-value="updateElement('valueTemplate', $event)"
+                  />
+                </label>
+              </div>
+            </template>
+
+            <template v-else-if="selectedElement.type === 'line'">
+              <div class="matnr-print-property-panel__grid">
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.direction')
+                  }}</span>
+                  <ElSelect
+                    :model-value="selectedElement.direction"
+                    @update:model-value="updateLineDirection($event)"
+                  >
+                    <ElOption
+                      value="horizontal"
+                      :label="
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.options.horizontal')
+                      "
+                    />
+                    <ElOption
+                      value="vertical"
+                      :label="
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.options.vertical')
+                      "
+                    />
+                  </ElSelect>
+                </label>
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.lineWidthMm')
+                  }}</span>
+                  <ElInputNumber
+                    :model-value="selectedElement.borderWidth"
+                    :min="0.2"
+                    :step="0.2"
+                    controls-position="right"
+                    @update:model-value="updateElement('borderWidth', $event)"
+                  />
+                </label>
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.lineColor')
+                  }}</span>
+                  <ElColorPicker
+                    :model-value="selectedElement.color"
+                    @change="updateElement('color', $event)"
+                  />
+                </label>
+              </div>
+            </template>
+
+            <template v-else-if="selectedElement.type === 'rect'">
+              <div class="matnr-print-property-panel__grid">
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.borderWidthMm')
+                  }}</span>
+                  <ElInputNumber
+                    :model-value="selectedElement.borderWidth"
+                    :min="0.2"
+                    :step="0.2"
+                    controls-position="right"
+                    @update:model-value="updateElement('borderWidth', $event)"
+                  />
+                </label>
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.radiusMm')
+                  }}</span>
+                  <ElInputNumber
+                    :model-value="selectedElement.radius"
+                    :min="0"
+                    :step="0.5"
+                    controls-position="right"
+                    @update:model-value="updateElement('radius', $event)"
+                  />
+                </label>
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.borderColor')
+                  }}</span>
+                  <ElColorPicker
+                    :model-value="selectedElement.borderColor"
+                    @change="updateElement('borderColor', $event)"
+                  />
+                </label>
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.fillColor')
+                  }}</span>
+                  <ElColorPicker
+                    :model-value="selectedElement.backgroundColor"
+                    @change="updateElement('backgroundColor', $event)"
+                  />
+                </label>
+              </div>
+            </template>
+
+            <template v-else-if="selectedElement.type === 'table'">
+              <div class="matnr-print-property-panel__grid">
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.fontSizeMm')
+                  }}</span>
+                  <ElInputNumber
+                    :model-value="selectedElement.style?.fontSize"
+                    :min="1.8"
+                    :step="0.2"
+                    controls-position="right"
+                    @update:model-value="updateElementStyle('fontSize', $event)"
+                  />
+                </label>
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.borderWidthMm')
+                  }}</span>
+                  <ElInputNumber
+                    :model-value="selectedElement.style?.borderWidth"
+                    :min="0.2"
+                    :step="0.2"
+                    controls-position="right"
+                    @update:model-value="updateElementStyle('borderWidth', $event)"
+                  />
+                </label>
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.textAlign')
+                  }}</span>
+                  <ElSelect
+                    :model-value="selectedElement.style?.textAlign"
+                    @update:model-value="updateElementStyle('textAlign', $event)"
+                  >
+                    <ElOption
+                      value="left"
+                      :label="
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.options.alignLeft')
+                      "
+                    />
+                    <ElOption
+                      value="center"
+                      :label="
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.options.alignCenter')
+                      "
+                    />
+                    <ElOption
+                      value="right"
+                      :label="
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.options.alignRight')
+                      "
+                    />
+                  </ElSelect>
+                </label>
+                <label>
+                  <span>{{
+                    t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.borderColor')
+                  }}</span>
+                  <ElColorPicker
+                    :model-value="selectedElement.style?.borderColor"
+                    @change="updateElementStyle('borderColor', $event)"
+                  />
+                </label>
+              </div>
+
+              <div class="matnr-print-property-panel__section-subtitle">
+                {{ t('pages.basicInfo.whMat.printTemplate.propertyPanel.sections.fieldList') }}
+              </div>
+              <div class="matnr-print-property-panel__table-rows">
+                <div
+                  v-for="(row, rowIndex) in tableRows"
+                  :key="row.id"
+                  class="matnr-print-property-panel__table-row"
+                >
+                  <div class="matnr-print-property-panel__table-row-header">
+                    <span>
+                      {{
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.rowTitle', {
+                          index: rowIndex + 1
+                        })
+                      }}
+                    </span>
+                    <ElButton type="danger" text @click="removeTableRow(rowIndex)">
+                      {{ t('common.actions.delete') }}
+                    </ElButton>
+                  </div>
+                  <div class="matnr-print-property-panel__grid">
+                    <label>
+                      <span>{{
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.leftMode')
+                      }}</span>
+                      <ElSelect
+                        :model-value="row.labelMode"
+                        @update:model-value="updateTableRow(rowIndex, 'labelMode', $event)"
+                      >
+                        <ElOption
+                          value="static"
+                          :label="
+                            t(
+                              'pages.basicInfo.whMat.printTemplate.propertyPanel.options.staticText'
+                            )
+                          "
+                        />
+                        <ElOption
+                          value="template"
+                          :label="
+                            t('pages.basicInfo.whMat.printTemplate.propertyPanel.options.template')
+                          "
+                        />
+                      </ElSelect>
+                    </label>
+                    <label>
+                      <span>{{
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.rightMode')
+                      }}</span>
+                      <ElSelect
+                        :model-value="row.valueMode"
+                        @update:model-value="updateTableRow(rowIndex, 'valueMode', $event)"
+                      >
+                        <ElOption
+                          value="template"
+                          :label="
+                            t('pages.basicInfo.whMat.printTemplate.propertyPanel.options.template')
+                          "
+                        />
+                        <ElOption
+                          value="static"
+                          :label="
+                            t(
+                              'pages.basicInfo.whMat.printTemplate.propertyPanel.options.staticText'
+                            )
+                          "
+                        />
+                      </ElSelect>
+                    </label>
+                    <label>
+                      <span>{{
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.rowHeightMm')
+                      }}</span>
+                      <ElInputNumber
+                        :model-value="row.height"
+                        :min="4"
+                        :step="1"
+                        controls-position="right"
+                        @update:model-value="updateTableRow(rowIndex, 'height', $event)"
+                      />
+                    </label>
+                    <label class="matnr-print-property-panel__span-2">
+                      <span>{{
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.leftContent')
+                      }}</span>
+                      <ElInput
+                        :model-value="row.labelTemplate"
+                        @focus="setPlaceholderTarget('table', rowIndex, 'labelTemplate')"
+                        @update:model-value="updateTableRow(rowIndex, 'labelTemplate', $event)"
+                      />
+                    </label>
+                    <label class="matnr-print-property-panel__span-2">
+                      <span>{{
+                        t('pages.basicInfo.whMat.printTemplate.propertyPanel.fields.rightContent')
+                      }}</span>
+                      <ElInput
+                        :model-value="row.valueTemplate"
+                        @focus="setPlaceholderTarget('table', rowIndex, 'valueTemplate')"
+                        @update:model-value="updateTableRow(rowIndex, 'valueTemplate', $event)"
+                      />
+                    </label>
+                  </div>
+                </div>
+              </div>
+              <ElButton size="small" @click="appendTableRow">
+                {{ t('pages.basicInfo.whMat.printTemplate.propertyPanel.actions.addRow') }}
+              </ElButton>
+            </template>
+          </template>
+        </section>
+      </div>
+    </ElScrollbar>
+  </div>
+</template>
+
+<script setup>
+  import { computed } from 'vue'
+  import { useI18n } from 'vue-i18n'
+  import { getFieldListTableRows, updateFieldListTableRows } from '../matnrPrintTemplate.helpers'
+
+  defineOptions({ name: 'MatnrPrintPropertyPanel' })
+
+  const { t } = useI18n()
+
+  const props = defineProps({
+    template: {
+      type: Object,
+      default: () => ({})
+    },
+    selectedElement: {
+      type: Object,
+      default: null
+    }
+  })
+
+  const emit = defineEmits([
+    'update-template-meta',
+    'update-canvas',
+    'update-element',
+    'remove-element',
+    'set-placeholder-target'
+  ])
+
+  const tableRows = computed(() => getFieldListTableRows(props.selectedElement))
+
+  function updateTemplateMeta(field, value) {
+    emit('update-template-meta', { field, value })
+  }
+
+  function updateCanvas(field, value) {
+    emit('update-canvas', { field, value })
+  }
+
+  function updateElement(field, value) {
+    emit('update-element', {
+      id: props.selectedElement?.id,
+      patch: {
+        [field]: value
+      }
+    })
+  }
+
+  function updateElementStyle(field, value) {
+    emit('update-element', {
+      id: props.selectedElement?.id,
+      patch: {
+        style: {
+          ...(props.selectedElement?.style || {}),
+          [field]: value
+        }
+      }
+    })
+  }
+
+  function updateLineDirection(direction) {
+    const patch =
+      direction === 'vertical'
+        ? {
+            direction,
+            w: Math.max(0.4, Number(props.selectedElement?.borderWidth || 0.4))
+          }
+        : {
+            direction,
+            h: Math.max(0.4, Number(props.selectedElement?.borderWidth || 0.4))
+          }
+    emit('update-element', {
+      id: props.selectedElement?.id,
+      patch
+    })
+  }
+
+  function setPlaceholderTarget(field, rowIndex, rowField) {
+    emit('set-placeholder-target', {
+      elementId: props.selectedElement?.id,
+      field,
+      rowIndex,
+      rowField
+    })
+  }
+
+  function appendTableRow() {
+    const nextRows = [
+      ...tableRows.value,
+      {
+        height: 6,
+        labelTemplate: t('pages.basicInfo.whMat.printTemplate.propertyPanel.defaultFieldLabel'),
+        labelMode: 'static',
+        valueTemplate: '{code}',
+        valueMode: 'template'
+      }
+    ]
+    syncTableRows(nextRows)
+  }
+
+  function removeTableRow(index) {
+    const nextRows = tableRows.value.filter((_, rowIndex) => rowIndex !== index)
+    syncTableRows(nextRows)
+  }
+
+  function updateTableRow(index, field, value) {
+    const nextRows = tableRows.value.map((row, rowIndex) =>
+      rowIndex === index
+        ? {
+            ...row,
+            [field]: value
+          }
+        : row
+    )
+    syncTableRows(nextRows)
+  }
+
+  function syncTableRows(rows) {
+    emit('update-element', {
+      id: props.selectedElement?.id,
+      patch: updateFieldListTableRows(props.selectedElement, rows)
+    })
+  }
+</script>
+
+<style scoped>
+  .matnr-print-property-panel {
+    flex: 1;
+    min-height: 0;
+    overflow: hidden;
+    background: #ffffff;
+  }
+
+  .matnr-print-property-panel__content {
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    padding: 16px;
+  }
+
+  .matnr-print-property-panel__section {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    padding: 14px;
+    border: 1px solid rgba(148, 163, 184, 0.18);
+    border-radius: 16px;
+    background: linear-gradient(180deg, #ffffff, #f8fafc);
+  }
+
+  .matnr-print-property-panel__section-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+  }
+
+  .matnr-print-property-panel__section-title {
+    font-size: 14px;
+    font-weight: 700;
+    color: var(--art-text-primary);
+  }
+
+  .matnr-print-property-panel__section-subtitle {
+    font-size: 13px;
+    font-weight: 600;
+    color: var(--art-text-primary);
+  }
+
+  .matnr-print-property-panel__grid {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 12px;
+  }
+
+  .matnr-print-property-panel__grid label {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    min-width: 0;
+    font-size: 12px;
+    color: var(--art-text-secondary);
+  }
+
+  .matnr-print-property-panel__span-2 {
+    grid-column: span 2;
+  }
+
+  .matnr-print-property-panel__switch {
+    justify-content: space-between;
+  }
+
+  .matnr-print-property-panel__empty {
+    padding: 18px 14px;
+    border-radius: 12px;
+    background: rgba(241, 245, 249, 0.85);
+    color: var(--art-text-secondary);
+    font-size: 13px;
+    line-height: 1.6;
+  }
+
+  .matnr-print-property-panel__table-rows {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  .matnr-print-property-panel__table-row {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+    padding: 12px;
+    border: 1px solid rgba(148, 163, 184, 0.2);
+    border-radius: 14px;
+    background: rgba(255, 255, 255, 0.9);
+  }
+
+  .matnr-print-property-panel__table-row-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    font-size: 13px;
+    font-weight: 600;
+    color: var(--art-text-primary);
+  }
+
+  @media (max-width: 1280px) {
+    .matnr-print-property-panel__grid {
+      grid-template-columns: minmax(0, 1fr);
+    }
+
+    .matnr-print-property-panel__span-2 {
+      grid-column: span 1;
+    }
+  }
+</style>
diff --git a/rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-toolbar.vue b/rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-toolbar.vue
new file mode 100644
index 0000000..aa9d9fd
--- /dev/null
+++ b/rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-toolbar.vue
@@ -0,0 +1,86 @@
+<template>
+  <div class="matnr-print-toolbar">
+    <ElSpace wrap>
+      <ElButton
+        v-for="item in items"
+        :key="item.type"
+        size="small"
+        @click="$emit('add-element', item.type)"
+      >
+        {{ item.label }}
+      </ElButton>
+    </ElSpace>
+
+    <ElSpace wrap>
+      <ElButton size="small" :disabled="!canZoomOut" @click="$emit('zoom-out')">
+        {{ t('pages.basicInfo.whMat.printTemplate.toolbar.zoomOut') }}
+      </ElButton>
+      <ElButton size="small" @click="$emit('zoom-reset')">{{ zoomPercent }}%</ElButton>
+      <ElButton size="small" :disabled="!canZoomIn" @click="$emit('zoom-in')">
+        {{ t('pages.basicInfo.whMat.printTemplate.toolbar.zoomIn') }}
+      </ElButton>
+      <ElButton
+        size="small"
+        :type="autoFitActive ? 'primary' : 'default'"
+        @click="$emit('zoom-fit')"
+      >
+        {{ t('pages.basicInfo.whMat.printTemplate.toolbar.zoomFit') }}
+      </ElButton>
+    </ElSpace>
+  </div>
+</template>
+
+<script setup>
+  import { computed } from 'vue'
+  import { useI18n } from 'vue-i18n'
+
+  defineOptions({ name: 'MatnrPrintToolbar' })
+
+  defineProps({
+    zoomPercent: {
+      type: Number,
+      default: 100
+    },
+    canZoomIn: {
+      type: Boolean,
+      default: true
+    },
+    canZoomOut: {
+      type: Boolean,
+      default: true
+    },
+    autoFitActive: {
+      type: Boolean,
+      default: false
+    }
+  })
+
+  defineEmits(['add-element', 'zoom-in', 'zoom-out', 'zoom-reset', 'zoom-fit'])
+
+  const { t, locale } = useI18n()
+
+  const items = computed(() => {
+    locale.value
+    return [
+      { type: 'text', label: t('pages.basicInfo.whMat.printTemplate.toolbar.elements.text') },
+      { type: 'barcode', label: t('pages.basicInfo.whMat.printTemplate.toolbar.elements.barcode') },
+      { type: 'qrcode', label: t('pages.basicInfo.whMat.printTemplate.toolbar.elements.qrcode') },
+      { type: 'line', label: t('pages.basicInfo.whMat.printTemplate.toolbar.elements.line') },
+      { type: 'rect', label: t('pages.basicInfo.whMat.printTemplate.toolbar.elements.rect') },
+      { type: 'table', label: t('pages.basicInfo.whMat.printTemplate.toolbar.elements.table') }
+    ]
+  })
+</script>
+
+<style scoped>
+  .matnr-print-toolbar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    flex-wrap: wrap;
+    padding: 12px 16px;
+    border-bottom: 1px solid rgba(148, 163, 184, 0.18);
+    background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.9));
+  }
+</style>
diff --git a/rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-print-dialog.vue b/rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-print-dialog.vue
new file mode 100644
index 0000000..5d348ab
--- /dev/null
+++ b/rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-print-dialog.vue
@@ -0,0 +1,694 @@
+<template>
+  <ElDialog
+    v-model="visibleModel"
+    :title="t('pages.basicInfo.whMat.printTemplate.dialog.title')"
+    width="min(96vw, 1180px)"
+    top="4vh"
+    class="wh-mat-print-preview-dialog"
+    destroy-on-close
+  >
+    <div class="wh-mat-print-dialog">
+      <div class="wh-mat-print-dialog__toolbar">
+        <ElSelect
+          v-model="selectedTemplateId"
+          class="wh-mat-print-dialog__template-select"
+          :placeholder="t('pages.basicInfo.whMat.printTemplate.dialog.templatePlaceholder')"
+          :disabled="loading || !templateOptions.length"
+          @change="handleTemplateChange"
+        >
+          <ElOption
+            v-for="item in templateOptions"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </ElSelect>
+        <div class="wh-mat-print-dialog__toolbar-right">
+          <div class="wh-mat-print-dialog__copies">
+            <span class="wh-mat-print-dialog__copies-label">
+              {{ t('pages.basicInfo.whMat.printTemplate.dialog.copyCount') }}
+            </span>
+            <ElInputNumber
+              v-model="copyCount"
+              :min="1"
+              :max="99"
+              :step="1"
+              :precision="0"
+              size="small"
+              class="wh-mat-print-dialog__copies-input"
+            />
+          </div>
+          <span class="wh-mat-print-dialog__summary">
+            {{
+              t('pages.basicInfo.whMat.printTemplate.dialog.labelCount', {
+                count: previewRows.length
+              })
+            }}
+          </span>
+          <div class="wh-mat-print-dialog__zoom">
+            <ElButton size="small" :disabled="!canZoomOut" @click="handleZoomOut">
+              {{ t('pages.basicInfo.whMat.printTemplate.toolbar.zoomOut') }}
+            </ElButton>
+            <ElButton size="small" @click="handleZoomReset"> {{ previewZoomPercent }}% </ElButton>
+            <ElButton size="small" :disabled="!canZoomIn" @click="handleZoomIn">
+              {{ t('pages.basicInfo.whMat.printTemplate.toolbar.zoomIn') }}
+            </ElButton>
+            <ElButton
+              size="small"
+              :type="autoFitEnabled ? 'primary' : 'default'"
+              @click="handleZoomFit"
+            >
+              {{ t('pages.basicInfo.whMat.printTemplate.toolbar.zoomFit') }}
+            </ElButton>
+          </div>
+        </div>
+      </div>
+
+      <ElEmpty
+        v-if="!templateOptions.length"
+        :description="t('pages.basicInfo.whMat.printTemplate.dialog.noTemplate')"
+      />
+
+      <ElEmpty
+        v-else-if="!normalizedRows.length"
+        :description="t('pages.basicInfo.whMat.printTemplate.dialog.noData')"
+      />
+
+      <template v-else>
+        <ElAlert
+          v-if="templateOverflow.hasOverflow"
+          class="wh-mat-print-dialog__overflow-alert"
+          type="warning"
+          :closable="false"
+          show-icon
+          :title="t('pages.basicInfo.whMat.printTemplate.dialog.canvasOverflow')"
+          :description="t('pages.basicInfo.whMat.printTemplate.dialog.canvasOverflowDescription')"
+        />
+
+        <div ref="previewViewportRef" class="wh-mat-print-dialog__preview-list">
+          <div
+            v-for="item in previewItems"
+            :key="item.key"
+            class="wh-mat-print-dialog__preview-item"
+            :style="item.previewStyle"
+          >
+            <div class="wh-mat-print-dialog__preview-canvas" :style="item.previewCanvasStyle">
+              <MatnrPrintCanvas
+                v-if="activeTemplate"
+                :template="activeTemplate"
+                :active-record="item.row"
+                mode="preview"
+                :interactive="false"
+                :show-grid="false"
+                :scale="4"
+              />
+            </div>
+          </div>
+        </div>
+      </template>
+
+      <div
+        v-if="printSourceVisible && activeTemplate && normalizedRows.length"
+        ref="printBodyRef"
+        class="wh-mat-print-dialog__print-source"
+      >
+        <div
+          v-for="item in previewItems"
+          :key="`${item.key}_print`"
+          class="wh-mat-print-dialog__preview-item wh-mat-print-dialog__preview-item--print"
+          :style="item.printStyle"
+        >
+          <MatnrPrintCanvas
+            :template="activeTemplate"
+            :active-record="item.row"
+            mode="preview"
+            :interactive="false"
+            :show-grid="false"
+            :scale="4"
+          />
+        </div>
+      </div>
+    </div>
+
+    <template #footer>
+      <div class="wh-mat-print-dialog__footer">
+        <ElButton @click="visibleModel = false">{{ t('common.actions.close') }}</ElButton>
+        <ElButton
+          type="primary"
+          :disabled="!activeTemplate || !normalizedRows.length"
+          @click="handlePrint"
+        >
+          {{ t('pages.basicInfo.whMat.printTemplate.dialog.confirmPrint') }}
+        </ElButton>
+      </div>
+    </template>
+  </ElDialog>
+</template>
+
+<script setup>
+  import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+  import { useI18n } from 'vue-i18n'
+  import { ElMessage } from 'element-plus'
+  import {
+    fetchGetDefaultMatnrPrintTemplate,
+    fetchGetMatnrPrintTemplateDetail,
+    fetchMatnrPrintTemplateList
+  } from '@/api/wh-mat'
+  import {
+    detectMatnrPrintTemplateOverflow,
+    normalizeMatnrPrintTemplate
+  } from '../matnrPrintTemplate.helpers'
+  import MatnrPrintCanvas from './matnr-print-canvas.vue'
+
+  defineOptions({ name: 'WhMatPrintDialog' })
+
+  const { t, locale } = useI18n()
+
+  const visibleModel = defineModel('visible', {
+    type: Boolean,
+    default: false
+  })
+
+  const props = defineProps({
+    rows: {
+      type: Array,
+      default: () => []
+    }
+  })
+
+  const previewViewportRef = ref(null)
+  const printBodyRef = ref(null)
+  const loading = ref(false)
+  const templateOptions = ref([])
+  const selectedTemplateId = ref(null)
+  const activeTemplate = ref(null)
+  const printSourceVisible = ref(false)
+  const copyCount = ref(1)
+  const SCREEN_MM_TO_PX = 96 / 25.4
+  const DEFAULT_PREVIEW_SCALE = 4
+  const MIN_PREVIEW_SCALE = 0.5
+  const MAX_PREVIEW_SCALE = 24
+  const PREVIEW_SCALE_STEPS = [0.5, 0.75, 1, 1.5, 2, 3, 4, 5, 6, 8, 10, 12, 14, 16, 20, 24]
+  const PREVIEW_PANEL_GAP = 24
+  const previewScale = ref(DEFAULT_PREVIEW_SCALE)
+  const autoFitEnabled = ref(true)
+  let resizeObserver = null
+
+  const normalizedRows = computed(() => (Array.isArray(props.rows) ? props.rows : []))
+  const previewRows = computed(() =>
+    normalizedRows.value.flatMap((row, rowIndex) =>
+      Array.from({ length: copyCount.value }, (_, copyIndex) => ({
+        key: `${row.id || row.code || rowIndex}_copy_${copyIndex + 1}`,
+        row
+      }))
+    )
+  )
+  const activeCanvas = computed(() => ({
+    width: Number(activeTemplate.value?.canvas?.width) || 70,
+    height: Number(activeTemplate.value?.canvas?.height) || 40
+  }))
+  const previewZoomRatio = computed(() =>
+    Number((previewScale.value / DEFAULT_PREVIEW_SCALE).toFixed(4))
+  )
+  const previewZoomPercent = computed(() =>
+    Math.round((previewScale.value / DEFAULT_PREVIEW_SCALE) * 100)
+  )
+  const canZoomIn = computed(() => previewScale.value < MAX_PREVIEW_SCALE)
+  const canZoomOut = computed(() => previewScale.value > MIN_PREVIEW_SCALE)
+  const templateOverflow = computed(() => detectMatnrPrintTemplateOverflow(activeTemplate.value))
+  const previewItems = computed(() =>
+    previewRows.value.map((item) => {
+      const canvas = activeCanvas.value
+      const zoomRatio = previewZoomRatio.value
+      return {
+        key: item.key,
+        row: item.row,
+        previewStyle: {
+          width: `${canvas.width * SCREEN_MM_TO_PX * zoomRatio}px`,
+          height: `${canvas.height * SCREEN_MM_TO_PX * zoomRatio}px`
+        },
+        previewCanvasStyle: {
+          width: `${canvas.width}mm`,
+          height: `${canvas.height}mm`,
+          transform: `scale(${zoomRatio})`
+        },
+        printStyle: {
+          width: `${canvas.width}mm`,
+          height: `${canvas.height}mm`
+        }
+      }
+    })
+  )
+
+  watch(
+    () => visibleModel.value,
+    async (visible) => {
+      if (visible) {
+        copyCount.value = 1
+        await loadTemplates()
+        await nextTick()
+        observePreviewViewport()
+        fitPreviewToViewport()
+        return
+      }
+      printSourceVisible.value = false
+      copyCount.value = 1
+    }
+  )
+
+  watch(
+    () => [activeTemplate.value?.id, normalizedRows.value.length],
+    async () => {
+      if (!visibleModel.value) {
+        return
+      }
+      await nextTick()
+      if (autoFitEnabled.value) {
+        fitPreviewToViewport()
+      }
+    }
+  )
+
+  async function loadTemplates() {
+    loading.value = true
+    try {
+      const [templates, defaultTemplate] = await Promise.all([
+        fetchMatnrPrintTemplateList({}),
+        fetchGetDefaultMatnrPrintTemplate()
+      ])
+      const nextTemplates = (Array.isArray(templates) ? templates : [])
+        .map((item) => normalizeMatnrPrintTemplate(item))
+        .filter((item) => item.status === 1)
+      templateOptions.value = nextTemplates
+      if (!nextTemplates.length) {
+        selectedTemplateId.value = null
+        activeTemplate.value = null
+        return
+      }
+
+      const defaultId =
+        defaultTemplate && typeof defaultTemplate === 'object'
+          ? Number(defaultTemplate.id) || null
+          : null
+      const nextTemplateId =
+        nextTemplates.find((item) => item.id === selectedTemplateId.value)?.id ||
+        nextTemplates.find((item) => item.id === defaultId)?.id ||
+        nextTemplates[0]?.id
+      selectedTemplateId.value = nextTemplateId
+      await handleTemplateChange(nextTemplateId)
+    } catch (error) {
+      ElMessage.error(
+        error?.message || t('pages.basicInfo.whMat.printTemplate.dialog.messages.loadFailed')
+      )
+      templateOptions.value = []
+      activeTemplate.value = null
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function handleTemplateChange(templateId) {
+    if (!templateId) {
+      activeTemplate.value = null
+      return
+    }
+    try {
+      const detail = await fetchGetMatnrPrintTemplateDetail(templateId)
+      activeTemplate.value = normalizeMatnrPrintTemplate(detail)
+    } catch (error) {
+      ElMessage.error(
+        error?.message || t('pages.basicInfo.whMat.printTemplate.dialog.messages.detailFailed')
+      )
+      activeTemplate.value = null
+    }
+  }
+
+  async function handlePrint() {
+    if (!activeTemplate.value) {
+      return
+    }
+
+    if (!printSourceVisible.value || !printBodyRef.value) {
+      printSourceVisible.value = true
+      await nextTick()
+      await waitForPaint()
+    }
+
+    if (!printBodyRef.value) {
+      printSourceVisible.value = false
+      return
+    }
+
+    const printWindow = window.open('', '_blank')
+    if (!printWindow) {
+      printSourceVisible.value = false
+      ElMessage.warning(t('pages.basicInfo.whMat.printTemplate.dialog.messages.popupBlocked'))
+      return
+    }
+
+    const content = printBodyRef.value.innerHTML
+    printSourceVisible.value = false
+    printWindow.document.open()
+    printWindow.document.write(buildPrintHtml(content, activeCanvas.value))
+    printWindow.document.close()
+  }
+
+  function buildPrintHtml(content, canvas) {
+    const printCanvas = canvas || { width: 70, height: 40 }
+    return (
+      `<!DOCTYPE html>
+<html lang="${locale.value === 'en' ? 'en' : 'zh-CN'}">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>${t('pages.basicInfo.whMat.printTemplate.dialog.printDocumentTitle')}</title>
+    <style>
+      :root {
+        color-scheme: light;
+      }
+      * {
+        box-sizing: border-box;
+        -webkit-print-color-adjust: exact;
+        print-color-adjust: exact;
+      }
+      @page {
+        size: ${printCanvas.width}mm ${printCanvas.height}mm;
+        margin: 0;
+      }
+      html,
+      body {
+        margin: 0;
+        padding: 0;
+        background: #ffffff;
+        color: #111827;
+        font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
+      }
+      body {
+        display: block;
+      }
+      .wh-mat-print-dialog__print-pages {
+        display: block;
+      }
+      .wh-mat-print-dialog__preview-list {
+        display: block;
+      }
+      .wh-mat-print-dialog__preview-item {
+        display: block;
+        width: ${printCanvas.width}mm;
+        height: ${printCanvas.height}mm;
+        margin: 0;
+        overflow: hidden;
+      }
+      .wh-mat-print-dialog__preview-item + .wh-mat-print-dialog__preview-item {
+        page-break-before: always;
+        break-before: page;
+      }
+      .matnr-print-canvas-shell {
+        width: ${printCanvas.width}mm;
+        height: ${printCanvas.height}mm;
+        display: block;
+        overflow: hidden;
+      }
+      .matnr-print-canvas {
+        position: relative;
+        box-sizing: border-box;
+        display: block;
+        margin: 0;
+        box-shadow: none;
+        overflow: hidden;
+      }
+      .matnr-print-element {
+        position: absolute;
+        overflow: hidden;
+      }
+      .matnr-print-element__text,
+      .matnr-print-element__barcode,
+      .matnr-print-element__qrcode,
+      .matnr-print-element__rect,
+      .matnr-print-element__table {
+        width: 100%;
+        height: 100%;
+      }
+      .matnr-print-element__text {
+        display: flex;
+        align-items: center;
+        white-space: pre-wrap;
+        word-break: break-word;
+      }
+      .matnr-print-element__barcode svg,
+      .matnr-print-element__qrcode svg {
+        width: 100%;
+        height: 100%;
+        display: block;
+      }
+      .matnr-print-element__qrcode {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+      }
+      .matnr-print-element__table {
+        border-collapse: collapse;
+        table-layout: fixed;
+      }
+      .matnr-print-element__table td {
+        padding: 0.4mm 0.8mm;
+        vertical-align: middle;
+        word-break: break-word;
+      }
+      </style>
+      <scr` +
+      `ipt>
+        window.addEventListener('load', () => {
+          window.focus();
+          setTimeout(() => window.print(), 60);
+        });
+        window.addEventListener('afterprint', () => {
+          window.close();
+        });
+      </scr` +
+      `ipt>
+  </head>
+  <body><div class="wh-mat-print-dialog__print-pages">${content}</div></body>
+</html>`
+    )
+  }
+
+  function waitForPaint() {
+    return new Promise((resolve) => {
+      requestAnimationFrame(() => resolve())
+    })
+  }
+
+  function resolveNextScale(direction) {
+    if (direction > 0) {
+      return (
+        PREVIEW_SCALE_STEPS.find((step) => step > previewScale.value + 0.001) || previewScale.value
+      )
+    }
+    return (
+      [...PREVIEW_SCALE_STEPS].reverse().find((step) => step < previewScale.value - 0.001) ||
+      previewScale.value
+    )
+  }
+
+  function getPreviewViewportAvailableSize() {
+    const viewportElement = previewViewportRef.value
+    if (!viewportElement) {
+      return null
+    }
+    const viewportStyle = window.getComputedStyle(viewportElement)
+    const width =
+      viewportElement.clientWidth -
+      Number.parseFloat(viewportStyle.paddingLeft || '0') -
+      Number.parseFloat(viewportStyle.paddingRight || '0') -
+      PREVIEW_PANEL_GAP
+    const height =
+      viewportElement.clientHeight -
+      Number.parseFloat(viewportStyle.paddingTop || '0') -
+      Number.parseFloat(viewportStyle.paddingBottom || '0') -
+      PREVIEW_PANEL_GAP
+    if (width <= 0 || height <= 0) {
+      return null
+    }
+    return { width, height }
+  }
+
+  function fitPreviewToViewport() {
+    const viewportSize = getPreviewViewportAvailableSize()
+    if (!viewportSize || !activeCanvas.value.width || !activeCanvas.value.height) {
+      return
+    }
+    const widthScale =
+      viewportSize.width / Math.max(Number(activeCanvas.value.width) * SCREEN_MM_TO_PX || 1, 1)
+    const heightScale =
+      viewportSize.height / Math.max(Number(activeCanvas.value.height) * SCREEN_MM_TO_PX || 1, 1)
+    const nextZoomRatio = Math.min(
+      widthScale,
+      heightScale,
+      MAX_PREVIEW_SCALE / DEFAULT_PREVIEW_SCALE
+    )
+    const nextScale = DEFAULT_PREVIEW_SCALE * nextZoomRatio
+    previewScale.value = Number(Math.max(MIN_PREVIEW_SCALE, nextScale).toFixed(2))
+  }
+
+  function observePreviewViewport() {
+    if (!resizeObserver || !previewViewportRef.value) {
+      return
+    }
+    resizeObserver.disconnect()
+    resizeObserver.observe(previewViewportRef.value)
+  }
+
+  function handleZoomIn() {
+    autoFitEnabled.value = false
+    previewScale.value = resolveNextScale(1)
+  }
+
+  function handleZoomOut() {
+    autoFitEnabled.value = false
+    previewScale.value = resolveNextScale(-1)
+  }
+
+  function handleZoomReset() {
+    autoFitEnabled.value = false
+    previewScale.value = DEFAULT_PREVIEW_SCALE
+  }
+
+  async function handleZoomFit() {
+    autoFitEnabled.value = true
+    await nextTick()
+    fitPreviewToViewport()
+  }
+
+  onMounted(() => {
+    resizeObserver = new ResizeObserver(() => {
+      if (autoFitEnabled.value) {
+        fitPreviewToViewport()
+      }
+    })
+    observePreviewViewport()
+  })
+
+  onBeforeUnmount(() => {
+    resizeObserver?.disconnect()
+    resizeObserver = null
+  })
+</script>
+
+<style scoped>
+  .wh-mat-print-dialog {
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    height: min(70vh, 760px);
+    min-height: 420px;
+  }
+
+  .wh-mat-print-dialog__toolbar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 16px;
+  }
+
+  .wh-mat-print-dialog__toolbar-right {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    margin-left: auto;
+  }
+
+  .wh-mat-print-dialog__zoom {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .wh-mat-print-dialog__copies {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .wh-mat-print-dialog__copies-label {
+    font-size: 13px;
+    color: var(--art-text-secondary);
+    white-space: nowrap;
+  }
+
+  .wh-mat-print-dialog__copies-input {
+    width: 110px;
+  }
+
+  .wh-mat-print-dialog__template-select {
+    width: 320px;
+    max-width: 100%;
+  }
+
+  .wh-mat-print-dialog__summary {
+    font-size: 13px;
+    color: var(--art-text-secondary);
+  }
+
+  .wh-mat-print-dialog__overflow-alert {
+    margin-bottom: -4px;
+  }
+
+  .wh-mat-print-dialog__preview-list {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    flex: 1;
+    min-height: 0;
+    gap: 16px;
+    overflow: auto;
+    padding: 12px;
+    background:
+      radial-gradient(circle at top left, rgba(59, 130, 246, 0.08), transparent 28%),
+      linear-gradient(180deg, #eef2f7 0%, #f8fafc 100%);
+    border-radius: 16px;
+  }
+
+  .wh-mat-print-dialog__preview-item {
+    display: flex;
+    align-items: flex-start;
+    justify-content: center;
+    flex: none;
+    padding: 0;
+    border-radius: 0;
+    background: transparent;
+    box-shadow: none;
+    overflow: visible;
+  }
+
+  .wh-mat-print-dialog__preview-item :deep(.matnr-print-canvas-shell) {
+    width: auto;
+    min-width: 0;
+    min-height: 0;
+    display: block;
+    overflow: visible;
+  }
+
+  .wh-mat-print-dialog__preview-canvas {
+    transform-origin: center top;
+  }
+
+  .wh-mat-print-dialog__preview-canvas :deep(.matnr-print-canvas) {
+    box-shadow: 0 18px 36px rgba(15, 23, 42, 0.08);
+  }
+
+  .wh-mat-print-dialog__print-source {
+    position: fixed;
+    left: -99999px;
+    top: 0;
+    visibility: hidden;
+    pointer-events: none;
+  }
+
+  .wh-mat-print-dialog__footer {
+    display: flex;
+    align-items: center;
+    justify-content: flex-end;
+    gap: 12px;
+  }
+</style>
diff --git a/rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-print-template-workspace.vue b/rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-print-template-workspace.vue
new file mode 100644
index 0000000..e509ebd
--- /dev/null
+++ b/rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-print-template-workspace.vue
@@ -0,0 +1,749 @@
+<template>
+  <div class="wh-mat-print-template-manager__layout">
+    <aside class="wh-mat-print-template-manager__sidebar">
+      <div class="wh-mat-print-template-manager__sidebar-header">
+        <div>
+          <div class="wh-mat-print-template-manager__title">
+            {{ t('pages.basicInfo.whMat.printTemplate.workspace.sidebarTitle') }}
+          </div>
+          <div class="wh-mat-print-template-manager__subtitle">
+            {{ t('pages.basicInfo.whMat.printTemplate.workspace.sidebarSubtitle') }}
+          </div>
+        </div>
+        <ElButton size="small" type="primary" @click="handleCreateTemplate">
+          {{ t('common.actions.add') }}
+        </ElButton>
+      </div>
+
+      <ElScrollbar class="wh-mat-print-template-manager__sidebar-scroll">
+        <div class="wh-mat-print-template-manager__template-list">
+          <button
+            v-for="template in templates"
+            :key="template.id || template.code"
+            type="button"
+            class="wh-mat-print-template-manager__template-card"
+            :class="{ 'is-active': template.code === activeTemplateCode }"
+            @click="selectTemplate(template.code)"
+          >
+            <div class="wh-mat-print-template-manager__template-card-header">
+              <span>{{ template.name }}</span>
+              <ElTag v-if="template.isDefault === 1" size="small" type="success">
+                {{ t('pages.basicInfo.whMat.printTemplate.workspace.defaultTag') }}
+              </ElTag>
+            </div>
+            <div class="wh-mat-print-template-manager__template-code">{{ template.code }}</div>
+            <div class="wh-mat-print-template-manager__template-actions">
+              <ElButton size="small" text @click.stop="handleCopyTemplate(template.code)">{{
+                t('pages.basicInfo.whMat.printTemplate.workspace.actions.copy')
+              }}</ElButton>
+              <ElButton size="small" text @click.stop="handleMarkDefault(template.code)">
+                {{ t('pages.basicInfo.whMat.printTemplate.workspace.actions.setDefault') }}
+              </ElButton>
+              <ElButton
+                size="small"
+                text
+                type="danger"
+                @click.stop="handleDeleteTemplate(template.code)"
+              >
+                {{ t('common.actions.delete') }}
+              </ElButton>
+            </div>
+          </button>
+        </div>
+      </ElScrollbar>
+    </aside>
+
+    <main class="wh-mat-print-template-manager__main">
+      <div class="wh-mat-print-template-manager__main-header">
+        <div>
+          <div class="wh-mat-print-template-manager__title">
+            {{
+              activeTemplate?.name ||
+              t('pages.basicInfo.whMat.printTemplate.workspace.unnamedTemplate')
+            }}
+          </div>
+          <div class="wh-mat-print-template-manager__subtitle">
+            {{
+              t('pages.basicInfo.whMat.printTemplate.workspace.previewRecord', {
+                name:
+                  previewRecord?.code ||
+                  previewRecord?.name ||
+                  t('pages.basicInfo.whMat.printTemplate.workspace.noPreviewRecord')
+              })
+            }}
+          </div>
+        </div>
+        <ElSpace wrap>
+          <ElButton :loading="loading" @click="handleReload">
+            {{ t('common.actions.refresh') }}
+          </ElButton>
+          <ElButton type="primary" :loading="saving" @click="handleSaveTemplate">
+            {{ t('pages.basicInfo.whMat.printTemplate.workspace.actions.saveTemplate') }}
+          </ElButton>
+        </ElSpace>
+      </div>
+
+      <MatnrPrintToolbar
+        :zoom-percent="zoomPercent"
+        :can-zoom-in="canZoomIn"
+        :can-zoom-out="canZoomOut"
+        :auto-fit-active="autoFitEnabled"
+        @add-element="handleAddElement"
+        @zoom-in="handleZoomIn"
+        @zoom-out="handleZoomOut"
+        @zoom-reset="handleZoomReset"
+        @zoom-fit="handleZoomFit"
+      />
+
+      <div ref="canvasPanelRef" class="wh-mat-print-template-manager__canvas-panel">
+        <ElEmpty
+          v-if="!activeTemplate"
+          :description="t('pages.basicInfo.whMat.printTemplate.workspace.emptyTemplate')"
+        />
+        <MatnrPrintCanvas
+          v-else
+          :template="activeTemplate"
+          :active-record="previewRecord"
+          :selected-element-id="selectedElementId"
+          mode="editor"
+          :scale="canvasScale"
+          :interactive="true"
+          :show-grid="true"
+          @select-element="selectedElementId = $event"
+          @update-element="handleElementPatch"
+        />
+      </div>
+    </main>
+
+    <aside class="wh-mat-print-template-manager__property">
+      <MatnrPrintPropertyPanel
+        v-if="activeTemplate"
+        :template="activeTemplate"
+        :selected-element="selectedElement"
+        @update-template-meta="handleTemplateMetaPatch"
+        @update-canvas="handleCanvasPatch"
+        @update-element="handleElementPatch"
+        @remove-element="handleRemoveElement"
+        @set-placeholder-target="placeholderTarget = $event"
+      />
+      <MatnrPrintFieldPanel
+        v-if="activeTemplate"
+        :fields="fieldOptions"
+        @insert-field="handleInsertPlaceholder"
+      />
+    </aside>
+  </div>
+</template>
+
+<script setup>
+  import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+  import { useI18n } from 'vue-i18n'
+  import { ElMessage, ElMessageBox } from 'element-plus'
+  import {
+    appendFieldPlaceholder,
+    buildMatnrPrintFieldOptions,
+    buildMatnrPrintTemplatePayload,
+    createDefaultMatnrPrintTemplate,
+    createElementByType,
+    duplicateMatnrPrintTemplate,
+    getFieldListTableRows,
+    normalizeMatnrPrintTemplate,
+    updateFieldListTableRows
+  } from '../matnrPrintTemplate.helpers'
+  import {
+    fetchGetDefaultMatnrPrintTemplate,
+    fetchGetMatnrPrintTemplateDetail,
+    fetchMatnrPrintTemplateList,
+    fetchRemoveMatnrPrintTemplate,
+    fetchSaveMatnrPrintTemplate,
+    fetchSetDefaultMatnrPrintTemplate,
+    fetchUpdateMatnrPrintTemplate
+  } from '@/api/wh-mat'
+  import MatnrPrintCanvas from './matnr-print-canvas.vue'
+  import MatnrPrintFieldPanel from './matnr-print-field-panel.vue'
+  import MatnrPrintPropertyPanel from './matnr-print-property-panel.vue'
+  import MatnrPrintToolbar from './matnr-print-toolbar.vue'
+
+  defineOptions({ name: 'WhMatPrintTemplateWorkspace' })
+
+  const { t, locale } = useI18n()
+
+  const props = defineProps({
+    enabledFields: {
+      type: Array,
+      default: () => []
+    },
+    previewRecord: {
+      type: Object,
+      default: () => ({})
+    },
+    autoLoad: {
+      type: Boolean,
+      default: true
+    }
+  })
+
+  const DEFAULT_CANVAS_SCALE = 4
+  const MIN_CANVAS_SCALE = 0.5
+  const MAX_CANVAS_SCALE = 24
+  const CANVAS_SCALE_STEPS = [0.5, 0.75, 1, 1.5, 2, 3, 4, 5, 6, 8, 10, 12, 14, 16, 20, 24]
+  const FIT_PANEL_GAP = 16
+
+  const templates = ref([])
+  const activeTemplateCode = ref('')
+  const selectedElementId = ref('')
+  const placeholderTarget = ref(null)
+  const loading = ref(false)
+  const saving = ref(false)
+  const canvasScale = ref(DEFAULT_CANVAS_SCALE)
+  const autoFitEnabled = ref(true)
+  const canvasPanelRef = ref(null)
+  let resizeObserver = null
+
+  const fieldOptions = computed(() => {
+    locale.value
+    return buildMatnrPrintFieldOptions(props.enabledFields)
+  })
+  const zoomPercent = computed(() => Math.round((canvasScale.value / DEFAULT_CANVAS_SCALE) * 100))
+  const canZoomIn = computed(() => canvasScale.value < MAX_CANVAS_SCALE)
+  const canZoomOut = computed(() => canvasScale.value > MIN_CANVAS_SCALE)
+  const activeTemplate = computed(
+    () => templates.value.find((template) => template.code === activeTemplateCode.value) || null
+  )
+  const selectedElement = computed(
+    () =>
+      activeTemplate.value?.elements?.find((element) => element.id === selectedElementId.value) ||
+      null
+  )
+
+  watch(
+    () => props.autoLoad,
+    async (autoLoad, previousValue) => {
+      if (autoLoad && !previousValue) {
+        await loadTemplates()
+      }
+    },
+    {
+      immediate: true
+    }
+  )
+
+  watch(
+    () =>
+      `${activeTemplate.value?.canvas?.width || 0}_${activeTemplate.value?.canvas?.height || 0}`,
+    async () => {
+      if (!autoFitEnabled.value || !activeTemplate.value) {
+        return
+      }
+      await nextTick()
+      fitCanvasToViewport()
+    }
+  )
+
+  async function loadTemplates() {
+    loading.value = true
+    try {
+      const [list, defaultTemplate] = await Promise.all([
+        fetchMatnrPrintTemplateList({}),
+        fetchGetDefaultMatnrPrintTemplate()
+      ])
+      const nextTemplates = (Array.isArray(list) ? list : [])
+        .map((item) => normalizeMatnrPrintTemplate(item))
+        .sort((left, right) => Number(right.isDefault) - Number(left.isDefault))
+
+      templates.value = nextTemplates
+      if (!templates.value.length) {
+        handleCreateTemplate()
+        return
+      }
+
+      const defaultCode = normalizeMatnrPrintTemplate(defaultTemplate || {}).code
+      const nextActiveCode =
+        templates.value.find((item) => item.code === activeTemplateCode.value)?.code ||
+        templates.value.find((item) => item.code === defaultCode)?.code ||
+        templates.value[0]?.code ||
+        ''
+      selectTemplate(nextActiveCode)
+    } catch (error) {
+      ElMessage.error(
+        error?.message || t('pages.basicInfo.whMat.printTemplate.workspace.messages.loadFailed')
+      )
+      if (!templates.value.length) {
+        handleCreateTemplate()
+      }
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function handleReload() {
+    loadTemplates()
+  }
+
+  function resolveNextCanvasScale(direction) {
+    if (direction > 0) {
+      return (
+        CANVAS_SCALE_STEPS.find((step) => step > canvasScale.value + 0.001) || canvasScale.value
+      )
+    }
+    return (
+      [...CANVAS_SCALE_STEPS].reverse().find((step) => step < canvasScale.value - 0.001) ||
+      canvasScale.value
+    )
+  }
+
+  function handleZoomIn() {
+    autoFitEnabled.value = false
+    canvasScale.value = resolveNextCanvasScale(1)
+  }
+
+  function handleZoomOut() {
+    autoFitEnabled.value = false
+    canvasScale.value = resolveNextCanvasScale(-1)
+  }
+
+  function handleZoomReset() {
+    autoFitEnabled.value = false
+    canvasScale.value = DEFAULT_CANVAS_SCALE
+  }
+
+  function getCanvasPanelAvailableSize() {
+    const panelElement = canvasPanelRef.value
+    if (!panelElement) {
+      return null
+    }
+    const panelStyle = window.getComputedStyle(panelElement)
+    const width =
+      panelElement.clientWidth -
+      Number.parseFloat(panelStyle.paddingLeft || '0') -
+      Number.parseFloat(panelStyle.paddingRight || '0') -
+      FIT_PANEL_GAP
+    const height =
+      panelElement.clientHeight -
+      Number.parseFloat(panelStyle.paddingTop || '0') -
+      Number.parseFloat(panelStyle.paddingBottom || '0') -
+      FIT_PANEL_GAP
+    if (width <= 0 || height <= 0) {
+      return null
+    }
+    return { width, height }
+  }
+
+  function fitCanvasToViewport() {
+    if (!activeTemplate.value) {
+      return
+    }
+    const panelSize = getCanvasPanelAvailableSize()
+    if (!panelSize) {
+      return
+    }
+    const canvas = activeTemplate.value.canvas || {}
+    const widthScale = panelSize.width / Math.max(Number(canvas.width) || 1, 1)
+    const heightScale = panelSize.height / Math.max(Number(canvas.height) || 1, 1)
+    const nextScale = Math.min(widthScale, heightScale, MAX_CANVAS_SCALE)
+    canvasScale.value = Number(Math.max(MIN_CANVAS_SCALE, nextScale).toFixed(2))
+  }
+
+  async function handleZoomFit() {
+    autoFitEnabled.value = true
+    await nextTick()
+    fitCanvasToViewport()
+  }
+
+  function selectTemplate(code) {
+    activeTemplateCode.value = code
+    selectedElementId.value = ''
+    placeholderTarget.value = null
+    if (autoFitEnabled.value) {
+      nextTick(() => {
+        fitCanvasToViewport()
+      })
+    }
+  }
+
+  function replaceActiveTemplate(patch) {
+    templates.value = templates.value.map((template) =>
+      template.code === activeTemplateCode.value
+        ? normalizeMatnrPrintTemplate({ ...template, ...patch })
+        : template
+    )
+  }
+
+  function handleTemplateMetaPatch({ field, value }) {
+    if (!activeTemplate.value) {
+      return
+    }
+    if (field === 'isDefault' && value === 1) {
+      templates.value = templates.value.map((template) => ({
+        ...template,
+        isDefault: template.code === activeTemplateCode.value ? 1 : 0
+      }))
+      return
+    }
+    replaceActiveTemplate({
+      [field]: value
+    })
+  }
+
+  function handleCanvasPatch({ field, value }) {
+    if (!activeTemplate.value) {
+      return
+    }
+    replaceActiveTemplate({
+      canvas: {
+        ...(activeTemplate.value.canvas || {}),
+        [field]: value
+      }
+    })
+  }
+
+  function handleElementPatch({ id, patch }) {
+    if (!activeTemplate.value || !id) {
+      return
+    }
+    const nextElements = activeTemplate.value.elements.map((element) =>
+      element.id === id
+        ? normalizeMatnrPrintTemplate({ elements: [{ ...element, ...patch }] }).elements[0]
+        : element
+    )
+    replaceActiveTemplate({
+      elements: nextElements
+    })
+  }
+
+  function handleAddElement(type) {
+    if (!activeTemplate.value) {
+      return
+    }
+    const newElement = createElementByType(type, activeTemplate.value.elements.length)
+    replaceActiveTemplate({
+      elements: [...activeTemplate.value.elements, newElement]
+    })
+    selectedElementId.value = newElement.id
+  }
+
+  function handleRemoveElement(id) {
+    if (!activeTemplate.value) {
+      return
+    }
+    replaceActiveTemplate({
+      elements: activeTemplate.value.elements.filter((element) => element.id !== id)
+    })
+    if (selectedElementId.value === id) {
+      selectedElementId.value = ''
+    }
+  }
+
+  function handleCreateTemplate() {
+    const newTemplate = createDefaultMatnrPrintTemplate({
+      name: `${t('pages.basicInfo.whMat.printTemplate.helpers.defaultTemplateName')}${templates.value.length + 1}`,
+      code: `MATNR_${Date.now()}_${templates.value.length + 1}`
+    })
+    templates.value = [
+      ...templates.value.map((item) => ({ ...item, isDefault: item.isDefault })),
+      newTemplate
+    ]
+    selectTemplate(newTemplate.code)
+  }
+
+  function handleCopyTemplate(code) {
+    const template = templates.value.find((item) => item.code === code)
+    if (!template) {
+      return
+    }
+    const copyTemplate = duplicateMatnrPrintTemplate(template)
+    templates.value = [...templates.value, copyTemplate]
+    selectTemplate(copyTemplate.code)
+  }
+
+  async function handleDeleteTemplate(code) {
+    const template = templates.value.find((item) => item.code === code)
+    if (!template) {
+      return
+    }
+    try {
+      await ElMessageBox.confirm(
+        t('pages.basicInfo.whMat.printTemplate.workspace.messages.deleteConfirm', {
+          name: template.name
+        }),
+        t('pages.basicInfo.whMat.printTemplate.workspace.actions.delete'),
+        {
+          type: 'warning'
+        }
+      )
+      if (template.id) {
+        await fetchRemoveMatnrPrintTemplate(template.id)
+      }
+      templates.value = templates.value.filter((item) => item.code !== code)
+      if (!templates.value.length) {
+        handleCreateTemplate()
+      } else if (activeTemplateCode.value === code) {
+        selectTemplate(templates.value[0].code)
+      }
+      ElMessage.success(t('pages.basicInfo.whMat.printTemplate.workspace.messages.deleteSuccess'))
+    } catch (error) {
+      if (error !== 'cancel') {
+        ElMessage.error(
+          error?.message || t('pages.basicInfo.whMat.printTemplate.workspace.messages.deleteFailed')
+        )
+      }
+    }
+  }
+
+  async function handleMarkDefault(code) {
+    const template = templates.value.find((item) => item.code === code)
+    if (!template) {
+      return
+    }
+    templates.value = templates.value.map((item) => ({
+      ...item,
+      isDefault: item.code === code ? 1 : 0
+    }))
+    if (template.id) {
+      try {
+        await fetchSetDefaultMatnrPrintTemplate(template.id)
+        ElMessage.success(
+          t('pages.basicInfo.whMat.printTemplate.workspace.messages.defaultSuccess')
+        )
+      } catch (error) {
+        ElMessage.error(
+          error?.message ||
+            t('pages.basicInfo.whMat.printTemplate.workspace.messages.defaultFailed')
+        )
+      }
+    }
+  }
+
+  async function handleSaveTemplate() {
+    if (!activeTemplate.value) {
+      return
+    }
+    saving.value = true
+    try {
+      const payload = buildMatnrPrintTemplatePayload(activeTemplate.value)
+      const saved = activeTemplate.value.id
+        ? await fetchUpdateMatnrPrintTemplate(payload)
+        : await fetchSaveMatnrPrintTemplate(payload)
+      const normalized = normalizeMatnrPrintTemplate(saved)
+      templates.value = templates.value.map((template) =>
+        template.code === activeTemplateCode.value
+          ? normalized
+          : { ...template, isDefault: normalized.isDefault === 1 ? 0 : template.isDefault }
+      )
+      if (!templates.value.find((template) => template.code === normalized.code)) {
+        templates.value = [...templates.value, normalized]
+      }
+      if (normalized.isDefault === 1) {
+        templates.value = templates.value.map((template) => ({
+          ...template,
+          isDefault: template.id === normalized.id ? 1 : 0
+        }))
+      }
+      activeTemplateCode.value = normalized.code
+      if (normalized.id) {
+        const detail = await fetchGetMatnrPrintTemplateDetail(normalized.id)
+        const nextTemplate = normalizeMatnrPrintTemplate(detail)
+        templates.value = templates.value.map((template) =>
+          template.code === normalized.code ? nextTemplate : template
+        )
+      }
+      ElMessage.success(t('pages.basicInfo.whMat.printTemplate.workspace.messages.saveSuccess'))
+    } catch (error) {
+      ElMessage.error(
+        error?.message || t('pages.basicInfo.whMat.printTemplate.workspace.messages.saveFailed')
+      )
+    } finally {
+      saving.value = false
+    }
+  }
+
+  function handleInsertPlaceholder(placeholder) {
+    if (!activeTemplate.value || !placeholderTarget.value || !selectedElement.value) {
+      return
+    }
+    const target = placeholderTarget.value
+    if (target.field === 'contentTemplate' || target.field === 'valueTemplate') {
+      handleElementPatch({
+        id: selectedElement.value.id,
+        patch: {
+          [target.field]: appendFieldPlaceholder(selectedElement.value[target.field], placeholder)
+        }
+      })
+      return
+    }
+    if (target.field === 'table') {
+      const rows = getFieldListTableRows(selectedElement.value).map((row, rowIndex) =>
+        rowIndex === target.rowIndex
+          ? {
+              ...row,
+              [target.rowField]: appendFieldPlaceholder(row[target.rowField], placeholder)
+            }
+          : row
+      )
+      handleElementPatch({
+        id: selectedElement.value.id,
+        patch: updateFieldListTableRows(selectedElement.value, rows)
+      })
+    }
+  }
+
+  defineExpose({
+    loadTemplates
+  })
+
+  onMounted(() => {
+    resizeObserver = new ResizeObserver(() => {
+      if (autoFitEnabled.value) {
+        fitCanvasToViewport()
+      }
+    })
+    if (canvasPanelRef.value) {
+      resizeObserver.observe(canvasPanelRef.value)
+    }
+  })
+
+  onBeforeUnmount(() => {
+    resizeObserver?.disconnect()
+    resizeObserver = null
+  })
+</script>
+
+<style scoped>
+  .wh-mat-print-template-manager__layout {
+    display: grid;
+    grid-template-columns: 280px minmax(0, 1fr) 360px;
+    height: 100%;
+    min-height: 0;
+    overflow: hidden;
+    background:
+      radial-gradient(circle at top left, rgba(59, 130, 246, 0.08), transparent 26%),
+      linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
+  }
+
+  .wh-mat-print-template-manager__sidebar,
+  .wh-mat-print-template-manager__property {
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+    min-height: 0;
+    overflow: hidden;
+    background: rgba(255, 255, 255, 0.94);
+    border-right: 1px solid rgba(148, 163, 184, 0.18);
+  }
+
+  .wh-mat-print-template-manager__property {
+    border-right: none;
+    border-left: 1px solid rgba(148, 163, 184, 0.18);
+  }
+
+  .wh-mat-print-template-manager__sidebar-header,
+  .wh-mat-print-template-manager__main-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 16px;
+    padding: 16px;
+    border-bottom: 1px solid rgba(148, 163, 184, 0.18);
+  }
+
+  .wh-mat-print-template-manager__sidebar-scroll {
+    flex: 1;
+    min-height: 0;
+  }
+
+  .wh-mat-print-template-manager__template-list {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    padding: 16px;
+  }
+
+  .wh-mat-print-template-manager__template-card {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+    padding: 14px;
+    border: 1px solid rgba(148, 163, 184, 0.18);
+    border-radius: 16px;
+    background: #ffffff;
+    text-align: left;
+    cursor: pointer;
+    transition:
+      transform 0.2s ease,
+      border-color 0.2s ease,
+      box-shadow 0.2s ease;
+  }
+
+  .wh-mat-print-template-manager__template-card:hover,
+  .wh-mat-print-template-manager__template-card.is-active {
+    border-color: rgba(37, 99, 235, 0.45);
+    transform: translateY(-1px);
+    box-shadow: 0 12px 28px rgba(37, 99, 235, 0.08);
+  }
+
+  .wh-mat-print-template-manager__template-card-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+    font-size: 14px;
+    font-weight: 700;
+    color: var(--art-text-primary);
+  }
+
+  .wh-mat-print-template-manager__template-code,
+  .wh-mat-print-template-manager__subtitle {
+    font-size: 12px;
+    color: var(--art-text-secondary);
+  }
+
+  .wh-mat-print-template-manager__template-actions {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    flex-wrap: wrap;
+  }
+
+  .wh-mat-print-template-manager__main {
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+    min-height: 0;
+    overflow: hidden;
+  }
+
+  .wh-mat-print-template-manager__canvas-panel {
+    flex: 1;
+    min-width: 0;
+    min-height: 0;
+    display: flex;
+    padding: 16px;
+    overflow: auto;
+  }
+
+  .wh-mat-print-template-manager__title {
+    font-size: 16px;
+    font-weight: 700;
+    color: var(--art-text-primary);
+  }
+
+  @media (max-width: 1440px) {
+    .wh-mat-print-template-manager__layout {
+      grid-template-columns: 240px minmax(0, 1fr) 320px;
+    }
+  }
+
+  @media (max-width: 1180px) {
+    .wh-mat-print-template-manager__layout {
+      grid-template-columns: minmax(0, 1fr);
+      grid-template-rows: minmax(0, 0.82fr) minmax(0, 1.45fr) minmax(0, 1fr);
+    }
+
+    .wh-mat-print-template-manager__sidebar,
+    .wh-mat-print-template-manager__property {
+      border-left: none;
+      border-right: none;
+      border-bottom: 1px solid rgba(148, 163, 184, 0.18);
+    }
+  }
+</style>
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/LocItemController.java b/rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/LocItemController.java
index 7959f0b..b16c1c5 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/LocItemController.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/LocItemController.java
@@ -41,7 +41,7 @@
     @Autowired
     private CompanysService companysService;
 
-    @PreAuthorize("hasAuthority('manager:locItem:list')")
+    @PreAuthorize("hasAnyAuthority('manager:locItem:list','manager:locPreview:list')")
     @PostMapping("/locItem/page")
     public R page(@RequestBody Map<String, Object> map) {
         BaseParam baseParam = buildParam(map, BaseParam.class);
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/MatnrPrintTemplateController.java b/rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/MatnrPrintTemplateController.java
new file mode 100644
index 0000000..1794bd8
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/MatnrPrintTemplateController.java
@@ -0,0 +1,101 @@
+package com.vincent.rsf.server.manager.controller;
+
+import com.alibaba.fastjson.JSONObject;
+import com.vincent.rsf.framework.common.R;
+import com.vincent.rsf.framework.exception.CoolException;
+import com.vincent.rsf.server.common.annotation.OperationLog;
+import com.vincent.rsf.server.manager.entity.MatnrPrintTemplate;
+import com.vincent.rsf.server.manager.service.MatnrPrintTemplateService;
+import com.vincent.rsf.server.manager.utils.buildPageRowsUtils;
+import com.vincent.rsf.server.system.controller.BaseController;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@Api(tags = "鐗╂枡鎵撳嵃妯℃澘鎺ュ彛")
+public class MatnrPrintTemplateController extends BaseController {
+
+    private final MatnrPrintTemplateService matnrPrintTemplateService;
+
+    public MatnrPrintTemplateController(MatnrPrintTemplateService matnrPrintTemplateService) {
+        this.matnrPrintTemplateService = matnrPrintTemplateService;
+    }
+
+    @PreAuthorize("hasAuthority('manager:matnrPrintTemplate:list')")
+    @ApiOperation("鏌ヨ鐗╂枡鎵撳嵃妯℃澘鍒楄〃")
+    @PostMapping("/matnrPrintTemplate/list")
+    public R list(@RequestBody(required = false) Map<String, Object> params) {
+        List<MatnrPrintTemplate> templates = matnrPrintTemplateService.listCurrentTenantTemplates();
+        return R.ok().add(buildPageRowsUtils.rowsMap(templates));
+    }
+
+    @PreAuthorize("hasAuthority('manager:matnrPrintTemplate:list')")
+    @ApiOperation("鑾峰彇鐗╂枡鎵撳嵃妯℃澘璇︽儏")
+    @GetMapping("/matnrPrintTemplate/{id}")
+    public R get(@PathVariable("id") Long id) {
+        return R.ok(buildPageRowsUtils.rowsMap(matnrPrintTemplateService.getCurrentTenantTemplate(id)));
+    }
+
+    @PreAuthorize("hasAuthority('manager:matnrPrintTemplate:list')")
+    @ApiOperation("鑾峰彇榛樿鐗╂枡鎵撳嵃妯℃澘")
+    @GetMapping("/matnrPrintTemplate/default")
+    public R getDefaultTemplate() {
+        MatnrPrintTemplate template = matnrPrintTemplateService.getCurrentTenantDefaultTemplate();
+        if (template == null) {
+            return R.ok();
+        }
+        return R.ok(buildPageRowsUtils.rowsMap(template));
+    }
+
+    @PreAuthorize("hasAuthority('manager:matnrPrintTemplate:save')")
+    @OperationLog("Create 鐗╂枡鎵撳嵃妯℃澘")
+    @ApiOperation("淇濆瓨鐗╂枡鎵撳嵃妯℃澘")
+    @PostMapping("/matnrPrintTemplate/save")
+    public R save(@RequestBody Map<String, Object> params) {
+        MatnrPrintTemplate template = JSONObject.parseObject(JSONObject.toJSONString(params), MatnrPrintTemplate.class);
+        if (template == null) {
+            throw new CoolException("妯℃澘鍙傛暟涓嶈兘涓虹┖");
+        }
+        MatnrPrintTemplate saved = matnrPrintTemplateService.saveTemplate(template);
+        return R.ok("Create Success").add(buildPageRowsUtils.rowsMap(saved));
+    }
+
+    @PreAuthorize("hasAuthority('manager:matnrPrintTemplate:update')")
+    @OperationLog("Update 鐗╂枡鎵撳嵃妯℃澘")
+    @ApiOperation("鏇存柊鐗╂枡鎵撳嵃妯℃澘")
+    @PostMapping("/matnrPrintTemplate/update")
+    @Transactional(rollbackFor = Exception.class)
+    public R update(@RequestBody Map<String, Object> params) {
+        MatnrPrintTemplate template = JSONObject.parseObject(JSONObject.toJSONString(params), MatnrPrintTemplate.class);
+        if (template == null || template.getId() == null) {
+            throw new CoolException("妯℃澘ID涓嶈兘涓虹┖");
+        }
+        MatnrPrintTemplate updated = matnrPrintTemplateService.updateTemplate(template);
+        return R.ok("Update Success").add(buildPageRowsUtils.rowsMap(updated));
+    }
+
+    @PreAuthorize("hasAuthority('manager:matnrPrintTemplate:remove')")
+    @OperationLog("Delete 鐗╂枡鎵撳嵃妯℃澘")
+    @ApiOperation("鍒犻櫎鐗╂枡鎵撳嵃妯℃澘")
+    @PostMapping("/matnrPrintTemplate/remove/{ids}")
+    public R remove(@PathVariable Long[] ids) {
+        matnrPrintTemplateService.removeTemplates(Arrays.asList(ids));
+        return R.ok("Delete Success").add(buildPageRowsUtils.rowsMap(ids));
+    }
+
+    @PreAuthorize("hasAuthority('manager:matnrPrintTemplate:update')")
+    @OperationLog("Set Default 鐗╂枡鎵撳嵃妯℃澘")
+    @ApiOperation("璁剧疆榛樿鐗╂枡鎵撳嵃妯℃澘")
+    @PostMapping("/matnrPrintTemplate/default/{id}")
+    public R setDefault(@PathVariable("id") Long id) {
+        matnrPrintTemplateService.setDefaultTemplate(id);
+        return R.ok("Update Success");
+    }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/manager/entity/MatnrPrintTemplate.java b/rsf-server/src/main/java/com/vincent/rsf/server/manager/entity/MatnrPrintTemplate.java
new file mode 100644
index 0000000..7046a76
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/manager/entity/MatnrPrintTemplate.java
@@ -0,0 +1,82 @@
+package com.vincent.rsf.server.manager.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.experimental.Accessors;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.io.Serializable;
+import java.util.Date;
+import java.util.Map;
+
+@Data
+@Accessors(chain = true)
+@TableName(value = "man_matnr_print_template", autoResultMap = true)
+@ApiModel(value = "MatnrPrintTemplate", description = "鐗╂枡鎵撳嵃妯℃澘")
+public class MatnrPrintTemplate implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty(value = "ID")
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty(value = "绉熸埛")
+    private Long tenantId;
+
+    @ApiModelProperty(value = "妯℃澘鍚嶇О")
+    private String name;
+
+    @ApiModelProperty(value = "妯℃澘缂栫爜")
+    private String code;
+
+    @ApiModelProperty(value = "鏄惁榛樿妯℃澘")
+    private Integer isDefault;
+
+    @ApiModelProperty(value = "鐘舵�� 1: 姝e父 0: 鍐荤粨")
+    private Integer status;
+
+    @ApiModelProperty(value = "妯℃澘鐢诲竷 JSON")
+    @TableField(value = "canvas_json", typeHandler = JacksonTypeHandler.class)
+    private Map<String, Object> canvasJson;
+
+    @ApiModelProperty(value = "鏄惁鍒犻櫎 1: 鏄� 0: 鍚�")
+    @TableLogic
+    private Integer deleted;
+
+    @ApiModelProperty(value = "娣诲姞浜哄憳")
+    private Long createBy;
+
+    @ApiModelProperty(value = "娣诲姞鏃堕棿")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createTime;
+
+    @ApiModelProperty(value = "淇敼浜哄憳")
+    private Long updateBy;
+
+    @ApiModelProperty(value = "淇敼鏃堕棿")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date updateTime;
+
+    @ApiModelProperty(value = "澶囨敞")
+    private String memo;
+
+    @TableField(exist = false)
+    private String tenantId$;
+
+    @TableField(exist = false)
+    private String createBy$;
+
+    @TableField(exist = false)
+    private String updateBy$;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/manager/mapper/MatnrPrintTemplateMapper.java b/rsf-server/src/main/java/com/vincent/rsf/server/manager/mapper/MatnrPrintTemplateMapper.java
new file mode 100644
index 0000000..2814bcd
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/manager/mapper/MatnrPrintTemplateMapper.java
@@ -0,0 +1,11 @@
+package com.vincent.rsf.server.manager.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.vincent.rsf.server.manager.entity.MatnrPrintTemplate;
+import org.apache.ibatis.annotations.Mapper;
+import org.springframework.stereotype.Repository;
+
+@Mapper
+@Repository
+public interface MatnrPrintTemplateMapper extends BaseMapper<MatnrPrintTemplate> {
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/manager/service/MatnrPrintTemplateService.java b/rsf-server/src/main/java/com/vincent/rsf/server/manager/service/MatnrPrintTemplateService.java
new file mode 100644
index 0000000..10a2687
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/manager/service/MatnrPrintTemplateService.java
@@ -0,0 +1,23 @@
+package com.vincent.rsf.server.manager.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.vincent.rsf.server.manager.entity.MatnrPrintTemplate;
+
+import java.util.List;
+
+public interface MatnrPrintTemplateService extends IService<MatnrPrintTemplate> {
+
+    List<MatnrPrintTemplate> listCurrentTenantTemplates();
+
+    MatnrPrintTemplate getCurrentTenantTemplate(Long id);
+
+    MatnrPrintTemplate getCurrentTenantDefaultTemplate();
+
+    MatnrPrintTemplate saveTemplate(MatnrPrintTemplate template);
+
+    MatnrPrintTemplate updateTemplate(MatnrPrintTemplate template);
+
+    boolean removeTemplates(List<Long> ids);
+
+    boolean setDefaultTemplate(Long id);
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/MatnrPrintTemplateServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/MatnrPrintTemplateServiceImpl.java
new file mode 100644
index 0000000..d69d567
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/MatnrPrintTemplateServiceImpl.java
@@ -0,0 +1,376 @@
+package com.vincent.rsf.server.manager.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.vincent.rsf.framework.exception.CoolException;
+import com.vincent.rsf.server.manager.entity.MatnrPrintTemplate;
+import com.vincent.rsf.server.manager.mapper.MatnrPrintTemplateMapper;
+import com.vincent.rsf.server.manager.service.MatnrPrintTemplateService;
+import com.vincent.rsf.server.system.entity.User;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+@Service("matnrPrintTemplateService")
+public class MatnrPrintTemplateServiceImpl
+        extends ServiceImpl<MatnrPrintTemplateMapper, MatnrPrintTemplate>
+        implements MatnrPrintTemplateService {
+
+    private static final Set<String> SUPPORTED_ELEMENT_TYPES = Collections.unmodifiableSet(
+            new LinkedHashSet<>(Arrays.asList("text", "barcode", "qrcode", "line", "rect", "table"))
+    );
+
+    @Override
+    public List<MatnrPrintTemplate> listCurrentTenantTemplates() {
+        List<MatnrPrintTemplate> templates = this.list(new LambdaQueryWrapper<MatnrPrintTemplate>()
+                .orderByDesc(MatnrPrintTemplate::getIsDefault)
+                .orderByDesc(MatnrPrintTemplate::getUpdateTime)
+                .orderByDesc(MatnrPrintTemplate::getCreateTime)
+        );
+        return templates == null ? new ArrayList<>() : templates;
+    }
+
+    @Override
+    public MatnrPrintTemplate getCurrentTenantTemplate(Long id) {
+        if (id == null) {
+            throw new CoolException("妯℃澘ID涓嶈兘涓虹┖");
+        }
+        MatnrPrintTemplate template = this.getById(id);
+        if (template == null) {
+            throw new CoolException("妯℃澘涓嶅瓨鍦ㄦ垨宸茶鍒犻櫎");
+        }
+        return template;
+    }
+
+    @Override
+    public MatnrPrintTemplate getCurrentTenantDefaultTemplate() {
+        MatnrPrintTemplate template = this.getOne(new LambdaQueryWrapper<MatnrPrintTemplate>()
+                .eq(MatnrPrintTemplate::getStatus, 1)
+                .eq(MatnrPrintTemplate::getIsDefault, 1)
+                .orderByDesc(MatnrPrintTemplate::getUpdateTime)
+                .last("limit 1")
+        );
+        if (template != null) {
+            return template;
+        }
+        return this.getOne(new LambdaQueryWrapper<MatnrPrintTemplate>()
+                .eq(MatnrPrintTemplate::getStatus, 1)
+                .orderByDesc(MatnrPrintTemplate::getUpdateTime)
+                .orderByDesc(MatnrPrintTemplate::getCreateTime)
+                .last("limit 1")
+        );
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public MatnrPrintTemplate saveTemplate(MatnrPrintTemplate template) {
+        MatnrPrintTemplate normalized = prepareTemplateForSave(template, false);
+        long currentCount = this.count();
+        boolean shouldDefault = Objects.equals(normalized.getIsDefault(), 1) || currentCount == 0;
+        normalized.setIsDefault(shouldDefault ? 1 : 0);
+        if (shouldDefault) {
+            clearCurrentTenantDefaults();
+        }
+        if (!this.save(normalized)) {
+            throw new CoolException("妯℃澘淇濆瓨澶辫触");
+        }
+        return this.getCurrentTenantTemplate(normalized.getId());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public MatnrPrintTemplate updateTemplate(MatnrPrintTemplate template) {
+        if (template == null || template.getId() == null) {
+            throw new CoolException("妯℃澘ID涓嶈兘涓虹┖");
+        }
+        MatnrPrintTemplate existing = getCurrentTenantTemplate(template.getId());
+        MatnrPrintTemplate normalized = prepareTemplateForSave(template, true);
+        normalized.setTenantId(existing.getTenantId());
+        normalized.setCreateBy(existing.getCreateBy());
+        normalized.setCreateTime(existing.getCreateTime());
+        normalized.setDeleted(existing.getDeleted());
+        boolean shouldDefault = Objects.equals(normalized.getIsDefault(), 1);
+        if (shouldDefault) {
+            clearCurrentTenantDefaults();
+        } else if (Objects.equals(existing.getIsDefault(), 1)) {
+            normalized.setIsDefault(1);
+        }
+        if (!this.updateById(normalized)) {
+            throw new CoolException("妯℃澘鏇存柊澶辫触");
+        }
+        ensureOneDefaultTemplate();
+        return this.getCurrentTenantTemplate(normalized.getId());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean removeTemplates(List<Long> ids) {
+        if (ids == null || ids.isEmpty()) {
+            throw new CoolException("璇烽�夋嫨瑕佸垹闄ょ殑妯℃澘");
+        }
+        List<MatnrPrintTemplate> templates = this.listByIds(ids);
+        if (templates == null || templates.isEmpty()) {
+            return true;
+        }
+        boolean removedDefault = templates.stream().anyMatch(item -> Objects.equals(item.getIsDefault(), 1));
+        if (!this.removeByIds(ids)) {
+            throw new CoolException("妯℃澘鍒犻櫎澶辫触");
+        }
+        if (removedDefault) {
+            ensureOneDefaultTemplate();
+        }
+        return true;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean setDefaultTemplate(Long id) {
+        MatnrPrintTemplate template = getCurrentTenantTemplate(id);
+        clearCurrentTenantDefaults();
+        boolean updated = this.update(new LambdaUpdateWrapper<MatnrPrintTemplate>()
+                .eq(MatnrPrintTemplate::getId, template.getId())
+                .set(MatnrPrintTemplate::getIsDefault, 1)
+                .set(MatnrPrintTemplate::getUpdateBy, resolveCurrentUserId())
+                .set(MatnrPrintTemplate::getUpdateTime, new Date())
+        );
+        if (!updated) {
+            throw new CoolException("榛樿妯℃澘璁剧疆澶辫触");
+        }
+        return true;
+    }
+
+    private MatnrPrintTemplate prepareTemplateForSave(MatnrPrintTemplate template, boolean updating) {
+        if (template == null) {
+            throw new CoolException("妯℃澘鍙傛暟涓嶈兘涓虹┖");
+        }
+        Long currentTenantId = resolveCurrentTenantId();
+        Long currentUserId = resolveCurrentUserId();
+        if (currentTenantId == null) {
+            throw new CoolException("褰撳墠绉熸埛淇℃伅缂哄け");
+        }
+        String name = normalizeText(template.getName());
+        String code = normalizeText(template.getCode());
+        if (name.isEmpty()) {
+            throw new CoolException("妯℃澘鍚嶇О涓嶈兘涓虹┖");
+        }
+        if (code.isEmpty()) {
+            throw new CoolException("妯℃澘缂栫爜涓嶈兘涓虹┖");
+        }
+        Map<String, Object> canvasJson = template.getCanvasJson();
+        if (canvasJson == null || canvasJson.isEmpty()) {
+            throw new CoolException("妯℃澘鐢诲竷涓嶈兘涓虹┖");
+        }
+        validateCanvasJson(canvasJson);
+        ensureTemplateCodeUnique(code, updating ? template.getId() : null);
+
+        Date now = new Date();
+        template.setTenantId(currentTenantId)
+                .setName(name)
+                .setCode(code)
+                .setStatus(template.getStatus() == null ? 1 : template.getStatus())
+                .setIsDefault(Objects.equals(template.getIsDefault(), 1) ? 1 : 0)
+                .setMemo(normalizeText(template.getMemo()))
+                .setUpdateBy(currentUserId)
+                .setUpdateTime(now);
+        if (!updating) {
+            template.setCreateBy(currentUserId);
+            template.setCreateTime(now);
+        }
+        return template;
+    }
+
+    private void ensureTemplateCodeUnique(String code, Long excludeId) {
+        long duplicateCount = this.count(new LambdaQueryWrapper<MatnrPrintTemplate>()
+                .eq(MatnrPrintTemplate::getCode, code)
+                .ne(excludeId != null, MatnrPrintTemplate::getId, excludeId)
+        );
+        if (duplicateCount > 0) {
+            throw new CoolException("妯℃澘缂栫爜宸插瓨鍦紝璇锋洿鎹㈠悗閲嶈瘯");
+        }
+    }
+
+    private void clearCurrentTenantDefaults() {
+        this.update(new LambdaUpdateWrapper<MatnrPrintTemplate>()
+                .eq(MatnrPrintTemplate::getIsDefault, 1)
+                .set(MatnrPrintTemplate::getIsDefault, 0)
+                .set(MatnrPrintTemplate::getUpdateBy, resolveCurrentUserId())
+                .set(MatnrPrintTemplate::getUpdateTime, new Date())
+        );
+    }
+
+    private void ensureOneDefaultTemplate() {
+        long defaultCount = this.count(new LambdaQueryWrapper<MatnrPrintTemplate>()
+                .eq(MatnrPrintTemplate::getIsDefault, 1)
+        );
+        if (defaultCount > 0) {
+            return;
+        }
+        MatnrPrintTemplate newest = this.getOne(new LambdaQueryWrapper<MatnrPrintTemplate>()
+                .orderByDesc(MatnrPrintTemplate::getUpdateTime)
+                .orderByDesc(MatnrPrintTemplate::getCreateTime)
+                .last("limit 1")
+        );
+        if (newest == null) {
+            return;
+        }
+        this.update(new LambdaUpdateWrapper<MatnrPrintTemplate>()
+                .eq(MatnrPrintTemplate::getId, newest.getId())
+                .set(MatnrPrintTemplate::getIsDefault, 1)
+                .set(MatnrPrintTemplate::getUpdateBy, resolveCurrentUserId())
+                .set(MatnrPrintTemplate::getUpdateTime, new Date())
+        );
+    }
+
+    private void validateCanvasJson(Map<String, Object> canvasJson) {
+        JSONObject root = JSONObject.parseObject(JSON.toJSONString(canvasJson));
+        if (root == null) {
+            throw new CoolException("妯℃澘鐢诲竷鏍煎紡涓嶆纭�");
+        }
+        if (root.getInteger("version") == null) {
+            throw new CoolException("妯℃澘鐗堟湰涓嶈兘涓虹┖");
+        }
+        JSONObject canvas = root.getJSONObject("canvas");
+        if (canvas == null) {
+            throw new CoolException("妯℃澘鐢诲竷閰嶇疆涓嶈兘涓虹┖");
+        }
+        double width = getPositiveNumber(canvas, "width", "鐢诲竷瀹藉害");
+        double height = getPositiveNumber(canvas, "height", "鐢诲竷楂樺害");
+        if (width <= 0 || height <= 0) {
+            throw new CoolException("鐢诲竷灏哄蹇呴』澶т簬0");
+        }
+        String unit = normalizeText(canvas.getString("unit"));
+        if (!"mm".equals(unit)) {
+            throw new CoolException("鐢诲竷鍗曚綅浠呮敮鎸� mm");
+        }
+        JSONArray elements = root.getJSONArray("elements");
+        if (elements == null) {
+            throw new CoolException("妯℃澘鍏冪礌涓嶈兘涓虹┖");
+        }
+        for (int index = 0; index < elements.size(); index++) {
+            JSONObject element = elements.getJSONObject(index);
+            if (element == null) {
+                throw new CoolException("妯℃澘鍏冪礌鏍煎紡涓嶆纭�");
+            }
+            validateElement(element, index);
+        }
+    }
+
+    private void validateElement(JSONObject element, int index) {
+        String type = normalizeText(element.getString("type"));
+        if (!SUPPORTED_ELEMENT_TYPES.contains(type)) {
+            throw new CoolException("绗�" + (index + 1) + "涓厓绱犵被鍨嬩笉鏀寔");
+        }
+        if (normalizeText(element.getString("id")).isEmpty()) {
+            throw new CoolException("绗�" + (index + 1) + "涓厓绱犵己灏� ID");
+        }
+        ensureNumber(element, "x", "鍏冪礌 X 鍧愭爣");
+        ensureNumber(element, "y", "鍏冪礌 Y 鍧愭爣");
+        if (!"line".equals(type)) {
+            getPositiveNumber(element, "w", "鍏冪礌瀹藉害");
+            getPositiveNumber(element, "h", "鍏冪礌楂樺害");
+        } else {
+            String direction = normalizeText(element.getString("direction"));
+            if (!Arrays.asList("horizontal", "vertical").contains(direction)) {
+                throw new CoolException("绾挎潯鍏冪礌鏂瑰悜浠呮敮鎸� horizontal 鎴� vertical");
+            }
+            getPositiveNumber(element, "w", "绾挎潯闀垮害");
+            getPositiveNumber(element, "h", "绾挎潯绮楃粏");
+        }
+
+        switch (type) {
+            case "text":
+                String contentMode = normalizeText(element.getString("contentMode"));
+                if (!Arrays.asList("static", "template").contains(contentMode)) {
+                    throw new CoolException("鏂囨湰鍏冪礌鍐呭妯″紡涓嶆敮鎸�");
+                }
+                if (normalizeText(element.getString("contentTemplate")).isEmpty()) {
+                    throw new CoolException("鏂囨湰鍏冪礌鍐呭涓嶈兘涓虹┖");
+                }
+                break;
+            case "barcode":
+                if (normalizeText(element.getString("valueTemplate")).isEmpty()) {
+                    throw new CoolException("鏉$爜鍏冪礌鍊兼ā鏉夸笉鑳戒负绌�");
+                }
+                String symbology = normalizeText(element.getString("symbology"));
+                if (!symbology.isEmpty() && !"CODE128".equals(symbology)) {
+                    throw new CoolException("涓�缁寸爜浠呮敮鎸� CODE128");
+                }
+                break;
+            case "qrcode":
+                if (normalizeText(element.getString("valueTemplate")).isEmpty()) {
+                    throw new CoolException("浜岀淮鐮佸厓绱犲�兼ā鏉夸笉鑳戒负绌�");
+                }
+                break;
+            case "table":
+                if (element.getJSONArray("columns") == null) {
+                    throw new CoolException("琛ㄦ牸鍏冪礌 columns 涓嶈兘涓虹┖");
+                }
+                if (element.getJSONArray("rows") == null) {
+                    throw new CoolException("琛ㄦ牸鍏冪礌 rows 涓嶈兘涓虹┖");
+                }
+                if (element.getJSONArray("cells") == null) {
+                    throw new CoolException("琛ㄦ牸鍏冪礌 cells 涓嶈兘涓虹┖");
+                }
+                break;
+            default:
+                break;
+        }
+    }
+
+    private void ensureNumber(JSONObject object, String key, String label) {
+        if (object.getBigDecimal(key) == null) {
+            throw new CoolException(label + "涓嶈兘涓虹┖");
+        }
+    }
+
+    private double getPositiveNumber(JSONObject object, String key, String label) {
+        if (object.getBigDecimal(key) == null) {
+            throw new CoolException(label + "涓嶈兘涓虹┖");
+        }
+        double value = object.getBigDecimal(key).doubleValue();
+        if (value <= 0) {
+            throw new CoolException(label + "蹇呴』澶т簬0");
+        }
+        return value;
+    }
+
+    private String normalizeText(String value) {
+        return value == null ? "" : value.trim();
+    }
+
+    private Long resolveCurrentTenantId() {
+        User loginUser = getCurrentUser();
+        return loginUser == null ? null : loginUser.getTenantId();
+    }
+
+    private Long resolveCurrentUserId() {
+        User loginUser = getCurrentUser();
+        return loginUser == null ? null : loginUser.getId();
+    }
+
+    private User getCurrentUser() {
+        try {
+            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+            if (authentication != null && authentication.getPrincipal() instanceof User) {
+                return (User) authentication.getPrincipal();
+            }
+        } catch (Exception ignored) {
+        }
+        return null;
+    }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/AuthController.java b/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/AuthController.java
index adb7030..9a47288 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/AuthController.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/AuthController.java
@@ -294,7 +294,7 @@
         if (menu.getParentId() == null || menu.getParentId() == 0L) {
             return "/index/index";
         }
-        return "/"+menu.getComponent();
+        return menu.getRoute();
     }
 
     private String resolveRoutePath(Menu menu) {
diff --git a/rsf-server/src/main/resources/sql/20260413_matnr_print_template.sql b/rsf-server/src/main/resources/sql/20260413_matnr_print_template.sql
new file mode 100644
index 0000000..7d08e14
--- /dev/null
+++ b/rsf-server/src/main/resources/sql/20260413_matnr_print_template.sql
@@ -0,0 +1,19 @@
+CREATE TABLE IF NOT EXISTS `man_matnr_print_template` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '涓婚敭ID',
+  `tenant_id` bigint NOT NULL COMMENT '绉熸埛ID',
+  `name` varchar(100) NOT NULL COMMENT '妯℃澘鍚嶇О',
+  `code` varchar(100) NOT NULL COMMENT '妯℃澘缂栫爜',
+  `is_default` tinyint NOT NULL DEFAULT 0 COMMENT '鏄惁榛樿妯℃澘 1鏄� 0鍚�',
+  `status` tinyint NOT NULL DEFAULT 1 COMMENT '鐘舵�� 1姝e父 0鍐荤粨',
+  `canvas_json` longtext NOT NULL COMMENT '鐢诲竷JSON',
+  `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '鏄惁鍒犻櫎 1鏄� 0鍚�',
+  `create_by` bigint DEFAULT NULL COMMENT '鍒涘缓浜�',
+  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '鍒涘缓鏃堕棿',
+  `update_by` bigint DEFAULT NULL COMMENT '鏇存柊浜�',
+  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '鏇存柊鏃堕棿',
+  `memo` varchar(500) DEFAULT NULL COMMENT '澶囨敞',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_matnr_print_template_tenant_code_deleted` (`tenant_id`, `code`, `deleted`),
+  KEY `idx_matnr_print_template_tenant_default` (`tenant_id`, `is_default`),
+  KEY `idx_matnr_print_template_tenant_status` (`tenant_id`, `status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='鐗╂枡鎵撳嵃妯℃澘';
diff --git a/rsf-server/src/main/resources/sql/20260413_matnr_print_template_menu.sql b/rsf-server/src/main/resources/sql/20260413_matnr_print_template_menu.sql
new file mode 100644
index 0000000..59a636d
--- /dev/null
+++ b/rsf-server/src/main/resources/sql/20260413_matnr_print_template_menu.sql
@@ -0,0 +1,380 @@
+-- 鐗╂枡鎵撳嵃妯℃澘宸︿晶鐙珛鑿滃崟 + 鏉冮檺鎸夐挳
+-- 璇存槑锛�
+-- 1. 鍦ㄢ�滃熀纭�淇℃伅鈥濅笅鏂板鈥滅墿鏂欐墦鍗版ā鏉库�濈嫭绔嬭彍鍗�
+-- 2. 鑿滃崟璺敱涓� /basic-info/matnr-print-template锛岀粍浠堕敭涓� matnrPrintTemplate
+-- 3. 鑻ユ鍓嶅凡鎵ц鏃х増鈥滄寕鍦ㄧ墿鏂欓〉涓嬬殑鎸夐挳鏉冮檺鈥濊剼鏈紝閲嶅鎵ц鏈剼鏈細鑷姩杩佺Щ鎸夐挳鍒版柊鑿滃崟涓�
+
+SET @tenant_id := 1;
+
+SET @basic_info_menu_id := COALESCE(
+  (
+    SELECT parent_id
+    FROM sys_menu
+    WHERE deleted = 0
+      AND tenant_id = @tenant_id
+      AND type = 0
+      AND (
+        route = '/basic-info/wh-mat'
+        OR component IN ('matnr', 'whMat')
+        OR name = 'menu.matnr'
+      )
+    ORDER BY id
+    LIMIT 1
+  ),
+  (
+    SELECT id
+    FROM sys_menu
+    WHERE deleted = 0
+      AND tenant_id = @tenant_id
+      AND type = 0
+      AND (
+        route = '/basic-info'
+        OR component = 'basicInfo'
+        OR name = 'menu.basicInfo'
+      )
+    ORDER BY id
+    LIMIT 1
+  )
+);
+
+INSERT INTO sys_menu (
+  name,
+  parent_id,
+  parent_name,
+  path,
+  path_name,
+  route,
+  component,
+  brief,
+  code,
+  type,
+  authority,
+  icon,
+  sort,
+  meta,
+  tenant_id,
+  status,
+  deleted,
+  create_time,
+  create_by,
+  update_time,
+  update_by,
+  memo
+)
+SELECT
+  'menu.matnrPrintTemplate',
+  @basic_info_menu_id,
+  'menu.basicInfo',
+  'matnr-print-template',
+  'matnrPrintTemplate',
+  '/basic-info/matnr-print-template',
+  'matnrPrintTemplate',
+  '鐗╂枡鎵撳嵃妯℃澘鐙珛鑿滃崟',
+  NULL,
+  0,
+  NULL,
+  'ri:price-tag-3-line',
+  96,
+  NULL,
+  @tenant_id,
+  1,
+  0,
+  NOW(),
+  1,
+  NOW(),
+  1,
+  '鐗╂枡鎵撳嵃妯℃澘宸︿晶鑿滃崟'
+FROM dual
+WHERE @basic_info_menu_id IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1
+    FROM sys_menu
+    WHERE deleted = 0
+      AND tenant_id = @tenant_id
+      AND type = 0
+      AND (
+        route = '/basic-info/matnr-print-template'
+        OR component = 'matnrPrintTemplate'
+        OR name = 'menu.matnrPrintTemplate'
+      )
+  );
+
+SET @template_menu_id := (
+  SELECT id
+  FROM sys_menu
+  WHERE deleted = 0
+    AND tenant_id = @tenant_id
+    AND type = 0
+    AND (
+      route = '/basic-info/matnr-print-template'
+      OR component = 'matnrPrintTemplate'
+      OR name = 'menu.matnrPrintTemplate'
+    )
+  ORDER BY id
+  LIMIT 1
+);
+
+UPDATE sys_menu
+SET
+  name = 'menu.matnrPrintTemplate',
+  parent_id = @basic_info_menu_id,
+  parent_name = 'menu.basicInfo',
+  path = 'matnr-print-template',
+  path_name = 'matnrPrintTemplate',
+  route = '/basic-info/matnr-print-template',
+  component = 'matnrPrintTemplate',
+  brief = '鐗╂枡鎵撳嵃妯℃澘鐙珛鑿滃崟',
+  type = 0,
+  authority = NULL,
+  icon = 'ri:price-tag-3-line',
+  sort = 96,
+  status = 1,
+  update_time = NOW(),
+  update_by = 1,
+  memo = '鐗╂枡鎵撳嵃妯℃澘宸︿晶鑿滃崟'
+WHERE @template_menu_id IS NOT NULL
+  AND id = @template_menu_id;
+
+UPDATE sys_menu
+SET
+  parent_id = @template_menu_id,
+  parent_name = 'menu.matnrPrintTemplate',
+  update_time = NOW(),
+  update_by = 1
+WHERE @template_menu_id IS NOT NULL
+  AND deleted = 0
+  AND tenant_id = @tenant_id
+  AND authority IN (
+    'manager:matnrPrintTemplate:list',
+    'manager:matnrPrintTemplate:save',
+    'manager:matnrPrintTemplate:update',
+    'manager:matnrPrintTemplate:remove'
+  );
+
+INSERT INTO sys_menu (
+  name,
+  parent_id,
+  parent_name,
+  path,
+  path_name,
+  route,
+  component,
+  brief,
+  code,
+  type,
+  authority,
+  icon,
+  sort,
+  meta,
+  tenant_id,
+  status,
+  deleted,
+  create_time,
+  create_by,
+  update_time,
+  update_by,
+  memo
+)
+SELECT
+  'Query 鐗╂枡鎵撳嵃妯℃澘',
+  @template_menu_id,
+  'menu.matnrPrintTemplate',
+  NULL,
+  NULL,
+  NULL,
+  NULL,
+  '鐗╂枡鎵撳嵃妯℃澘璇诲彇鏉冮檺',
+  NULL,
+  1,
+  'manager:matnrPrintTemplate:list',
+  NULL,
+  10,
+  NULL,
+  @tenant_id,
+  1,
+  0,
+  NOW(),
+  1,
+  NOW(),
+  1,
+  '鐗╂枡鎵撳嵃妯℃澘鍒楄〃銆佽鎯呫�侀粯璁ゆā鏉裤�佹墦鍗拌鍙�'
+FROM dual
+WHERE @template_menu_id IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1
+    FROM sys_menu
+    WHERE deleted = 0
+      AND tenant_id = @tenant_id
+      AND authority = 'manager:matnrPrintTemplate:list'
+  );
+
+INSERT INTO sys_menu (
+  name,
+  parent_id,
+  parent_name,
+  path,
+  path_name,
+  route,
+  component,
+  brief,
+  code,
+  type,
+  authority,
+  icon,
+  sort,
+  meta,
+  tenant_id,
+  status,
+  deleted,
+  create_time,
+  create_by,
+  update_time,
+  update_by,
+  memo
+)
+SELECT
+  'Create 鐗╂枡鎵撳嵃妯℃澘',
+  @template_menu_id,
+  'menu.matnrPrintTemplate',
+  NULL,
+  NULL,
+  NULL,
+  NULL,
+  '鐗╂枡鎵撳嵃妯℃澘鏂板鏉冮檺',
+  NULL,
+  1,
+  'manager:matnrPrintTemplate:save',
+  NULL,
+  11,
+  NULL,
+  @tenant_id,
+  1,
+  0,
+  NOW(),
+  1,
+  NOW(),
+  1,
+  '鐗╂枡鎵撳嵃妯℃澘鏂板'
+FROM dual
+WHERE @template_menu_id IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1
+    FROM sys_menu
+    WHERE deleted = 0
+      AND tenant_id = @tenant_id
+      AND authority = 'manager:matnrPrintTemplate:save'
+  );
+
+INSERT INTO sys_menu (
+  name,
+  parent_id,
+  parent_name,
+  path,
+  path_name,
+  route,
+  component,
+  brief,
+  code,
+  type,
+  authority,
+  icon,
+  sort,
+  meta,
+  tenant_id,
+  status,
+  deleted,
+  create_time,
+  create_by,
+  update_time,
+  update_by,
+  memo
+)
+SELECT
+  'Update 鐗╂枡鎵撳嵃妯℃澘',
+  @template_menu_id,
+  'menu.matnrPrintTemplate',
+  NULL,
+  NULL,
+  NULL,
+  NULL,
+  '鐗╂枡鎵撳嵃妯℃澘淇敼鏉冮檺',
+  NULL,
+  1,
+  'manager:matnrPrintTemplate:update',
+  NULL,
+  12,
+  NULL,
+  @tenant_id,
+  1,
+  0,
+  NOW(),
+  1,
+  NOW(),
+  1,
+  '鐗╂枡鎵撳嵃妯℃澘缂栬緫銆佽榛樿'
+FROM dual
+WHERE @template_menu_id IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1
+    FROM sys_menu
+    WHERE deleted = 0
+      AND tenant_id = @tenant_id
+      AND authority = 'manager:matnrPrintTemplate:update'
+  );
+
+INSERT INTO sys_menu (
+  name,
+  parent_id,
+  parent_name,
+  path,
+  path_name,
+  route,
+  component,
+  brief,
+  code,
+  type,
+  authority,
+  icon,
+  sort,
+  meta,
+  tenant_id,
+  status,
+  deleted,
+  create_time,
+  create_by,
+  update_time,
+  update_by,
+  memo
+)
+SELECT
+  'Delete 鐗╂枡鎵撳嵃妯℃澘',
+  @template_menu_id,
+  'menu.matnrPrintTemplate',
+  NULL,
+  NULL,
+  NULL,
+  NULL,
+  '鐗╂枡鎵撳嵃妯℃澘鍒犻櫎鏉冮檺',
+  NULL,
+  1,
+  'manager:matnrPrintTemplate:remove',
+  NULL,
+  13,
+  NULL,
+  @tenant_id,
+  1,
+  0,
+  NOW(),
+  1,
+  NOW(),
+  1,
+  '鐗╂枡鎵撳嵃妯℃澘鍒犻櫎'
+FROM dual
+WHERE @template_menu_id IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1
+    FROM sys_menu
+    WHERE deleted = 0
+      AND tenant_id = @tenant_id
+      AND authority = 'manager:matnrPrintTemplate:remove'
+  );

--
Gitblit v1.9.1