zhou zhou
17 小时以前 28c6a76ead9b65a0b5861d70f0838ef2a46f5c45
#barcode
15个文件已添加
10个文件已修改
5568 ■■■■■ 已修改文件
rsf-design/package.json 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/pnpm-lock.yaml 81 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/wh-mat.js 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/langs/en.json 167 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/langs/zh.json 167 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/router/adapters/backendMenuAdapter.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/utils/backend-menu-title.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/matnr-print-template/index.vue 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/wh-mat/index.vue 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/wh-mat/matnrPrintTemplate.helpers.js 827 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-canvas.vue 571 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-field-panel.vue 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-property-panel.vue 859 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-toolbar.vue 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-print-dialog.vue 694 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-print-template-workspace.vue 749 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/LocItemController.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/MatnrPrintTemplateController.java 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/entity/MatnrPrintTemplate.java 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/mapper/MatnrPrintTemplateMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/MatnrPrintTemplateService.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/MatnrPrintTemplateServiceImpl.java 376 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/controller/AuthController.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/resources/sql/20260413_matnr_print_template.sql 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/resources/sql/20260413_matnr_print_template_menu.sql 380 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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",
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: {}
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}`
  })
}
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",
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": "批量修改状态",
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)
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'
  }
rsf-design/src/views/basic-info/matnr-print-template/index.vue
New file
@@ -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>
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 {
rsf-design/src/views/basic-info/wh-mat/matnrPrintTemplate.helpers.js
New file
@@ -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 ''
  }
}
rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-canvas.vue
New file
@@ -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>
rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-field-panel.vue
New file
@@ -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>
rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-property-panel.vue
New file
@@ -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>
rsf-design/src/views/basic-info/wh-mat/modules/matnr-print-toolbar.vue
New file
@@ -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>
rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-print-dialog.vue
New file
@@ -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>
rsf-design/src/views/basic-info/wh-mat/modules/wh-mat-print-template-workspace.vue
New file
@@ -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>
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);
rsf-server/src/main/java/com/vincent/rsf/server/manager/controller/MatnrPrintTemplateController.java
New file
@@ -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");
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/manager/entity/MatnrPrintTemplate.java
New file
@@ -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: 正常 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$;
}
rsf-server/src/main/java/com/vincent/rsf/server/manager/mapper/MatnrPrintTemplateMapper.java
New file
@@ -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> {
}
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/MatnrPrintTemplateService.java
New file
@@ -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);
}
rsf-server/src/main/java/com/vincent/rsf/server/manager/service/impl/MatnrPrintTemplateServiceImpl.java
New file
@@ -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;
    }
}
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) {
rsf-server/src/main/resources/sql/20260413_matnr_print_template.sql
New file
@@ -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正常 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='物料打印模板';
rsf-server/src/main/resources/sql/20260413_matnr_print_template_menu.sql
New file
@@ -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'
  );