云
10 小时以前 fb4db4ae9b6a7f113ba049f6789e6916c0dcc276
feat(mes): 新增业务码支持并优化追溯查询功能

- 在批次、托盘、袋码表中新增 business_code 字段及唯一约束
- 实现内部扫码兼容业务码与历史追溯码的查询机制
- 添加补生成已有码业务码的功能接口
- 优化托盘绑定时品种信息的自动解析和合并逻辑
- 完善批次入库时有效袋码的进度计算机制
- 更新追溯响应VO增加入库时间等关键字段
- 重构批次预生成逻辑使用统一编码生成器分配码值
- 优化页面列表查询支持业务码模糊匹配搜索
已修改33个文件
已添加23个文件
1923 ■■■■■ 文件已修改
index.html 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pnpm-lock.yaml 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/browser-check.html 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/startup-check.js 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/core/auth.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/bagCode/index.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/batch/index.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/pallet/index.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/scanEvent/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/qc/seedQuality/index.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/productsales/index.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/wm/productsales/line/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/request.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/table-action/table-action.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/layouts/auth.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/mobile/access.ts 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/mobile/api.ts 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/mobile/icon.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/mobile/routes.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/mobile/scroll.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/mobile/terminal.ts 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/effects/common-ui/src/ui/authentication/login.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/stores/src/modules/tabbar.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/guard.ts 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes/core.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/auth.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/_core/authentication/login.vue 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/bagCode/data.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/bagCode/index.vue 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/batch/data.ts 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/batch/index.vue 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/batch/modules/form.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/components/business-qr.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/components/qr-trace-modal.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/pallet/data.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/pallet/index.vue 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/home.vue 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/inbound.vue 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/layout.vue 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/mine.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/mobile.css 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/picker.vue 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/quality-records.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/quality.vue 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/query.vue 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/records.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/sales.vue 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/scanner.vue 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mobile/stock.vue 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/data.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/modules/detail-list.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/modules/form.vue 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/wls/productsales/modules/picking-bags.vue 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
index.html
@@ -9,7 +9,7 @@
    <meta name="author" content="Vben" />
    <meta
      name="viewport"
      content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
      content="width=device-width,initial-scale=1.0,viewport-fit=cover"
    />
    <!-- ç”± vite æ³¨å…¥ VITE_APP_TITLE å˜é‡ï¼Œåœ¨ .env æ–‡ä»¶å†…配置 -->
    <title>%VITE_APP_TITLE%</title>
@@ -29,6 +29,7 @@
  </head>
  <body>
    <div id="app"></div>
    <script data-startup-check src="%BASE_URL%startup-check.js"></script>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
package.json
@@ -60,13 +60,14 @@
    "cropperjs": "catalog:",
    "dayjs": "catalog:",
    "dhtmlx-gantt": "catalog:",
    "echarts": "catalog:",
    "diagram-js": "catalog:",
    "echarts": "catalog:",
    "fast-xml-parser": "catalog:",
    "gsap": "^3.15.0",
    "highlight.js": "catalog:",
    "html-to-image": "^1.11.13",
    "jspdf": "^4.2.1",
    "jsqr": "^1.4.0",
    "livekit-client": "^2.20.0",
    "pinia": "catalog:",
    "sortablejs": "catalog:",
pnpm-lock.yaml
@@ -662,6 +662,9 @@
      jspdf:
        specifier: ^4.2.1
        version: 4.2.1
      jsqr:
        specifier: ^1.4.0
        version: 1.4.0
      livekit-client:
        specifier: ^2.20.0
        version: 2.20.0(@types/dom-mediacapture-record@1.0.22)
@@ -3186,8 +3189,8 @@
  '@lezer/common@1.5.2':
    resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
  '@lezer/highlight@1.2.3':
    resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
  '@lezer/highlight@1.2.5':
    resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==}
  '@lezer/json@1.0.3':
    resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
@@ -7198,6 +7201,9 @@
  jspdf@4.2.1:
    resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==}
  jsqr@1.4.0:
    resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==}
  katex@0.16.47:
    resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
    hasBin: true
@@ -8246,12 +8252,12 @@
    resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
    engines: {node: '>= 0.4'}
  regenerate-unicode-properties@10.2.2:
    resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==}
  regenerate-unicode-properties@10.3.0:
    resolution: {integrity: sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==}
    engines: {node: '>=4'}
  regenerate@1.4.2:
    resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==}
  regenerate@1.5.0:
    resolution: {integrity: sha512-1s+CxXjJ71i7LNVy8nYwg47XV4y6lAnOEhsMtSKq3WpmPfhl9LuKlTqKIn/3yUpE8lBz+W+TT88Bxyhnr0U4yw==}
  regenerator-runtime@0.13.11:
    resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
@@ -8260,8 +8266,8 @@
    resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
    engines: {node: '>= 0.4'}
  regexpu-core@6.4.0:
    resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==}
  regexpu-core@6.5.3:
    resolution: {integrity: sha512-E8VgUoSK+LMQrLboDxavm76MTBGrQhx9RiNuomRmYnZ10Uw12HrI8fkxOOncktb2hBpzIZ9SV/347euQHG5z+g==}
    engines: {node: '>=4'}
  regjsgen@0.8.0:
@@ -8863,8 +8869,8 @@
  strnum@1.0.5:
    resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
  style-mod@4.1.3:
    resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
  style-mod@4.1.4:
    resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
  style-search@0.1.0:
    resolution: {integrity: sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==}
@@ -9233,8 +9239,8 @@
    resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
    engines: {node: '>= 0.4'}
  typed-array-byte-offset@1.0.4:
    resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
  typed-array-byte-offset@1.0.5:
    resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==}
    engines: {node: '>= 0.4'}
  typed-array-length@1.0.8:
@@ -9953,8 +9959,8 @@
  zeebe-bpmn-moddle@1.15.0:
    resolution: {integrity: sha512-C40qyX4q79lECM6JyEVGjBqDhgPgGf19T3waOPUtlW+ZuWXl/tvuAsrBwHhJssWbAFJAGYdJ9urPF80EijUmLg==}
  zimmerframe@1.1.4:
    resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
  zimmerframe@1.1.5:
    resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==}
  zip-stream@6.0.1:
    resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
@@ -10070,7 +10076,7 @@
    dependencies:
      '@babel/core': 7.29.7
      '@babel/helper-annotate-as-pure': 7.29.7
      regexpu-core: 6.4.0
      regexpu-core: 6.5.3
      semver: 6.3.1
  '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)':
@@ -10740,7 +10746,7 @@
    dependencies:
      '@codemirror/language': 6.12.3
      '@codemirror/view': 6.43.1
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.5
  '@bpmn-io/diagram-js-ui@0.2.4':
    dependencies:
@@ -10763,7 +10769,7 @@
      '@codemirror/lint': 6.9.7
      '@codemirror/state': 6.6.0
      '@codemirror/view': 6.43.1
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.5
      min-dom: 5.3.0
      mitt: 3.0.1
@@ -10788,13 +10794,13 @@
  '@bpmn-io/lezer-feel@2.5.0':
    dependencies:
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.5
      '@lezer/lr': 1.4.10
      min-dash: 5.0.0
  '@bpmn-io/lezer-feel@3.0.1':
    dependencies:
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.5
      '@lezer/lr': 1.4.10
      min-dash: 5.0.0
@@ -10879,9 +10885,9 @@
      '@codemirror/state': 6.6.0
      '@codemirror/view': 6.43.1
      '@lezer/common': 1.5.2
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.5
      '@lezer/lr': 1.4.10
      style-mod: 4.1.3
      style-mod: 4.1.4
  '@codemirror/lint@6.9.7':
    dependencies:
@@ -10897,7 +10903,7 @@
    dependencies:
      '@codemirror/state': 6.6.0
      crelt: 1.0.7
      style-mod: 4.1.3
      style-mod: 4.1.4
      w3c-keyname: 2.2.8
  '@commitlint/cli@19.8.0(@types/node@25.9.4)(typescript@6.0.3)':
@@ -11586,14 +11592,14 @@
  '@lezer/common@1.5.2': {}
  '@lezer/highlight@1.2.3':
  '@lezer/highlight@1.2.5':
    dependencies:
      '@lezer/common': 1.5.2
  '@lezer/json@1.0.3':
    dependencies:
      '@lezer/common': 1.5.2
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.5
      '@lezer/lr': 1.4.10
  '@lezer/lr@1.4.10':
@@ -11603,7 +11609,7 @@
  '@lezer/markdown@1.6.4':
    dependencies:
      '@lezer/common': 1.5.2
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.5
  '@livekit/mutex@1.1.1': {}
@@ -14374,7 +14380,7 @@
      string.prototype.trimstart: 1.0.8
      typed-array-buffer: 1.0.3
      typed-array-byte-length: 1.0.3
      typed-array-byte-offset: 1.0.4
      typed-array-byte-offset: 1.0.5
      typed-array-length: 1.0.8
      unbox-primitive: 1.1.0
      which-typed-array: 1.1.22
@@ -14798,7 +14804,7 @@
      '@codemirror/state': 6.6.0
      '@codemirror/view': 6.43.1
      '@lezer/common': 1.5.2
      '@lezer/highlight': 1.2.3
      '@lezer/highlight': 1.2.5
      '@lezer/lr': 1.4.10
      '@lezer/markdown': 1.6.4
      min-dom: 5.3.0
@@ -15594,6 +15600,8 @@
      core-js: 3.49.0
      dompurify: 3.4.11
      html2canvas: 1.4.1
  jsqr@1.4.0: {}
  katex@0.16.47:
    dependencies:
@@ -16754,11 +16762,11 @@
      get-proto: 1.0.1
      which-builtin-type: 1.2.1
  regenerate-unicode-properties@10.2.2:
  regenerate-unicode-properties@10.3.0:
    dependencies:
      regenerate: 1.4.2
      regenerate: 1.5.0
  regenerate@1.4.2: {}
  regenerate@1.5.0: {}
  regenerator-runtime@0.13.11:
    optional: true
@@ -16772,10 +16780,10 @@
      gopd: 1.2.0
      set-function-name: 2.0.2
  regexpu-core@6.4.0:
  regexpu-core@6.5.3:
    dependencies:
      regenerate: 1.4.2
      regenerate-unicode-properties: 10.2.2
      regenerate: 1.5.0
      regenerate-unicode-properties: 10.3.0
      regjsgen: 0.8.0
      regjsparser: 0.13.2
      unicode-match-property-ecmascript: 2.0.0
@@ -17437,7 +17445,7 @@
  strnum@1.0.5: {}
  style-mod@4.1.3: {}
  style-mod@4.1.4: {}
  style-search@0.1.0: {}
@@ -17604,7 +17612,7 @@
      is-reference: 3.0.3
      locate-character: 3.0.0
      magic-string: 0.30.21
      zimmerframe: 1.1.4
      zimmerframe: 1.1.5
    transitivePeerDependencies:
      - '@typescript-eslint/types'
@@ -17843,13 +17851,12 @@
      has-proto: 1.2.0
      is-typed-array: 1.1.15
  typed-array-byte-offset@1.0.4:
  typed-array-byte-offset@1.0.5:
    dependencies:
      available-typed-arrays: 1.0.7
      call-bind: 1.0.9
      for-each: 0.3.5
      gopd: 1.2.0
      has-proto: 1.2.0
      is-typed-array: 1.1.15
      reflect.getprototypeof: 1.0.10
@@ -18669,7 +18676,7 @@
  zeebe-bpmn-moddle@1.15.0: {}
  zimmerframe@1.1.4: {}
  zimmerframe@1.1.5: {}
  zip-stream@6.0.1:
    dependencies:
public/browser-check.html
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="zh">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>工业终端浏览器检测</title>
  <style>
    body{margin:0;padding:20px;font:16px/1.6 sans-serif;color:#0f172a;background:#f8fafc}
    main{max-width:480px;margin:auto}h1{font-size:22px}p{word-break:break-word}
    table{width:100%;border-collapse:collapse;background:white}td{padding:10px;border-bottom:1px solid #cbd5e1}
    .fail{color:#b91c1c}.pass{color:#166534}button,a{display:block;min-height:48px;font-size:16px;margin-top:16px}
    #agent{padding:12px;background:white;word-break:break-all}
  </style>
</head>
<body><main>
  <h1>工业终端浏览器检测</h1>
  <p>此页面不依赖平台框架。请将浏览器信息及“不支持”的检测项提供给管理员。</p>
  <p id="agent"></p>
  <table aria-label="浏览器能力"><tbody id="checks"></tbody></table>
  <p>全部通过仅代表基础能力可用,仍需根据平台启动信息检查依赖和网络。摄像头权限与硬件扫码是不同功能。</p>
  <button id="reload" type="button">重新检测</button><a href="./">返回平台</a>
  <noscript>浏览器禁用了 JavaScript,请先在浏览器设置中启用。</noscript>
</main><script>
(function () {
  document.getElementById('agent').textContent = navigator.userAgent;
  function syntax(code) { try { new Function(code); return true; } catch (error) { return false; } }
  function check(name, supported) {
    var row = document.createElement('tr');
    var title = document.createElement('td');
    var value = document.createElement('td');
    title.textContent = name; value.textContent = supported ? '支持' : '不支持';
    value.className = supported ? 'pass' : 'fail';
    row.appendChild(title); row.appendChild(value); document.getElementById('checks').appendChild(row);
  }
  check('模块脚本', 'noModule' in document.createElement('script'));
  check('动态模块导入', syntax('return import("./check.js")'));
  check('可选链 / ç©ºå€¼åˆå¹¶', syntax('var a = {}; return a?.b ?? 0'));
  check('异步函数', syntax('return async function () {}'));
  check('Proxy', typeof Proxy === 'function');
  check('Promise', typeof Promise === 'function');
  check('ResizeObserver', typeof ResizeObserver === 'function');
  check('dialog å¼¹å±‚', typeof document.createElement('dialog').showModal === 'function');
  check('本地存储', (function () { try { var key = 'mom-browser-check-' + new Date().getTime(); localStorage.setItem(key, '1'); localStorage.removeItem(key); return true; } catch (error) { return false; } }()));
  check('安全环境(摄像头需要)', window.isSecureContext === true);
  document.getElementById('reload').onclick = function () { window.location.reload(); };
}());
</script></body></html>
public/startup-check.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
/* Keep this file in ES5 syntax: it must run even when the application cannot. */
(function () {
  var finished = false;
  var lastError = '';
  var timer;
  function errorText(error) {
    return String(error && error.message ? error.message : error || '未知启动错误').slice(0, 300);
  }
  function showFailure(error) {
    if (finished || document.getElementById('mom-startup-error')) return;
    var box = document.createElement('section');
    box.id = 'mom-startup-error';
    box.setAttribute('role', 'alert');
    box.style.cssText = 'position:fixed;z-index:99999;left:0;right:0;top:0;bottom:0;overflow:auto;background:#fff;color:#0f172a;padding:24px;box-sizing:border-box;font:16px/1.6 sans-serif;';
    function line(tag, text) {
      var element = document.createElement(tag);
      element.textContent = text;
      element.style.wordBreak = 'break-word';
      box.appendChild(element);
      return element;
    }
    line('h2', '平台未能完成启动');
    line('p', '可能是浏览器兼容性或脚本加载失败。请打开检测页,将检测结果提供给管理员。');
    line('p', '启动信息:' + errorText(error));
    line('p', '浏览器:' + navigator.userAgent);
    var link = line('a', '打开浏览器检测页');
    link.href = document.querySelector('script[data-startup-check]').src.replace(/[^/]*$/, 'browser-check.html');
    link.style.cssText = 'display:block;min-height:48px;color:#0369a1;';
    var button = line('button', '重新加载');
    button.style.cssText = 'min-height:48px;padding:8px 20px;font-size:16px;';
    button.onclick = function () { window.location.reload(); };
    document.body.appendChild(box);
  }
  window.__MOM_STARTUP__ = {
    finish: function () {
      finished = true;
      window.clearTimeout(timer);
      var box = document.getElementById('mom-startup-error');
      if (box) box.parentNode.removeChild(box);
    },
    fail: function (error) { showFailure(error); }
  };
  window.addEventListener('error', function (event) {
    if (event.message) lastError = errorText(event.message);
    else if (event.target && event.target.tagName === 'SCRIPT') lastError = '脚本文件加载失败';
  }, true);
  window.addEventListener('unhandledrejection', function (event) { lastError = errorText(event.reason); });
  timer = window.setTimeout(function () {
    showFailure(lastError || '30 ç§’内未完成初始化;请检查浏览器能力与脚本请求。');
  }, 30000);
}());
src/api/core/auth.ts
@@ -92,9 +92,10 @@
}
/** èŽ·å–æƒé™ä¿¡æ¯ */
export async function getAuthPermissionInfoApi() {
export async function getAuthPermissionInfoApi(terminalType: 'pc' | 'mobile' = 'pc') {
  return requestClient.get<AuthPermissionInfo>(
    '/system/auth/get-permission-info',
    { params: { terminalType } },
  );
}
src/api/mes/pro/bagCode/index.ts
@@ -5,9 +5,11 @@
export namespace MesProBagCodeApi {
  /** è¢‹ç ï¼ˆä¸€è¢‹ä¸€ç ï¼‰ */
  export interface BagCode {
    inboundTime?: string;
    id?: number; // ç¼–号
    uuid?: string; // ç”Ÿæˆç»„ UUID
    code?: string; // è¢‹ç ï¼ˆäºŒç»´ç å†…容)
    businessCode?: string; // å†…部出入库业务码(纯码值)
    bagNo?: number; // æ‰¹æ¬¡å†…袋序号
    batchId?: number; // ç”Ÿäº§æ‰¹æ¬¡ç¼–号
    batchCode?: string; // æ‰¹æ¬¡å·
@@ -29,6 +31,7 @@
  /** è¢‹ç åˆ†é¡µæŸ¥è¯¢å‚æ•° */
  export interface PageParams extends PageParam {
    code?: string;
    businessCode?: string;
    batchId?: number;
    batchCode?: string;
    uuid?: string;
@@ -107,8 +110,10 @@
  /** æ‰«ç è¿½æº¯ä¿¡æ¯ */
  export interface BagCodeTrace {
    inboundTime?: string;
    id?: number; // è¢‹ç ç¼–号
    code?: string; // è¢‹ç ï¼ˆäºŒç»´ç å†…容)
    businessCode?: string; // å†…部出入库业务码
    bagNo?: number; // è¢‹åºå·
    status?: number; // è¢‹ç çŠ¶æ€
    printTime?: string; // å¯¼å‡ºå°åˆ·æ—¶é—´
@@ -226,3 +231,13 @@
export function exportBagQr(params: { uuid?: string; batchId?: number }) {
  return requestClient.download('/mes/pro/bag-code/export-qr', { params });
}
/** è¡¥ç”Ÿæˆå·²æœ‰è¢‹ç çš„业务码,重复操作不更换已有码值。 */
export function generateBagBusinessCodes(ids: number[]) {
  return requestClient.post<number>('/mes/pro/bag-code/generate-business-code', { ids });
}
/** ä¸šåŠ¡äºŒç»´ç ä»…ç¼–ç å­˜å‚¨çš„ä¸šåŠ¡ç å€¼ï¼Œä¸æ‹¼æŽ¥è¿½æº¯ç½‘å€ã€‚ */
export function exportBagBusinessQr(params: { uuid?: string; batchId?: number }) {
  return requestClient.download('/mes/pro/bag-code/export-business-qr', { params });
}
src/api/mes/pro/batch/index.ts
@@ -18,6 +18,10 @@
  /** ç”Ÿäº§æ‰¹æ¬¡ */
  export interface Batch {
    id?: number; // ç¼–号
    itemId?: number; // æ‰˜ç›˜å¸¦å…¥çš„品种编号
    itemCode?: string;
    itemName?: string;
    businessCode?: string; // å†…部出入库业务码
    code?: string; // æ‰¹æ¬¡å·ï¼ˆè¡¥å½•正式信息后自动生成)
    tempCode?: string; // ä¸´æ—¶æ‰¹æ¬¡ç ï¼ˆé¢„生成二维码阶段使用,扫码追溯兼容)
    lineId?: number; // äº§çº¿ç¼–号(页面展示为「加工单位」)
@@ -39,7 +43,10 @@
  /** ç”Ÿäº§æ‰¹æ¬¡æ‰«ç è¿½æº¯ */
  export interface BatchTrace {
    inboundBagQuantity?: number;
    pendingBagQuantity?: number;
    id?: number; // ç”Ÿäº§æ‰¹æ¬¡ç¼–号
    businessCode?: string; // å†…部出入库业务码
    code?: string; // æ‰¹æ¬¡å·ï¼ˆè¡¥å½•后的正式批次号)
    tempCode?: string; // ä¸´æ—¶æ‰¹æ¬¡ç ï¼ˆäºŒç»´ç å†…容;未补录信息时展示)
    status?: number; // æ‰¹æ¬¡çŠ¶æ€ï¼ˆå­—å…¸ mes_pro_batch_status)
@@ -64,6 +71,7 @@
  /** ç”Ÿäº§æ‰¹æ¬¡åˆ†é¡µæŸ¥è¯¢å‚æ•° */
  export interface PageParams extends PageParam {
    businessCode?: string; // å†…部出入库业务码
    code?: string;
    tempCode?: string;
    lineId?: number;
@@ -78,6 +86,7 @@
  /** é¢„生成批次二维码返回项 */
  export interface PreGenerateResult {
    businessCode?: string;
    id: number; // ç”Ÿäº§æ‰¹æ¬¡ç¼–号
    tempCode: string; // ä¸´æ—¶æ‰¹æ¬¡ç ï¼ˆäºŒç»´ç å†…容)
  }
@@ -157,3 +166,11 @@
export function exportBatchQr(batchId: number) {
  return requestClient.download(`/mes/pro/batch/export-qr?batchId=${batchId}`);
}
export function generateBatchBusinessCodes(ids: number[]) {
  return requestClient.post<number>('/mes/pro/batch/generate-business-code', { ids });
}
export function exportBatchBusinessQr(batchId: number) {
  return requestClient.download('/mes/pro/batch/export-business-qr', { params: { batchId } });
}
src/api/mes/pro/pallet/index.ts
@@ -6,7 +6,12 @@
export namespace MesProPalletApi {
  /** æ‰˜ç›˜ç  */
  export interface Pallet {
    inboundTime?: string;
    id?: number; // ç¼–号
    itemId?: number; // æ‰˜ç›˜å¸¦å…¥çš„品种编号
    itemCode?: string;
    itemName?: string;
    businessCode?: string; // å†…部出入库业务码
    code?: string; // æ‰˜ç›˜ç ï¼ˆç»‘定批次后生成)
    tempCode?: string; // ä¸´æ—¶æ‰˜ç›˜ç ï¼ˆé¢„生成二维码阶段使用,扫码追溯兼容)
    batchId?: number; // ç”Ÿäº§æ‰¹æ¬¡ç¼–号
@@ -21,6 +26,7 @@
  /** æ‰˜ç›˜ç åˆ†é¡µæŸ¥è¯¢å‚æ•° */
  export interface PageParams extends PageParam {
    businessCode?: string; // å†…部出入库业务码
    code?: string;
    tempCode?: string;
    batchId?: number;
@@ -31,7 +37,10 @@
  /** æ‰˜ç›˜ç æ‰«ç è¿½æº¯ */
  export interface PalletTrace {
    inboundTime?: string;
    pendingBagQuantity?: number;
    id?: number; // ç¼–号
    businessCode?: string; // å†…部出入库业务码
    code?: string; // æ‰˜ç›˜ç ï¼ˆç»‘定批次后的正式托盘码)
    tempCode?: string; // ä¸´æ—¶æ‰˜ç›˜ç ï¼ˆäºŒç»´ç å†…容;未绑定批次时展示)
    palletNo?: number; // æ‰¹æ¬¡å†…托盘序号
@@ -63,6 +72,7 @@
  /** é¢„生成托盘二维码返回项 */
  export interface PreGenerateResult {
    businessCode?: string;
    id: number; // æ‰˜ç›˜ç ç¼–号
    tempCode: string; // ä¸´æ—¶æ‰˜ç›˜ç ï¼ˆäºŒç»´ç å†…容)
  }
@@ -147,3 +157,11 @@
    data: { ids },
  });
}
export function generatePalletBusinessCodes(ids: number[]) {
  return requestClient.post<number>('/mes/pro/pallet/generate-business-code', { ids });
}
export function exportPalletBusinessQr(ids: number[]) {
  return requestClient.download('/mes/pro/pallet/export-business-qr-by-ids', { method: 'POST', data: { ids } });
}
src/api/mes/pro/scanEvent/index.ts
@@ -16,7 +16,8 @@
    deviceCode?: string;
    lineCode?: string;
    warehouseId?: number;
    eventTime?: string;
    locationId?: number;
    areaId?: number;
    transactionId?: number;
    status?: 'SUCCESS' | 'FAILED';
    errorCode?: string;
src/api/mes/qc/seedQuality/index.ts
@@ -43,3 +43,16 @@
export function submitSeedQuality(data: MesQcSeedQualityApi.SubmitParams) {
  return requestClient.post<number>('/mes/qc/seed-quality/submit', data);
}
export interface SeedQualityScan {
  code: string; businessCode?: string; batchCode?: string;
  inbound: boolean; canceled: boolean; qualityId?: number; result?: number;
  scanType?: 'BAG' | 'PALLET' | 'BATCH'; totalCount?: number;
  pendingBagIds?: number[]; completedBagIds?: number[];
}
export function resolveSeedQuality(code: string, scanType: 'BAG' | 'PALLET' | 'BATCH' = 'BAG') {
  return requestClient.get<SeedQualityScan>('/mes/qc/seed-quality/resolve', { params: { code, scanType } });
}
export function submitSeedQualityGroup(data: MesQcSeedQualityApi.SubmitParams & { scanType: 'BAG' | 'PALLET' | 'BATCH'; bagIds: number[] }) {
  return requestClient.post<{ processedCount: number }>('/mes/qc/seed-quality/submit-group', data);
}
src/api/mes/wm/productsales/index.ts
@@ -31,6 +31,17 @@
    salesId: number;
    scanContent: string;
    quantity?: number;
    scanType?: 'BAG' | 'PALLET' | 'BATCH';
    bagIds?: number[];
  }
  export interface PickingBag {
    id: number;
    code: string;
    businessCode?: string;
    palletId?: number;
    selectable: boolean;
    unavailableReason?: string;
  }
  export interface ProductSalesScanResponse {
@@ -55,11 +66,13 @@
    bagCount?: number;
    inboundBagCount?: number;
    message?: string;
    bags?: PickingBag[];
    selectedBagIds?: number[];
  }
}
/** æŸ¥è¯¢é”€å”®å‡ºåº“单分页 */
export function getProductSalesPage(params: PageParam) {
export function getProductSalesPage(params: PageParam & { code?: string; status?: number }) {
  return requestClient.get<PageResult<MesWmProductSalesApi.ProductSales>>(
    '/mes/wm/product-sales/page',
    { params },
@@ -103,6 +116,10 @@
  );
}
export function previewProductSalesScan(code: string, scanType?: string, salesId?: number) {
  return requestClient.get<MesWmProductSalesApi.ProductSalesScanResponse>('/mes/wm/product-sales/scan-preview', { params: { code, scanType, salesId } });
}
/** æ‰§è¡Œæ‹£è´§ */
export function stockProductSales(id: number) {
  return requestClient.put(`/mes/wm/product-sales/stock?id=${id}`);
src/api/mes/wm/productsales/line/index.ts
@@ -24,7 +24,7 @@
}
/** æŸ¥è¯¢é”€å”®å‡ºåº“单行分页 */
export function getProductSalesLinePage(params: PageParam) {
export function getProductSalesLinePage(params: PageParam & { salesId?: number }) {
  return requestClient.get<
    PageResult<MesWmProductSalesLineApi.ProductSalesLine>
  >('/mes/wm/product-sales-line/page', { params });
src/api/request.ts
@@ -44,7 +44,7 @@
    ) {
      accessStore.setLoginExpired(true);
    } else {
      await authStore.logout();
      await authStore.logout(true, true);
    }
  }
src/components/table-action/table-action.vue
@@ -38,6 +38,10 @@
    type: Boolean,
    default: true,
  },
  dropdownTrigger: {
    type: Array as PropType<('click' | 'hover' | 'contextmenu')[]>,
    default: () => ['hover'],
  },
});
const { hasAccessByCodes } = useAccess();
@@ -203,7 +207,7 @@
      </template>
    </Space>
    <Dropdown v-if="getDropdownList.length > 0" :trigger="['hover']">
    <Dropdown v-if="getDropdownList.length > 0" :trigger="dropdownTrigger">
      <slot name="more">
        <Button type="link">
          <template #icon>
src/layouts/auth.vue
@@ -5,6 +5,7 @@
import { preferences } from '@vben/preferences';
import { $t } from '#/locales';
import { terminalType } from '#/mobile/terminal';
declare const __APP_LOGO__: string;
@@ -14,7 +15,12 @@
</script>
<template>
  <main v-if="terminalType === 'mobile'" class="mobile-login-shell">
    <header><strong>MOM</strong><span>{{appName}}</span></header>
    <section><RouterView /></section>
  </main>
  <AuthPageLayout
    v-else
    :app-name="appName"
    :logo="logo"
    :logo-dark="logoDark"
@@ -25,3 +31,14 @@
    <!-- <template #toolbar></template> -->
  </AuthPageLayout>
</template>
<style scoped>
.mobile-login-shell { width:100%;max-width:480px;min-height:100dvh;margin:0 auto;padding:24px 24px 32px;background:#f8fafc;color:#0f172a;color-scheme:light; }
.mobile-login-shell header { display:flex;align-items:center;gap:12px;margin:8px 0 32px;font-size:18px; }
.mobile-login-shell header strong { color:#0369a1;font-size:20px; }
.mobile-login-shell section { padding:24px 20px;border:1px solid #e2e8f0;border-radius:12px;background:#fff; }
.mobile-login-shell :deep(input) { font-size:16px;min-height:48px; }
.mobile-login-shell :deep(button[aria-label^='登录']) { min-height:52px;background:#0369a1;box-shadow:none;border-radius:8px;letter-spacing:normal; }
.mobile-login-shell :deep(.dark) { color-scheme:light; }
@media(max-width:380px) { .mobile-login-shell {padding:16px;} .mobile-login-shell section {padding:20px 16px;} }
</style>
src/main.ts
@@ -7,6 +7,12 @@
declare const __APP_FAVICON__: string;
declare const __APP_NAME__: string;
declare global {
  interface Window {
    __MOM_STARTUP__?: { finish: () => void; fail: (error: unknown) => void };
  }
}
/**
 * åº”用初始化完成之后再进行页面加载渲染
 */
@@ -53,6 +59,10 @@
  // ç§»é™¤å¹¶é”€æ¯loading
  unmountGlobalLoading();
  window.__MOM_STARTUP__?.finish();
}
initApplication();
initApplication().catch((error: unknown) => {
  window.__MOM_STARTUP__?.fail(error);
  console.error('Application startup failed', error);
});
src/mobile/access.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
import { useAccessStore } from '@vben/stores';
export const features = [
  { key: 'inbound', title: '扫码入库', hint: '生产暂存库', icon: 'scan', permission: 'mes:pro-scan-in:inbound' },
  { key: 'bags', title: '袋码查询', hint: '查询与绑定托盘', icon: 'bag', permission: 'mes:pro-bag-code:query' },
  { key: 'pallets', title: '托盘查询', hint: '查询与绑定批次', icon: 'box', permission: 'mes:pro-pallet:query' },
  { key: 'batches', title: '批次查询', hint: '查看生产信息', icon: 'layers', permission: 'mes:pro-batch:query' },
  { key: 'stock', title: '库存查询', hint: '品种、批次与库位', icon: 'warehouse', permission: 'mes:wm-material-stock:query' },
  { key: 'quality', title: '种子质检', hint: '袋码、托盘与批次', icon: 'scan', permission: 'mes:qc-seed-quality:submit' },
  { key: 'quality-records', title: '质检记录', hint: '结果与不合格原因', icon: 'records', permission: 'mes:qc-seed-quality:query' },
  { key: 'sales', title: '销售出库', hint: '拣货、运单与出库', icon: 'box', permission: 'mes:wm-product-sales:query' },
] as const;
export function can(permission: string) { return useAccessStore().accessCodes.includes(permission); }
export function availableFeatures() {
  const store = useAccessStore();
  return features.filter(f => can(f.permission) && store.accessMenus.some(m => m.path === '/mobile/' + f.key));
}
export function allowedMobilePath(path: string) {
  const key = path.split('/')[2];
  if (['home', 'work', 'mine'].includes(key || '')) return true;
  if (key === 'records') return can('mes:pro-scan-in:query') && useAccessStore().accessMenus.some(m => m.path === '/mobile/inbound');
  return availableFeatures().some(f => f.key === key);
}
export function extractCode(raw: string) {
  let value = raw.trim();
  if (!value) throw new Error('请扫描或输入码值');
  if (/^https?:\/\//i.test(value)) {
    const code = new URL(value).searchParams.get('code');
    if (!code) throw new Error('该链接不包含业务码值');
    value = code.trim();
  }
  if (!value || value.length > 64 || /\s/.test(value)) throw new Error('码值格式不正确');
  return value;
}
src/mobile/api.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
import { requestClient } from '#/api/request';
import { getBagCodeByCode, resolveTraceType } from '#/api/mes/pro/bagCode';
import { getPalletByCode } from '#/api/mes/pro/pallet';
import { getBatchByCode } from '#/api/mes/pro/batch';
import { can, extractCode } from './access';
import type { MesProScanEventApi } from '#/api/mes/pro/scanEvent';
export interface TraceObject {
  id?: number; code?: string; businessCode?: string; tempCode?: string; itemName?: string; itemId?: number;
  batchId?: number; batchCode?: string; lineName?: string; produceDate?: string;
  palletId?: number; palletCode?: string; bagCount?: number; bagQuantity?: number; status?: number;
  batchStatus?: number;
  inboundTime?: string; inboundBagQuantity?: number; pendingBagQuantity?: number;
}
export async function lookup(raw: string) {
  const code = extractCode(raw);
  const type = await resolveTraceType(code);
  if (!type) throw new Error('未找到对应袋码、托盘或批次');
  const permission = { bag: 'mes:pro-bag-code:query', pallet: 'mes:pro-pallet:query', batch: 'mes:pro-batch:query' }[type];
  if (!can(permission)) throw new Error('当前账号没有该码类型的查询权限');
  const data: TraceObject = await ({ bag: getBagCodeByCode, pallet: getPalletByCode, batch: getBatchByCode }[type])(code);
  if (!data?.id) throw new Error('未找到对象信息');
  return { code, type, data };
}
export type TraceResult = Awaited<ReturnType<typeof lookup>>;
export function writeBlockReason(trace: TraceResult) {
  const canceled={bag:30,pallet:20,batch:40}[trace.type];
  if(trace.data.status===canceled)return '该'+{bag:'袋码',pallet:'托盘',batch:'批次'}[trace.type]+'已作废,不可执行写操作';
  if(trace.data.batchStatus===40)return '该对象所属批次已作废,不可执行写操作';
  return '';
}
export function myRecords(params: { pageNo: number; pageSize: number; status?: string; eventId?: string; receivedTime?: string[] }) {
  return requestClient.get<{ list: MesProScanEventApi.ScanEvent[]; total: number }>('/mes/pro/scan-in/my-page', { params });
}
export function myEvent(eventId: string) {
  return requestClient.get<MesProScanEventApi.ScanEvent | null>('/mes/pro/scan-in/my-event', { params: { eventId } });
}
export function errorText(error: unknown) {
  const e = error as { message?: string; data?: { msg?: string }; response?: { data?: { msg?: string } } };
  return e?.response?.data?.msg || e?.data?.msg || e?.message || '请求失败,请稍后重试';
}
src/mobile/icon.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{ name: string }>();
const paths: Record<string,string> = {
  home:'M3 10 12 3l9 7v10H3z M9 20v-7h6v7',
  scan:'M3 8V3h5 M16 3h5v5 M21 16v5h-5 M8 21H3v-5 M5 12h14',
  bag:'M6 7h12l2 14H4z M9 7V4h6v3',
  box:'m3 7 9-4 9 4v10l-9 4-9-4z M3 7l9 4 9-4 M12 11v10',
  layers:'m3 7 9-4 9 4-9 4z M3 12l9 4 9-4 M3 17l9 4 9-4',
  warehouse:'M3 21V8l9-5 9 5v13 M7 21V11h10v10 M7 15h10 M7 18h10',
  work:'M4 7h16v14H4z M9 7V3h6v4 M8 12h8 M8 16h5',
  records:'M5 3h14v18H5z M9 8h6 M9 12h6 M9 16h4',
  mine:'M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0 M4 21v-3a8 8 0 0 1 16 0v3',
  back:'m15 5-7 7 7 7', arrow:'m9 5 7 7-7 7',
};
</script>
<template><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path :d="paths[name] || paths.work"/></svg></template>
src/mobile/routes.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
import type { RouteRecordRaw } from 'vue-router';
export const mobileRoutes: RouteRecordRaw[] = [{
  path: '/mobile', name: 'MobileRoot', component: () => import('#/views/mobile/layout.vue'),
  redirect: '/mobile/home', meta: { title: '现场作业', hideInMenu: true },
  children: [
    { path: 'home', name: 'MobileHome', component: () => import('#/views/mobile/home.vue'), meta: { title: '工作台' } },
    { path: 'work', name: 'MobileWork', component: () => import('#/views/mobile/home.vue'), meta: { title: '作业' } },
    { path: 'mine', name: 'MobileMine', component: () => import('#/views/mobile/mine.vue'), meta: { title: '我的' } },
    { path: 'inbound', name: 'MobileInbound', component: () => import('#/views/mobile/inbound.vue'), meta: { title: '扫码入库' } },
    { path: 'records', name: 'MobileRecords', component: () => import('#/views/mobile/records.vue'), meta: { title: '作业记录' } },
    ...(['bags','pallets','batches'] as const).map(key => ({ path: key, name: 'MobileQuery-' + key, component: () => import('#/views/mobile/query.vue'), meta: { title: { bags: '袋码查询', pallets: '托盘查询', batches: '批次查询' }[key] } })),
    { path: 'stock', name: 'MobileStock', component: () => import('#/views/mobile/stock.vue'), meta: { title: '库存查询' } },
    { path: 'quality', name: 'MobileQuality', component: () => import('#/views/mobile/quality.vue'), meta: { title: '种子质检' } },
    { path: 'quality-records', name: 'MobileQualityRecords', component: () => import('#/views/mobile/quality-records.vue'), meta: { title: '质检记录' } },
    { path: 'sales', name: 'MobileSales', component: () => import('#/views/mobile/sales.vue'), meta: { title: '销售出库' } },
  ],
}];
src/mobile/scroll.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,5 @@
const positions = new Map<string, number>();
const cached = new Set(['/mobile/bags', '/mobile/pallets', '/mobile/batches', '/mobile/stock']);
export function saveMobileScroll(path: string) { if (cached.has(path)) positions.set(path, window.scrollY); }
export function mobileScroll(path: string) { const top=positions.get(path);return top==null?undefined:{left:0,top}; }
export function clearMobileScroll() { positions.clear(); }
src/mobile/terminal.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
import { ref } from 'vue';
import { clearMobileScroll } from './scroll';
export type TerminalType = 'pc' | 'mobile';
const KEY = 'mom-terminal';
function initial(): TerminalType {
  const stored = sessionStorage.getItem(KEY);
  return stored === 'pc' || stored === 'mobile' ? stored : (window.innerWidth < 768 ? 'mobile' : 'pc');
}
export const terminalType = ref<TerminalType>(initial());
export function selectTerminal(value: TerminalType) {
  terminalType.value = value;
  sessionStorage.setItem(KEY, value);
}
export function resetTerminal() {
  clearMobileScroll();
  sessionStorage.removeItem(KEY);
  for (const key of Object.keys(sessionStorage)) {
    if (key.startsWith('mom-mobile-')) sessionStorage.removeItem(key);
  }
  terminalType.value = window.innerWidth < 768 ? 'mobile' : 'pc';
}
export function terminalHome() { return terminalType.value === 'mobile' ? '/mobile/home' : undefined; }
export function safeDestination(path: unknown, fallback: string) {
  if (typeof path !== 'string') return fallback;
  let decoded: string;
  try { decoded = decodeURIComponent(path); } catch { return fallback; }
  if (!decoded.startsWith('/') || decoded.startsWith('//') || decoded.includes('\\') || decoded.startsWith('/auth')) return fallback;
  return decoded.startsWith('/mobile/') === (terminalType.value === 'mobile') ? decoded : fallback;
}
src/packages/effects/common-ui/src/ui/authentication/login.vue
@@ -19,6 +19,7 @@
interface Props extends AuthenticationProps {
  formSchema?: VbenFormSchema[];
  showFieldLabels?: boolean;
}
defineOptions({
@@ -33,6 +34,7 @@
  qrCodeLoginPath: '/auth/qrcode-login',
  registerPath: '/auth/register',
  showCodeLogin: true,
  showFieldLabels: false,
  showDocLink: true,
  showForgetPassword: true,
  showQrcodeLogin: true,
@@ -52,10 +54,11 @@
const [Form, formApi] = useVbenForm(
  reactive({
    commonConfig: {
      hideLabel: true,
      hideLabel: computed(() => !props.showFieldLabels),
      hideRequiredMark: true,
    },
    schema: computed(() => props.formSchema),
    layout: computed(() => props.showFieldLabels ? 'vertical' as const : 'horizontal' as const),
    showDefaultActions: false,
  }),
);
@@ -152,7 +155,7 @@
        'cursor-wait': loading,
      }"
      :loading="loading"
      aria-label="login"
      :aria-label="submitButtonText || $t('common.login')"
      class="h-12 w-full rounded-xl bg-emerald-600 text-base font-semibold tracking-[0.18em] text-white shadow-[0_10px_24px_rgba(5,150,105,0.22)] transition-[background-color,box-shadow] duration-200 hover:bg-emerald-700 hover:shadow-[0_12px_28px_rgba(5,150,105,0.3)] focus-visible:ring-2 focus-visible:ring-emerald-500/50 disabled:cursor-not-allowed disabled:opacity-70"
      @click="handleSubmit"
    >
src/packages/stores/src/modules/tabbar.ts
@@ -587,7 +587,7 @@
    affixTabs(): TabDefinition[] {
      const affixTabs = this.tabs.filter((tab) => isAffixTab(tab));
      return affixTabs.toSorted((a, b) => {
      return affixTabs.sort((a, b) => {
        const orderA = (a.meta?.affixTabOrder ?? 0) as number;
        const orderB = (b.meta?.affixTabOrder ?? 0) as number;
        return orderA - orderB;
src/router/guard.ts
@@ -13,6 +13,8 @@
import { useAuthStore } from '#/store';
import { generateAccess } from './access';
import { safeDestination, terminalHome, terminalType } from '#/mobile/terminal';
import { allowedMobilePath } from '#/mobile/access';
/**
 * é€šç”¨å®ˆå«é…ç½®
@@ -55,14 +57,24 @@
    const authStore = useAuthStore();
    const dictStore = useDictStore();
    // Mobile core routes require authentication and per-feature authorization.
    if (to.path.startsWith('/mobile')) {
      if (!accessStore.accessToken) return { path: LOGIN_PATH, query: { redirect: encodeURIComponent(to.fullPath) }, replace: true };
      if (terminalType.value !== 'mobile') return preferences.app.defaultHomePath;
      if (authStore.loadedTerminal !== 'mobile') {
        try { await authStore.fetchUserInfo(); }
        catch { return { path: LOGIN_PATH, query: { loadError: '1' }, replace: true }; }
      }
      if (!allowedMobilePath(to.path)) return { path: '/mobile/home', query: { forbidden: '1' }, replace: true };
      return true;
    }
    if (accessStore.accessToken && terminalType.value === 'mobile' && !to.path.startsWith('/auth/')) return '/mobile/home';
    // åŸºæœ¬è·¯ç”±ï¼Œè¿™äº›è·¯ç”±ä¸éœ€è¦è¿›å…¥æƒé™æ‹¦æˆª
    if (coreRouteNames.includes(to.name as string)) {
      if (to.path === LOGIN_PATH && accessStore.accessToken) {
        return decodeURIComponent(
          (to.query?.redirect as string) ||
            userStore.userInfo?.homePath ||
            preferences.app.defaultHomePath,
        );
      if (to.path === LOGIN_PATH && accessStore.accessToken && !to.query.loadError) {
        return safeDestination(to.query?.redirect,
          terminalHome() || userStore.userInfo?.homePath || preferences.app.defaultHomePath);
      }
      return true;
    }
@@ -90,6 +102,15 @@
      return to;
    }
    // A fresh tab or a terminal switch must reload the corresponding menu set.
    if (authStore.loadedTerminal !== 'pc') {
      try {
        await authStore.fetchUserInfo();
        accessStore.setIsAccessChecked(false);
      } catch {
        return { path: LOGIN_PATH, query: { loadError: '1' }, replace: true };
      }
    }
    // æ˜¯å¦å·²ç»ç”Ÿæˆè¿‡åŠ¨æ€è·¯ç”±
    if (accessStore.isAccessChecked) {
      return true;
src/router/index.ts
@@ -9,6 +9,7 @@
import { createRouterGuard } from './guard';
import { routes } from './routes';
import { setupBaiduTongJi } from './tongji';
import { mobileScroll } from '#/mobile/scroll';
/**
 *  @zh_CN åˆ›å»ºvue-router实例
@@ -24,6 +25,8 @@
    if (savedPosition) {
      return savedPosition;
    }
    const mobilePosition=mobileScroll(to.path);
    if(mobilePosition)return mobilePosition;
    return to.hash ? { behavior: 'smooth', el: to.hash } : { left: 0, top: 0 };
  },
  // æ˜¯å¦åº”该禁止尾部斜杠。
src/router/routes/core.ts
@@ -4,6 +4,7 @@
import { preferences } from '@vben/preferences';
import { $t } from '#/locales';
import { mobileRoutes } from '#/mobile/routes';
const BasicLayout = () => import('#/layouts/basic.vue');
const AuthPageLayout = () => import('#/layouts/auth.vue');
@@ -22,6 +23,7 @@
/** åŸºæœ¬è·¯ç”±ï¼Œè¿™äº›è·¯ç”±æ˜¯å¿…须存在的 */
const coreRoutes: RouteRecordRaw[] = [
  ...mobileRoutes,
  /**
   * æ ¹è·¯ç”±
   * ä½¿ç”¨åŸºç¡€å¸ƒå±€ï¼Œä½œä¸ºæ‰€æœ‰é¡µé¢çš„父级容器,子级就不必配置BasicLayout。
src/store/auth.ts
@@ -21,6 +21,7 @@
  socialLogin,
} from '#/api';
import { $t } from '#/locales';
import { resetTerminal, safeDestination, terminalHome, terminalType } from '#/mobile/terminal';
export const useAuthStore = defineStore('auth', () => {
  const accessStore = useAccessStore();
@@ -28,6 +29,7 @@
  const router = useRouter();
  const loginLoading = ref(false);
  const loadedTerminal = ref<string>();
  /**
   * å¼‚步处理登录操作
@@ -67,6 +69,7 @@
      // å¦‚果成功获取到 accessToken
      if (accessToken) {
        accessStore.setIsAccessChecked(false);
        accessStore.setAccessToken(accessToken);
        accessStore.setRefreshToken(refreshToken);
@@ -87,11 +90,12 @@
          onSuccess
            ? await onSuccess?.()
            : await router.push(
                userInfo.homePath || preferences.app.defaultHomePath,
                safeDestination(router.currentRoute.value.query.redirect,
                  terminalHome() || userInfo.homePath || preferences.app.defaultHomePath),
              );
        }
        if (userInfo?.nickname) {
        if (userInfo?.nickname && terminalType.value === 'pc') {
          notification.success({
            description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
            duration: 3,
@@ -108,7 +112,7 @@
    };
  }
  async function logout(redirect: boolean = true) {
  async function logout(redirect: boolean = true, preserveTerminal: boolean = false) {
    try {
      const accessToken = accessStore.accessToken as string;
      if (accessToken) {
@@ -118,6 +122,8 @@
      // ä¸åšä»»ä½•处理
    }
    resetAllStores();
    if (!preserveTerminal) resetTerminal();
    loadedTerminal.value = undefined;
    accessStore.setLoginExpired(false);
    // å›žç™»å½•页带上当前路由地址
@@ -135,18 +141,20 @@
    // åŠ è½½
    // eslint-disable-next-line no-useless-assignment
    let authPermissionInfo: AuthPermissionInfo | null = null;
    authPermissionInfo = await getAuthPermissionInfoApi();
    authPermissionInfo = await getAuthPermissionInfoApi(terminalType.value);
    // userStore
    userStore.setUserInfo(authPermissionInfo.user);
    userStore.setUserRoles(authPermissionInfo.roles);
    // accessStore
    accessStore.setAccessMenus(authPermissionInfo.menus);
    accessStore.setAccessCodes(authPermissionInfo.permissions);
    loadedTerminal.value = terminalType.value;
    return authPermissionInfo;
  }
  function $reset() {
    loginLoading.value = false;
    loadedTerminal.value = undefined;
  }
  return {
@@ -154,6 +162,7 @@
    authLogin,
    fetchUserInfo,
    loginLoading,
    loadedTerminal,
    logout,
  };
});
src/views/_core/authentication/login.vue
@@ -11,10 +11,15 @@
import { checkCaptcha, getCaptcha } from '#/api/core/auth';
import { useAuthStore } from '#/store';
import { selectTerminal, terminalType } from '#/mobile/terminal';
import { errorText } from '#/mobile/api';
import { useRoute } from 'vue-router';
defineOptions({ name: 'Login' });
const authStore = useAuthStore();
const route = useRoute();
const loginError = ref(route.query.loadError ? '菜单加载失败,请重新登录后重试。' : '');
const captchaEnable = isCaptchaEnable();
const loginRef = ref();
@@ -28,7 +33,9 @@
    verifyRef.value.show();
    return;
  }
  await authStore.authLogin('username', values);
  loginError.value = '';
  try { await authStore.authLogin('username', values); }
  catch (e) { loginError.value = errorText(e); }
}
/** éªŒè¯ç é€šè¿‡ï¼Œæ‰§è¡Œç™»å½• */
@@ -37,10 +44,11 @@
}: {
  captchaVerification: string;
}) {
  await authStore.authLogin('username', {
  loginError.value = '';
  try { await authStore.authLogin('username', {
    ...(await loginRef.value.getFormApi().getValues()),
    captchaVerification,
  });
  }); } catch (e) { loginError.value = errorText(e); }
}
const formSchema = computed((): VbenFormSchema[] => {
@@ -79,12 +87,28 @@
<template>
  <div>
    <fieldset class="terminal-selector">
      <legend>选择使用端</legend>
      <div class="terminal-options">
        <label :class="{ selected: terminalType === 'mobile' }">
          <input type="radio" name="terminal" value="mobile" :checked="terminalType === 'mobile'" :disabled="authStore.loginLoading" @change="selectTerminal('mobile')" />
          <span>移动端</span>
        </label>
        <label :class="{ selected: terminalType === 'pc' }">
          <input type="radio" name="terminal" value="pc" :checked="terminalType === 'pc'" :disabled="authStore.loginLoading" @change="selectTerminal('pc')" />
          <span>PC ç«¯</span>
        </label>
      </div>
      <p>已按屏幕大小选择,可手动切换</p>
    </fieldset>
    <AuthenticationLogin
      ref="loginRef"
      :form-schema="formSchema"
      :loading="authStore.loginLoading"
      :show-field-labels="terminalType === 'mobile'"
      :submit-button-text="terminalType === 'mobile' ? '登录移动端' : '登录 PC ç«¯'"
      title="欢迎回来"
      sub-title="登录种植业数字化平台,开启智慧种植"
      :sub-title="terminalType === 'mobile' ? '使用已有账号登录现场作业' : '登录种植业数字化平台,开启智慧种植'"
      :show-code-login="false"
      :show-qrcode-login="false"
      :show-third-party-login="false"
@@ -93,6 +117,7 @@
      :show-doc-link="false"
      @submit="handleLogin"
    />
    <p v-if="loginError" class="login-error" role="alert">{{loginError}}</p>
    <Verification
      ref="verifyRef"
      v-if="captchaEnable"
@@ -105,3 +130,14 @@
    />
  </div>
</template>
<style scoped>
.terminal-selector { border: 0; margin: 0 0 24px; padding: 0; }
.login-error { padding:12px;color:#b91c1c;background:#fef2f2;border-radius:8px; }
.terminal-selector legend { font-size: 16px; margin-bottom: 8px; }
.terminal-options { display: flex; gap: 12px; }
.terminal-options label { display:flex;align-items:center;justify-content:center;gap:8px;flex:1;min-height:52px;border:1px solid #64748b;border-radius:8px;cursor:pointer; }
.terminal-options label.selected { border-color:#0369a1;color:#0369a1;background:#f0f9ff; }
.terminal-options label:focus-within { outline:2px solid #0369a1;outline-offset:2px; }
.terminal-selector p { margin-top:8px;color:#475569;font-size:14px; }
</style>
src/views/mes/pro/bagCode/data.ts
@@ -16,12 +16,16 @@
  return [
    {
      fieldName: 'code',
      label: '袋码',
      label: '追溯码',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入袋码(二维码内容)',
        placeholder: '请输入追溯码',
      },
    },
    {
      fieldName: 'businessCode', label: '业务码', component: 'Input',
      componentProps: { allowClear: true, placeholder: '请输入出入库业务码' },
    },
    {
      fieldName: 'batchCode',
@@ -77,10 +81,11 @@
    { type: 'checkbox', width: 50 },
    {
      field: 'code',
      title: '袋码(二维码内容)',
      title: '追溯码',
      fixed: 'left',
      minWidth: 220,
    },
    { field: 'businessCode', title: '业务码(出入库)', minWidth: 210 },
    {
      field: 'bagNo',
      title: '袋序号',
@@ -121,6 +126,7 @@
      title: '绑定托盘码',
      minWidth: 190,
    },
    { field: 'inboundTime', title: '入库状态', width: 100, formatter: ({ cellValue }) => cellValue ? '已入库' : '未入库' },
    {
      field: 'status',
      title: '袋码状态',
src/views/mes/pro/bagCode/index.vue
@@ -6,17 +6,20 @@
  import { MesProBagCodeStatusEnum } from "@vben/constants";
  import { downloadFileFromBlobPart } from "@vben/utils";
  import { message, Modal } from "ant-design-vue";
  import { Button, message, Modal } from "ant-design-vue";
  import { ACTION_ICON, TableAction, useVbenVxeGrid } from "#/adapter/vxe-table";
  import {
    deleteBagCodesByUuid,
    exportBagCodeExcel,
    exportBagQr,
    exportBagBusinessQr,
    generateBagBusinessCodes,
    getBagCodePage,
    bindBagCodeBatch,
    unbindBagCodePallet,
  } from "#/api/mes/pro/bagCode";
  import { IconifyIcon } from "@vben/icons";
  import { $t } from "#/locales";
  import { useGridColumns, useGridFormSchema } from "./data";
@@ -26,6 +29,7 @@
  import RawGenerateModal from "./modules/raw-generate-form.vue";
  import ReplaceModal from "./modules/replace-form.vue";
  import QrTraceModal from "../components/qr-trace-modal.vue";
  import BusinessQrModal from "../components/business-qr.vue";
  defineOptions({ name: "MesProBagCode" });
@@ -59,6 +63,29 @@
    destroyOnClose: true,
  });
  const [BusinessQrViewModal, businessQrApi] = useVbenModal({
    connectedComponent: BusinessQrModal, destroyOnClose: true,
  });
  function handleBusinessQr(row: MesProBagCodeApi.BagCode) {
    if (!row.businessCode) { message.warning('请先补生成业务码'); return; }
    businessQrApi.setData({ code: row.businessCode }).open();
  }
  async function handleGenerateBusinessCodes() {
    const records = getSelectedRecords();
    if (!records) return;
    if (records.length > 200) { message.warning('单次最多补生成 200 æ¡'); return; }
    if (records.some(row => row.status === MesProBagCodeStatusEnum.CANCELED)) {
      message.warning('已作废袋码不可补生成业务码'); return;
    }
    const ids = records.map(row => row.id).filter((id): id is number => id != null);
    if (ids.length !== records.length) { message.warning('所选袋码缺少编号'); return; }
    const count = await generateBagBusinessCodes(ids);
    message.success(`已补生成 ${count} ä»½ä¸šåŠ¡ç ï¼Œå·²æœ‰ç å€¼ä¿æŒä¸å˜`);
    handleRefresh();
  }
  /** åˆ·æ–°è¡¨æ ¼ */
  function handleRefresh() {
    gridApi.query();
@@ -91,7 +118,7 @@
  }
  /** å¯¼å‡ºäºŒç»´ç  ZIP(按当前查询条件的生成组/批次) */
  async function handleExportQr() {
  async function handleExportQr(kind: 'trace' | 'business' = 'trace') {
    const values = await gridApi.formApi.getValues();
    const params: { uuid?: string; batchId?: number } = {};
    if (values.uuid) {
@@ -104,8 +131,8 @@
      message.warning("请先按生成组或批次筛选后再导出二维码");
      return;
    }
    const data = await exportBagQr(params);
    downloadFileFromBlobPart({ fileName: "袋码二维码.zip", source: data });
    const data = await (kind === 'business' ? exportBagBusinessQr : exportBagQr)(params);
    downloadFileFromBlobPart({ fileName: kind === 'business' ? '袋码业务二维码.zip' : '袋码追溯二维码.zip', source: data });
  }
  /** å¯¼å‡ºè¢‹ç  Excel(交付印刷),导出后袋码状态变为已印刷,需刷新列表 */
@@ -319,73 +346,39 @@
    <BindPalletFormModal @success="handleRefresh" />
    <ReplaceFormModal @success="handleReplaceSuccess" />
    <QrTraceQueryModal />
    <BusinessQrViewModal />
    <Grid table-title="袋码列表(一袋一码)">
      <template #table-title><span class="text-[1rem] font-bold" style="white-space:nowrap">袋码列表(一袋一码)</span></template>
      <template #toolbar-tools>
        <TableAction :actions="[
            {
              label: '生成数据包',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-bag-code:create'],
              onClick: handleGenerate,
            },
            {
              label: '生成原始袋码',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-bag-code:create'],
              onClick: handleRawGenerate,
            },
            {
              label: '录入批次',
              type: 'primary',
              auth: ['mes:pro-bag-code:update'],
              onClick: handleBindBatch,
            },
            {
              label: '绑定托盘',
              type: 'primary',
              auth: ['mes:pro-bag-code:update'],
              onClick: handleBindPallet,
            },
            {
              label: '破损替换',
              type: 'primary',
              auth: ['mes:pro-bag-code:replace'],
              onClick: handleReplace,
            },
            {
              label: '解绑托盘',
              auth: ['mes:pro-bag-code:update'],
              onClick: handleUnbindPallet,
            },
            {
              label: '扫码追溯',
              icon: ACTION_ICON.SEARCH,
              auth: ['mes:pro-bag-code:query'],
              onClick: handleTrace,
            },
            {
              label: '导出二维码',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pro-bag-code:export'],
              onClick: handleExportQr,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pro-bag-code:export'],
              onClick: handleExport,
            },
          ]" />
        <div class="mes-code-toolbar">
          <TableAction :actions="[{ label: '生成数据包', type: 'primary', icon: ACTION_ICON.ADD, auth: ['mes:pro-bag-code:create'], onClick: handleGenerate },
          { label: '录入批次', auth: ['mes:pro-bag-code:update'], onClick: handleBindBatch },
          { label: '绑定托盘', auth: ['mes:pro-bag-code:update'], onClick: handleBindPallet },
          { label: '扫码追溯', icon: ACTION_ICON.SEARCH, auth: ['mes:pro-bag-code:query'], onClick: handleTrace }]" />
          <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: '追溯二维码', auth: ['mes:pro-bag-code:export'], onClick: () => handleExportQr('trace') },
          { label: '业务二维码', auth: ['mes:pro-bag-code:export'], onClick: () => handleExportQr('business') },
          { label: 'Excel æ•°æ®åŒ…', auth: ['mes:pro-bag-code:export'], onClick: handleExport }]">
            <template #more><Button>导出 <IconifyIcon icon="lucide:chevron-down" /></Button></template>
          </TableAction>
          <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: '生成原始袋码', auth: ['mes:pro-bag-code:create'], onClick: handleRawGenerate },
          { label: '补生成业务码', auth: ['mes:pro-bag-code:create'], onClick: handleGenerateBusinessCodes },
          { label: '破损替换', auth: ['mes:pro-bag-code:replace'], onClick: handleReplace },
          { label: '解绑托盘', auth: ['mes:pro-bag-code:update'], onClick: handleUnbindPallet }]">
            <template #more><Button>更多操作 <IconifyIcon icon="lucide:chevron-down" /></Button></template>
          </TableAction>
        </div>
      </template>
      <template #actions="{ row }">
        <TableAction :actions="[
            {
              label: '查看二维码',
              label: '追溯二维码',
              type: 'link',
              auth: ['mes:pro-bag-code:query'],
              onClick: handleViewQr.bind(null, row),
            },
            {
              label: '业务二维码', type: 'link', auth: ['mes:pro-bag-code:query'],
              ifShow: !!row.businessCode, onClick: handleBusinessQr.bind(null, row),
            },
            {
              label: $t('common.delete'),
@@ -407,3 +400,9 @@
    </Grid>
  </Page>
</template>
<style scoped>
.mes-code-toolbar { display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px; }
.mes-code-toolbar :deep(.ant-btn) { white-space:nowrap; }
.mes-code-toolbar :deep(.ant-btn > .iconify) { display:inline-block;width:14px;height:14px;vertical-align:middle; }
</style>
src/views/mes/pro/batch/data.ts
@@ -18,12 +18,16 @@
export interface PalletOption {
  label: string;
  value: number;
  itemId?: number;
  itemName?: string;
  itemCode?: string;
}
/** æ–°å¢ž/修改的表单 */
export function useFormSchema(
  formType: FormType,
  palletOptions: PalletOption[] = [],
  onPalletChange?: (ids: number[]) => void,
): VbenFormSchema[] {
  const disabled = formType === 'detail';
  return [
@@ -132,6 +136,18 @@
      rules: 'selectRequired',
    },
    {
      fieldName: 'itemId', component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'itemName', label: '品种名称', component: 'Input',
      componentProps: { disabled: true, placeholder: '选择托盘后自动带入' },
    },
    {
      fieldName: 'itemCode', label: '品种编码', component: 'Input',
      componentProps: { disabled: true, placeholder: '选择托盘后自动带入' },
    },
    {
      fieldName: 'palletIds',
      label: '绑定托盘',
      component: 'Select',
@@ -139,17 +155,25 @@
        allowClear: true,
        disabled,
        maxTagCount: 3,
        dropdownMatchSelectWidth: false,
        dropdownStyle: { minWidth: '360px', maxWidth: 'calc(100vw - 32px)' },
        mode: 'multiple',
        optionFilterProp: 'label',
        options: palletOptions,
        placeholder: '请选择托盘(可不选)',
        onChange: onPalletChange,
        placeholder: '请选择相同品种的托盘',
        showSearch: true,
      },
      dependencies: {
        triggerFields: [''],
        triggerFields: ['itemId'],
        show: () => formType !== 'detail',
        componentProps: (values) => ({
          options: palletOptions.map(option => ({ ...option,
            disabled: !option.itemId || (!!values.itemId && option.itemId !== values.itemId),
          })),
        }),
      },
      help: '绑定后批次直接进入生产中:托盘生成正式托盘码,托盘下未录批次的袋码自动级联录入本批次(袋码品种须一致);编辑时仅需勾选新增托盘,已绑定的不可解绑',
      help: '只能绑定相同品种的托盘,品种信息自动带入;缺少品种的托盘请先装袋。绑定后批次直接进入生产中:托盘生成正式托盘码,托盘下未录批次的袋码自动级联录入本批次(袋码品种须一致);编辑时仅需勾选新增托盘,已绑定的不可解绑',
    },
    {
      fieldName: 'remark',
@@ -168,6 +192,7 @@
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    { fieldName: 'businessCode', label: '业务码', component: 'Input', componentProps: { allowClear: true, placeholder: '请输入作业扫码业务码' } },
    {
      fieldName: 'code',
      label: '批次号',
@@ -237,6 +262,7 @@
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<MesProBatchApi.Batch>['columns'] {
  return [
    { field: 'businessCode', title: '业务码(作业扫码)', minWidth: 210 },
    {
      field: 'code',
      title: '批次号',
@@ -244,6 +270,8 @@
      minWidth: 200,
      slots: { default: 'code' },
    },
    { field: 'itemName', title: '品种名称', minWidth: 150 },
    { field: 'itemCode', title: '品种编码', minWidth: 150 },
    {
      field: 'lineName',
      title: '加工单位',
src/views/mes/pro/batch/index.vue
@@ -13,15 +13,19 @@
    deleteBatch,
    exportBatchExcel,
    exportBatchQr,
    exportBatchBusinessQr,
    generateBatchBusinessCodes,
    getBatchPage,
    updateBatchStatus,
  } from "#/api/mes/pro/batch";
  import { IconifyIcon } from "@vben/icons";
  import { $t } from "#/locales";
  import { useGridColumns, useGridFormSchema } from "./data";
  import Form from "./modules/form.vue";
  import PreGenerateModal from "./modules/pre-generate.vue";
  import QrTraceModal from "../components/qr-trace-modal.vue";
  import BusinessQrModal from "../components/business-qr.vue";
  const [FormModal, formModalApi] = useVbenModal({
    connectedComponent: Form,
@@ -39,6 +43,21 @@
  });
  /** åˆ·æ–°è¡¨æ ¼ */
  const [BusinessQrViewModal, businessQrApi] = useVbenModal({ connectedComponent: BusinessQrModal, destroyOnClose: true });
  function handleBusinessQr(row: MesProBatchApi.Batch) {
    businessQrApi.setData({ code: row.businessCode }).open();
  }
  async function handleGenerateBusiness(row: MesProBatchApi.Batch) {
    if (row.id == null) return;
    const count = await generateBatchBusinessCodes([row.id]);
    message.success(`已补生成 ${count} ä»½ä¸šåŠ¡ç ï¼Œå·²æœ‰ç å€¼ä¿æŒä¸å˜`); handleRefresh();
  }
  async function handleExportBusiness(row: MesProBatchApi.Batch) {
    if (row.id == null) return;
    const data = await exportBatchBusinessQr(row.id);
    downloadFileFromBlobPart({ fileName: '批次与托盘业务二维码.zip', source: data });
  }
  function handleRefresh() {
    gridApi.query();
  }
@@ -162,36 +181,18 @@
    <FormModal @success="handleRefresh" />
    <PreGenerateModalApi @success="handlePreGenerateSuccess" />
    <QrTraceQueryModal />
    <BusinessQrViewModal />
    <Grid table-title="生产批次列表">
      <template #table-title><span class="text-[1rem] font-bold" style="white-space:nowrap">生产批次列表</span></template>
      <template #toolbar-tools>
        <TableAction :actions="[
            {
              label: '预生成二维码',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-batch:create'],
              onClick: handlePreGenerate,
            },
            {
              label: $t('ui.actionTitle.create', ['批次']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-batch:create'],
              onClick: handleCreate,
            },
            {
              label: '扫码追溯',
              icon: ACTION_ICON.SEARCH,
              auth: ['mes:pro-batch:query'],
              onClick: handleTrace,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pro-batch:export'],
              onClick: handleExport,
            },
          ]" />
        <div class="mes-code-toolbar">
          <TableAction :actions="[{ label: '预生成二维码', type: 'primary', icon: ACTION_ICON.ADD, auth: ['mes:pro-batch:create'], onClick: handlePreGenerate },
          { label: '新增批次', auth: ['mes:pro-batch:create'], onClick: handleCreate },
          { label: '扫码追溯', icon: ACTION_ICON.SEARCH, auth: ['mes:pro-batch:query'], onClick: handleTrace }]" />
          <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: 'Excel æ•°æ®', auth: ['mes:pro-batch:export'], onClick: handleExport }]">
            <template #more><Button>导出 <IconifyIcon icon="lucide:chevron-down" /></Button></template>
          </TableAction>
        </div>
      </template>
      <template #code="{ row }">
        <Button type="link" @click="handleDetail(row)">
@@ -202,7 +203,7 @@
      <template #actions="{ row }">
        <TableAction :actions="[
            {
              label: '查看二维码',
              label: '追溯二维码',
              type: 'link',
              auth: ['mes:pro-batch:query'],
              onClick: handleViewQr.bind(null, row),
@@ -242,6 +243,9 @@
            },
          ]"
          :drop-down-actions="[
            { label: '业务二维码', auth: ['mes:pro-batch:query'], ifShow: !!row.businessCode, onClick: () => handleBusinessQr(row) },
            { label: '补生成业务码', auth: ['mes:pro-batch:create'], ifShow: !row.businessCode && row.status !== MesProBatchStatusEnum.CANCELED, onClick: () => handleGenerateBusiness(row) },
            { label: '导出整套业务二维码', auth: ['mes:pro-batch:export'], ifShow: row.status !== MesProBatchStatusEnum.CANCELED, onClick: () => handleExportBusiness(row) },
            {
              label: '开始生产',
              auth: ['mes:pro-batch:update'],
@@ -275,7 +279,7 @@
              },
            },
            {
              label: '打印整套二维码',
              label: '打印整套追溯二维码',
              auth: ['mes:pro-batch:export'],
              onClick: () => handlePrintQrSet(row),
            },
@@ -284,3 +288,9 @@
    </Grid>
  </Page>
</template>
<style scoped>
.mes-code-toolbar { display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px; }
.mes-code-toolbar :deep(.ant-btn) { white-space:nowrap; }
.mes-code-toolbar :deep(.ant-btn > .iconify) { display:inline-block;width:14px;height:14px;vertical-align:middle; }
</style>
src/views/mes/pro/batch/modules/form.vue
@@ -37,6 +37,18 @@
  const formType = ref<FormType>("create");
  const formData = ref<MesProBatchApi.Batch>();
  let palletOptions: PalletOption[] = [];
  async function handlePalletChange(ids: number[] = []) {
    const selected = palletOptions.filter(option => ids.includes(option.value));
    const source = formData.value?.itemId ? formData.value : selected[0];
    if (selected.some(option => !option.itemId || (source?.itemId && option.itemId !== source.itemId))) {
      message.warning('生产批次只能绑定相同品种的托盘');
      await formApi.setFieldValue('palletIds', selected.filter(option => option.itemId === source?.itemId).map(option => option.value));
    }
    await formApi.setFieldValue('itemId', source?.itemId);
    await formApi.setFieldValue('itemName', source?.itemName);
    await formApi.setFieldValue('itemCode', source?.itemCode);
  }
  const isEditable = computed(() =>
    ["create", "update", "fill"].includes(formType.value)
@@ -208,15 +220,16 @@
      const data = modalApi.getData<{ formType: FormType; id?: number }>();
      formType.value = data.formType;
      // æ–°å¢ž/补录/编辑态加载未绑定批次的托盘,供「绑定托盘」多选下拉使用
      let palletOptions: PalletOption[] = [];
      palletOptions = [];
      if (formType.value !== "detail") {
        const pallets = await getUnboundPalletList();
        palletOptions = pallets.map((pallet) => ({
          label: `${pallet.tempCode ?? `托盘${pallet.id}`}(已装 ${pallet.bagCount ?? 0} è¢‹ï¼‰`,
          label: `${pallet.tempCode ?? `托盘${pallet.id}`} Â· ${pallet.itemName || "待完善品种"}(已装 ${pallet.bagCount ?? 0} è¢‹ï¼‰`,
          value: pallet.id as number,
          itemId: pallet.itemId, itemName: pallet.itemName, itemCode: pallet.itemCode,
        }));
      }
      formApi.setState({ schema: useFormSchema(formType.value, palletOptions) });
      formApi.setState({ schema: useFormSchema(formType.value, palletOptions, handlePalletChange) });
      if (formType.value !== "detail") {
        // æ¸…空上次打开时勾选的托盘,避免残留值误触发「批次进入生产中」
        await formApi.setFieldValue("palletIds", undefined);
src/views/mes/pro/components/business-qr.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Barcode, useVbenModal } from '@vben/common-ui';
const code = ref('');
const [Modal, modalApi] = useVbenModal({
  onOpenChange(open) {
    code.value = open ? (modalApi.getData<{ code: string }>()?.code || '') : '';
  },
});
</script>
<template>
  <Modal title="业务码二维码" :show-confirm-button="false">
    <div style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px">
      <Barcode v-if="code" :content="code" :width="240" />
      <strong style="font-size:20px;word-break:break-all">{{ code }}</strong>
      <p>扫码出入库使用,二维码内容为上方业务码值。</p>
    </div>
  </Modal>
</template>
src/views/mes/pro/components/qr-trace-modal.vue
@@ -52,9 +52,10 @@
  /** äºŒç»´ç å±•示内容:配置了溯源地址则拼 H5 URL,否则回退纯码值(与后端 buildQrCodeContent ä¸€è‡´ï¼‰ */
  const qrContent = computed(() => {
    if (!currentCode.value) return "";
    const code = bagTrace.value?.code || currentCode.value;
    return traceBaseUrl.value
      ? `${traceBaseUrl.value}?code=${encodeURIComponent(currentCode.value)}`
      : currentCode.value;
      ? `${traceBaseUrl.value}?code=${encodeURIComponent(code)}`
      : code;
  });
  /** ä»Žå®Œæ•´ H5 é“¾æŽ¥ä¸­æå–追溯码(兼容扫码枪扫描打印标签链接) */
@@ -109,12 +110,15 @@
      const type = await resolveTraceType(value);
      if (type === "bag") {
        bagTrace.value = await getBagCodeByCode(value);
        currentCode.value = bagTrace.value.code || value;
        traceTypeLabel.value = "袋码";
      } else if (type === "pallet") {
        palletTrace.value = await getPalletByCode(value);
        currentCode.value = palletTrace.value.code || palletTrace.value.tempCode || value;
        traceTypeLabel.value = "托盘码";
      } else if (type === "batch") {
        batchTrace.value = await getBatchByCode(value);
        currentCode.value = batchTrace.value.code || batchTrace.value.tempCode || value;
        traceTypeLabel.value = "批次号";
      } else {
        searchError.value = true;
@@ -235,9 +239,10 @@
    schema: [
      {
        field: "code",
        label: "袋码(二维码内容)",
        label: "追溯码",
        span: 2,
      },
      { field: 'businessCode', label: '业务码(出入库)', span: 2 },
      {
        field: "bagNo",
        label: "袋序号",
src/views/mes/pro/pallet/data.ts
@@ -10,6 +10,7 @@
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    { fieldName: 'businessCode', label: '业务码', component: 'Input', componentProps: { allowClear: true, placeholder: '请输入出入库业务码' } },
    {
      fieldName: 'code',
      label: '托盘码',
@@ -54,6 +55,7 @@
export function useGridColumns(): VxeTableGridOptions<MesProPalletApi.Pallet>['columns'] {
  return [
    { type: 'checkbox', width: 50 },
    { field: 'businessCode', title: '业务码(出入库)', minWidth: 210 },
    {
      field: 'code',
      title: '托盘码',
@@ -61,6 +63,8 @@
      minWidth: 210,
      slots: { default: 'code' },
    },
    { field: 'itemName', title: '品种名称', minWidth: 150 },
    { field: 'itemCode', title: '品种编码', minWidth: 150 },
    {
      field: 'palletNo',
      title: '托盘序号',
@@ -77,6 +81,7 @@
      title: '已绑袋码数量',
      width: 120,
    },
    { field: 'inboundTime', title: '入库状态', width: 100, formatter: ({ cellValue }) => cellValue ? '已入库' : '未入库' },
    {
      field: 'status',
      title: '托盘状态',
src/views/mes/pro/pallet/index.vue
@@ -5,7 +5,7 @@
  import { Page, useVbenModal } from "@vben/common-ui";
  import { downloadFileFromBlobPart } from "@vben/utils";
  import { message, Modal, Tag } from "ant-design-vue";
  import { Button, message, Modal, Tag } from "ant-design-vue";
  import { ACTION_ICON, TableAction, useVbenVxeGrid } from "#/adapter/vxe-table";
  import {
@@ -13,14 +13,18 @@
    exportPalletExcel,
    exportPalletQr,
    exportPalletQrByIds,
    exportPalletBusinessQr,
    generatePalletBusinessCodes,
    getPalletPage,
  } from "#/api/mes/pro/pallet";
  import { IconifyIcon } from "@vben/icons";
  import { $t } from "#/locales";
  import { useGridColumns, useGridFormSchema } from "./data";
  import BindBatchModal from "./modules/bind-batch.vue";
  import GenerateModal from "./modules/generate-form.vue";
  import QrTraceModal from "../components/qr-trace-modal.vue";
  import BusinessQrModal from "../components/business-qr.vue";
  defineOptions({ name: "MesProPallet" });
@@ -40,6 +44,28 @@
  });
  /** åˆ·æ–°è¡¨æ ¼ */
  const [BusinessQrViewModal, businessQrApi] = useVbenModal({ connectedComponent: BusinessQrModal, destroyOnClose: true });
  function handleBusinessQr(row: MesProPalletApi.Pallet) {
    businessQrApi.setData({ code: row.businessCode }).open();
  }
  function getBusinessIds() {
    const records = [...(gridApi.grid.getCheckboxReserveRecords?.() || []), ...(gridApi.grid.getCheckboxRecords?.() || [])] as MesProPalletApi.Pallet[];
    const ids = [...new Set(records.map(row => row.id).filter((id): id is number => id != null))];
    if (!ids.length || ids.length > 200) { message.warning('请勾选 1 è‡³ 200 æ¡æ‰˜ç›˜è®°å½•'); return; }
    if (records.some(row => row.status === 20)) { message.warning('已作废托盘不可操作业务码'); return; }
    return ids;
  }
  async function handleGenerateBusiness() {
    const ids = getBusinessIds(); if (!ids) return;
    const count = await generatePalletBusinessCodes(ids);
    message.success(`已补生成 ${count} ä»½ä¸šåŠ¡ç ï¼Œå·²æœ‰ç å€¼ä¿æŒä¸å˜`); handleRefresh();
  }
  async function handleExportBusiness() {
    const ids = getBusinessIds(); if (!ids) return;
    const data = await exportPalletBusinessQr(ids);
    downloadFileFromBlobPart({ fileName: '托盘业务二维码.zip', source: data });
  }
  function handleRefresh() {
    gridApi.query();
  }
@@ -177,42 +203,23 @@
    <GenerateFormModal @success="handlePreGenerateSuccess" />
    <BindBatchFormModal @success="handleRefresh" />
    <QrTraceQueryModal />
    <BusinessQrViewModal />
    <Grid table-title="托盘码列表">
      <template #table-title><span class="text-[1rem] font-bold" style="white-space:nowrap">托盘码列表</span></template>
      <template #toolbar-tools>
        <TableAction :actions="[
            {
              label: '预生成二维码',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-pallet:create'],
              onClick: handleCreate,
            },
            {
              label: '绑定批次',
              icon: ACTION_ICON.EDIT,
              auth: ['mes:pro-pallet:update'],
              onClick: handleBindBatch,
            },
            {
              label: '扫码追溯',
              icon: ACTION_ICON.SEARCH,
              auth: ['mes:pro-pallet:query'],
              onClick: handleTrace,
            },
            {
              label: '导出二维码',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pro-pallet:export'],
              onClick: handleExportQr,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['mes:pro-pallet:export'],
              onClick: handleExport,
            },
          ]" />
        <div class="mes-code-toolbar">
          <TableAction :actions="[{ label: '预生成二维码', type: 'primary', icon: ACTION_ICON.ADD, auth: ['mes:pro-pallet:create'], onClick: handleCreate },
          { label: '绑定批次', auth: ['mes:pro-pallet:update'], onClick: handleBindBatch },
          { label: '扫码追溯', icon: ACTION_ICON.SEARCH, auth: ['mes:pro-pallet:query'], onClick: handleTrace }]" />
          <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: '追溯二维码(按批次)', auth: ['mes:pro-pallet:export'], onClick: handleExportQr },
          { label: '业务二维码(选中记录)', auth: ['mes:pro-pallet:export'], onClick: handleExportBusiness },
          { label: 'Excel æ•°æ®', auth: ['mes:pro-pallet:export'], onClick: handleExport }]">
            <template #more><Button>导出 <IconifyIcon icon="lucide:chevron-down" /></Button></template>
          </TableAction>
          <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: '补生成业务码(选中记录)', auth: ['mes:pro-pallet:create'], onClick: handleGenerateBusiness }]">
            <template #more><Button>更多操作 <IconifyIcon icon="lucide:chevron-down" /></Button></template>
          </TableAction>
        </div>
      </template>
      <template #code="{ row }">
        <span>{{ getDisplayCode(row) }}</span>
@@ -226,11 +233,12 @@
      <template #actions="{ row }">
        <TableAction :actions="[
            {
              label: '查看二维码',
              label: '追溯二维码',
              type: 'link',
              auth: ['mes:pro-pallet:query'],
              onClick: handleViewQr.bind(null, row),
            },
            { label: '业务二维码', type: 'link', auth: ['mes:pro-pallet:query'], ifShow: !!row.businessCode, onClick: handleBusinessQr.bind(null, row) },
            {
              label: '导出二维码',
              type: 'link',
@@ -255,3 +263,9 @@
    </Grid>
  </Page>
</template>
<style scoped>
.mes-code-toolbar { display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px; }
.mes-code-toolbar :deep(.ant-btn) { white-space:nowrap; }
.mes-code-toolbar :deep(.ant-btn > .iconify) { display:inline-block;width:14px;height:14px;vertical-align:middle; }
</style>
src/views/mobile/home.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useUserStore } from '@vben/stores';
import { availableFeatures, allowedMobilePath } from '#/mobile/access';
import { myRecords, errorText } from '#/mobile/api';
import Icon from '#/mobile/icon.vue';
import type { MesProScanEventApi } from '#/api/mes/pro/scanEvent';
const route = useRoute(), user = useUserStore();
const position = ref('尚未选择作业位置');
const items = computed(availableFeatures);
const records = ref<MesProScanEventApi.ScanEvent[]>([]);
const error = ref(''), loading = ref(true);
onMounted(async () => {
  try { const saved=JSON.parse(sessionStorage.getItem('mom-mobile-position-'+user.userInfo?.id)||'null');if(saved?.name)position.value=saved.name; } catch { /* Position will be selected before submitting. */ }
  if(allowedMobilePath('/mobile/records')) try { records.value = (await myRecords({pageNo:1,pageSize:2})).list; } catch(e){error.value=errorText(e);} finally {loading.value=false;}
});
</script>
<template>
  <p class="m-muted">{{ user.userInfo?.nickname }},欢迎进入现场作业</p>
  <p v-if="route.query.forbidden" class="m-warning" role="alert">当前账号没有该功能的访问权限。</p>
  <div v-if="items.length === 0" class="m-panel m-empty"><h2 class="m-title">暂无移动端可用功能</h2><p>请联系管理员配置已适配菜单和操作权限。</p><RouterLink to="/mobile/mine" class="m-link">切换使用端或退出</RouterLink></div>
  <template v-else>
    <template v-if="route.path.endsWith('/home')">
      <div class="m-panel">
        <p class="m-muted">生产与仓库现场作业 Â· {{position}}</p>
        <h2 class="m-title">从一次扫码开始</h2>
        <RouterLink v-if="items.some(f=>f.key==='inbound')" class="m-primary" to="/mobile/inbound"><Icon name="scan"/>扫码入库</RouterLink>
        <RouterLink v-else :to="'/mobile/'+items[0]!.key" class="m-primary">{{items[0]!.title}}</RouterLink>
        <p class="m-muted" style="margin:12px 0 0">入库前核对对象、数量和库位,提交后查看处理结果。</p>
      </div>
    </template>
    <h2 class="m-section-title">{{route.path.endsWith('/work')?'全部作业':'常用功能'}}</h2>
    <div class="m-grid">
      <RouterLink v-for="item in items" :key="item.key" :to="'/mobile/'+item.key" class="m-feature">
        <Icon :name="item.icon"/><div><strong>{{item.title}}</strong><small>{{item.hint}}</small></div>
      </RouterLink>
    </div>
    <template v-if="route.path.endsWith('/home') && allowedMobilePath('/mobile/records')">
      <h2 class="m-section-title">最近作业</h2>
      <p v-if="error" class="m-error" role="alert">{{error}}</p>
      <p v-else-if="loading" class="m-muted">正在获取最近作业…</p>
      <p v-else-if="!records.length" class="m-empty">暂无扫码入库记录</p>
      <article v-for="r in records" :key="r.id" class="m-record"><span :class="['m-status',r.status?.toLowerCase()]">{{r.status==='SUCCESS'?'已入库':'处理失败'}}</span><p>{{r.bagCode}}</p><p class="m-muted">{{r.receivedTime}}</p></article>
      <RouterLink to="/mobile/records" class="m-link">查看全部记录</RouterLink>
    </template>
  </template>
</template>
src/views/mobile/inbound.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { useUserStore } from '@vben/stores';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
import { getWarehouseLocationSimpleList, type MesWmWarehouseLocationApi } from '#/api/mes/wm/warehouse/location';
import { getWarehouseAreaSimpleList, type MesWmWarehouseAreaApi } from '#/api/mes/wm/warehouse/area';
import { inboundScanEvent, type MesProScanEventApi } from '#/api/mes/pro/scanEvent';
import { lookup, myEvent, errorText, writeBlockReason, type TraceResult } from '#/mobile/api';
import Scanner from './scanner.vue';
import Picker from './picker.vue';
const user=useUserStore(),storageKey='mom-mobile-inbound-'+user.userInfo?.id;
const positionKey='mom-mobile-position-'+user.userInfo?.id;
const locations=ref<MesWmWarehouseLocationApi.WarehouseLocation[]>([]),areas=ref<MesWmWarehouseAreaApi.WarehouseArea[]>([]);
const locationId=ref<number>(),areaId=ref<number>(),busy=ref(false),error=ref(''),trace=ref<TraceResult>();
const quantity=ref(1),result=ref<MesProScanEventApi.ScanEvent>(),pending=ref<MesProScanEventApi.InboundParams>(),unknown=ref(false),ready=ref(false);
const remembered=ref('');
const areaLoading=ref(false),positionLoading=ref(false);
let areaRequest=0;
const place=computed(()=>[locations.value.find(l=>l.id===locationId.value)?.name,areas.value.find(a=>a.id===areaId.value)?.name].filter(Boolean).join(' / ')||remembered.value);
async function loadAreas(){
  const ticket=++areaRequest, id=locationId.value;
  areas.value=[];areaId.value=undefined;areaLoading.value=!!id;
  try{if(id){const list=await getWarehouseAreaSimpleList(id);if(ticket===areaRequest)areas.value=list.filter(a=>!a.frozen&&(a.status==null||a.status===0));}}
  finally{if(ticket===areaRequest)areaLoading.value=false;}
}
async function changeLocation(){error.value='';trace.value=undefined;try{await loadAreas();}catch(e){error.value=errorText(e);}}
async function loadPositions(){
  if(positionLoading.value)return;positionLoading.value=true;
  error.value='';
  try{
    const warehouses=await getWarehouseSimpleList();
    const wip=warehouses.find(w=>w.code==='WIP_VIRTUAL_WAREHOUSE');
    if(!wip?.id)throw new Error('未配置生产暂存仓,请联系管理员');
    locations.value=(await getWarehouseLocationSimpleList(wip.id)).filter(l=>!l.frozen);
    const saved=JSON.parse(sessionStorage.getItem(positionKey)||'null');
    if(saved&&locations.value.some(l=>l.id===saved.locationId)){locationId.value=saved.locationId;await loadAreas();if(areas.value.some(a=>a.id===saved.areaId))areaId.value=saved.areaId;}
    ready.value=true;
  }catch(e){error.value=errorText(e);}finally{positionLoading.value=false;}
}
onMounted(async()=>{
  try {
    const savedPosition=JSON.parse(sessionStorage.getItem(positionKey)||'null');
    remembered.value=savedPosition?.name||'';
    const savedPending=JSON.parse(sessionStorage.getItem(storageKey)||'null');
    if(savedPending){pending.value=savedPending;unknown.value=true;return;}
  } catch { error.value='上次操作信息无法读取,请通过作业记录核实后再入库。'; return; }
  await loadPositions();
});
function eventId(){
  const bytes=crypto.getRandomValues(new Uint8Array(16));
  return Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');
}
async function identify(code:string){
  if(busy.value||pending.value)return;busy.value=true;error.value='';trace.value=undefined;result.value=undefined;
  try{
    const found=await lookup(code),reason=writeBlockReason(found);
    if(reason)throw new Error(reason);
    if (found.type === 'bag' && found.data.inboundTime || found.type !== 'bag' && found.data.pendingBagQuantity === 0) throw new Error('该对象没有待入库袋码,请勿重复入库');
    trace.value=found;quantity.value=1;
  }catch(e){error.value=errorText(e);}finally{busy.value=false;}
}
function complete(r:MesProScanEventApi.ScanEvent){
  result.value=r;unknown.value=false;pending.value=undefined;sessionStorage.removeItem(storageKey);
  error.value=r.status==='SUCCESS'?'':(r.errorMessage||'入库失败');
}
async function submit(){
  if(busy.value)return;
  if(!pending.value){
    if(!trace.value||!locationId.value||!areaId.value){error.value='请先选择库区、库位并识别码值';return;}
    if(!Number.isFinite(quantity.value)||quantity.value<=0){error.value='入库数量必须大于零';return;}
    pending.value={eventId:eventId(),bagCode:trace.value.code,scanType:trace.value.type.toUpperCase() as MesProScanEventApi.ScanType,quantity:quantity.value,locationId:locationId.value,areaId:areaId.value};
    sessionStorage.setItem(positionKey,JSON.stringify({locationId:locationId.value,areaId:areaId.value,name:place.value}));
  }
  sessionStorage.setItem(storageKey,JSON.stringify(pending.value));
  busy.value=true;error.value='';
  try{complete(await inboundScanEvent(pending.value));}
  catch(e){unknown.value=true;error.value='结果待确认:'+errorText(e)+'。请先核实原操作,勿重新扫码入库。';}
  finally{busy.value=false;}
}
async function verify(){
  if(!pending.value||busy.value)return;busy.value=true;error.value='';
  try{const r=await myEvent(pending.value.eventId);if(r)complete(r);else error.value='尚未查到记录。请稍后核实,或使用原操作号重试。';}
  catch(e){error.value=errorText(e);}finally{busy.value=false;}
}
function next(){result.value=undefined;trace.value=undefined;error.value='';if(!ready.value)void loadPositions();}
onBeforeRouteLeave(()=>!busy.value&&(!trace.value||!!result.value||unknown.value||window.confirm('离开将放弃尚未提交的对象,是否继续?')));
</script>
<template>
  <p class="m-muted">生产暂存入库 Â· è´¨æ£€å‰æš‚å­˜</p>
  <div v-if="pending && unknown" class="m-panel">
    <h2 class="m-title">核实上次入库结果</h2><p>{{pending.bagCode}}</p><p>目标位置:{{place||('库区 '+pending.locationId+' / åº“位 '+pending.areaId)}}</p><p class="m-muted">操作号:{{pending.eventId}}</p>
    <p class="m-warning">同一操作重试将沿用原操作号,不创建新的入库请求。</p>
    <div class="m-stack"><button class="m-primary" :disabled="busy" @click="verify">核实结果</button><button class="m-secondary" :disabled="busy" @click="submit">使用原操作号重试</button></div>
  </div>
  <template v-else-if="result">
    <div :class="result.status==='SUCCESS'?'m-success':'m-error'" role="status"><h2 class="m-title">{{result.status==='SUCCESS'?'入库成功':'入库失败'}}</h2><p>{{result.bagCode}}</p><p>{{result.errorMessage||place}}</p><p class="m-muted">{{result.eventTime||result.receivedTime}}</p></div>
    <button class="m-primary" @click="next">继续扫码</button>
    <RouterLink to="/mobile/records" class="m-link">查看作业记录</RouterLink>
  </template>
  <template v-else>
    <div v-if="!trace" class="m-panel">
      <h2 class="m-title">目标位置</h2>
      <Picker v-model="locationId" label="库区" :options="locations" :disabled="busy||!!trace" :loading="positionLoading" placeholder="请选择库区" @update:model-value="changeLocation"/>
      <Picker v-model="areaId" label="库位" :options="areas" :disabled="busy||!!trace||!locationId" :loading="areaLoading" placeholder="请选择库位"/>
      <p v-if="!ready" class="m-muted">正在获取作业位置</p>
      <button v-if="!ready&&error" class="m-secondary" @click="loadPositions">重新获取位置</button>
    </div>
    <Scanner v-if="!trace" :disabled="busy||!areaId||!ready" @code="identify"/>
    <div v-else class="m-panel">
      <h2 class="m-title">核对入库对象</h2>
      <dl><div class="m-row"><dt>品种</dt><dd>{{trace.data.itemName||'未录入'}}</dd></div><div class="m-row"><dt>码值</dt><dd>{{trace.code}}</dd></div><div class="m-row"><dt>类型</dt><dd>{{{bag:'袋码',pallet:'托盘码',batch:'批次码'}[trace.type]}}</dd></div><div class="m-row"><dt>目标位置</dt><dd>{{place}}</dd></div><div class="m-row"><dt>批次</dt><dd>{{trace.data.batchCode||'未绑定'}}</dd></div></dl>
      <label v-if="trace.type==='bag'" class="m-field"><span>入库数量(按库存计量单位)</span><input v-model.number="quantity" type="number" inputmode="decimal" min="0.000001" step="any" :disabled="busy"/></label>
      <p v-else class="m-warning">整托 / æ•´æ‰¹å…¥åº“,本次待入库 {{trace.data.pendingBagQuantity ?? '—'}} è¢‹ï¼Œæœ€ç»ˆç”±æœåŠ¡ç«¯æŒ‰æœªå…¥åº“æœ‰æ•ˆè¢‹æ•°è®¡ç®—ã€‚</p>
      <div class="m-action-bar"><button class="m-primary" :disabled="busy" @click="submit">{{busy?'正在入库…':'确认入库'}}</button><button class="m-secondary" :disabled="busy" @click="next">返回重新扫描</button></div>
    </div>
  </template>
  <p v-if="error" class="m-error" role="alert">{{error}}</p>
</template>
src/views/mobile/layout.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router';
import Icon from '#/mobile/icon.vue';
import { allowedMobilePath } from '#/mobile/access';
import { saveMobileScroll } from '#/mobile/scroll';
import './mobile.css';
const route = useRoute(), router = useRouter();
const keyboard = ref(false);
const tabs = [{ key:'home', title:'工作台', icon:'home' },{key:'work', title:'作业',icon:'work'},{key:'records',title:'记录',icon:'records'},{key:'mine',title:'我的',icon:'mine'}];
const root = computed(() => ['home','work','records','mine'].includes(route.path.split('/')[2] || ''));
function updateViewport() { keyboard.value = document.activeElement?.matches('input,textarea') === true && (window.visualViewport?.height || window.innerHeight) < window.innerHeight - 120; }
onMounted(() => window.visualViewport?.addEventListener('resize', updateViewport));
onUnmounted(() => window.visualViewport?.removeEventListener('resize', updateViewport));
onBeforeRouteUpdate((_to,from)=>{saveMobileScroll(from.path);});
function back() {
  const previous=window.history.state?.back;
  if(typeof previous==='string'&&previous.startsWith('/mobile/')&&allowedMobilePath(previous.split('?')[0]||''))router.back();
  else router.push('/mobile/work');
}
</script>
<template>
  <div class="mobile-shell" :class="{ 'keyboard-open': keyboard }">
    <header class="m-header">
      <button v-if="!root" class="m-icon-button" aria-label="返回上一页" @click="back"><Icon name="back"/></button>
      <h1>{{ route.meta.title }}</h1>
      <span class="m-product">MOM</span>
    </header>
    <main class="m-content" :class="{ 'with-tabs': root && !keyboard }">
      <RouterView v-slot="{ Component }"><KeepAlive :include="['MobileQuery','MobileStock']"><component :is="Component" :key="route.path"/></KeepAlive></RouterView>
    </main>
    <nav v-if="root && !keyboard" class="m-nav" aria-label="主导航">
      <RouterLink v-for="tab in tabs.filter(t => allowedMobilePath('/mobile/' + t.key))" :key="tab.key" :to="'/mobile/' + tab.key" :aria-current="route.path.endsWith('/' + tab.key) ? 'page' : undefined">
        <Icon :name="tab.icon"/><span>{{ tab.title }}</span>
      </RouterLink>
    </nav>
  </div>
</template>
src/views/mobile/mine.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useUserStore } from '@vben/stores';
import { useAuthStore } from '#/store';
import { router } from '#/router';
import { preferences } from '@vben/preferences';
import { selectTerminal } from '#/mobile/terminal';
const user=useUserStore(),auth=useAuthStore(),busy=ref(false);
async function switchPc(){busy.value=true;selectTerminal('pc');window.location.assign(router.resolve(preferences.app.defaultHomePath).href);}
async function logout(){busy.value=true;try{await auth.logout(false);}finally{busy.value=false;}}
</script>
<template>
<div class="m-panel"><h2 class="m-title">{{user.userInfo?.nickname}}</h2><p class="m-muted">当前使用:移动端</p><p>菜单与操作由账号权限控制。</p></div>
<div class="m-panel"><h2 class="m-title">扫码使用说明</h2><p>摄像头扫码需通过 HTTPS æ‰“开并允许相机权限。也可手动输入,或用键盘模拟扫码设备输入并按回车采集。</p><p class="m-muted">扫码查询不会自动执行入库或绑定。</p></div>
<div class="m-stack"><button class="m-secondary" :disabled="busy" @click="switchPc">切换至 PC ç«¯</button><button class="m-secondary" :disabled="busy" @click="logout">退出登录</button></div>
</template>
src/views/mobile/mobile.css
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
.mobile-shell{--m-primary:#0369a1;--m-text:#0f172a;--m-muted:#475569;--m-line:#e2e8f0;width:100%;max-width:480px;min-height:100dvh;margin:0 auto;background:#f8fafc;color:var(--m-text);font-family:system-ui,-apple-system,"Microsoft YaHei",sans-serif;font-size:16px;line-height:1.5;color-scheme:light}
.mobile-shell *{box-sizing:border-box}.mobile-shell button,.mobile-shell input,.mobile-shell select{font:inherit}
.mobile-shell button,.mobile-shell a{touch-action:manipulation}.mobile-shell button{cursor:pointer}
.mobile-shell :focus-visible{outline:2px solid #0369a1;outline-offset:3px}
.m-header{height:56px;display:flex;align-items:center;gap:8px;padding:0 16px;border-bottom:1px solid var(--m-line);background:white;position:sticky;top:0;z-index:20}
.m-header h1{font-size:20px;font-weight:650;margin:0;flex:1}.m-product{font-size:14px;font-weight:700;color:#475569}
.m-content{padding:16px;overflow-wrap:anywhere}.m-content.with-tabs{padding-bottom:calc(88px + env(safe-area-inset-bottom))}
.m-nav{position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:480px;display:flex;min-height:64px;padding-bottom:env(safe-area-inset-bottom);background:white;border-top:1px solid var(--m-line);z-index:30}
.m-nav a{flex:1;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:2px;color:#475569;font-size:14px;text-decoration:none;min-height:64px}.m-nav a.router-link-active{color:#0369a1;font-weight:650}
.mobile-shell svg{width:24px;height:24px;flex-shrink:0}.m-icon-button{border:0;background:transparent;display:grid;place-items:center;width:44px;height:44px;color:inherit}
.m-panel{background:#fff;border:1px solid var(--m-line);border-radius:12px;padding:16px;margin-bottom:16px}
.m-muted{color:#475569;font-size:14px}.m-title{font-size:18px;font-weight:650;margin:0 0 12px}.m-section-title{font-size:18px;font-weight:650;margin:24px 0 12px}
.m-primary,.m-secondary{min-height:52px;border-radius:8px;padding:12px 16px;display:flex;align-items:center;justify-content:center;gap:8px;width:100%;text-decoration:none;font-weight:600}
.m-primary{background:#0369a1;color:#fff;border:1px solid #0369a1}.m-secondary{background:white;color:#0369a1;border:1px solid #64748b}.mobile-shell button:disabled{background:#e2e8f0;color:#475569;border-color:#94a3b8;cursor:not-allowed}
.m-stack{display:grid;gap:12px}.m-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.m-feature{border-bottom:1px solid #e2e8f0;padding:16px 4px;display:flex;align-items:center;gap:12px;color:#0f172a;text-decoration:none;min-height:76px}.m-feature svg{color:#0369a1}.m-feature strong{display:block;font-weight:650}.m-feature small{color:#475569;font-size:14px}
.m-field{display:grid;gap:6px;margin-bottom:16px}.m-field>span,.m-field label{font-weight:550}
.m-field input,.m-field select,.m-input{width:100%;min-height:48px;border:1px solid #64748b;border-radius:8px;background:white;color:#0f172a;padding:10px 12px;min-width:0}.mobile-shell input::placeholder{color:#475569;opacity:1}
.m-error,.m-warning,.m-success{padding:12px;border-radius:8px;margin:12px 0;font-size:16px}.m-error{background:#fef2f2;color:#b91c1c}.m-warning{background:#fffbeb;color:#92400e}.m-success{background:#f0fdf4;color:#166534}
.m-row{padding:14px 0;border-bottom:1px solid #e2e8f0;display:flex;justify-content:space-between;gap:12px}.m-row:last-child{border-bottom:0}.m-row dt{color:#475569;flex-shrink:0}.m-row dd{margin:0;text-align:right;word-break:break-all}
.m-record{display:block;padding:16px 0;border-bottom:1px solid #e2e8f0}.m-record strong{font-size:18px}.m-record p{margin:4px 0}.m-status{display:inline-block;font-size:14px;font-weight:600;color:#475569}.m-status.success{color:#166534}.m-status.failed{color:#b91c1c}
.m-action-bar{position:sticky;bottom:0;background:#f8fafc;padding:12px 0 calc(12px + env(safe-area-inset-bottom));display:grid;gap:8px;z-index:10}
.m-empty{padding:32px 16px;text-align:center;color:#475569}.m-inline{display:flex;align-items:center;gap:12px}.m-inline>*{min-width:0}.m-link{color:#0369a1;text-decoration:underline;min-height:44px;display:inline-flex;align-items:center}
.m-camera{width:100%;max-width:320px;aspect-ratio:1;object-fit:cover;display:block;margin:12px auto;background:#0f172a;border-radius:8px}.m-detail-toggle{background:none;border:0;color:#0369a1;min-height:44px;padding:8px 0}
@media(max-width:380px){.m-grid{gap:8px}.m-content{padding:12px}.m-feature{gap:8px}.m-header{padding:0 12px}}
.m-picker-trigger{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;min-height:48px;border:1px solid #64748b;border-radius:8px;background:white;color:#0f172a;padding:10px 12px;text-align:left}
.m-picker-dialog{position:fixed;inset:auto 0 0;margin:0 auto;width:100%;max-width:480px;max-height:85dvh;border:0;border-radius:16px 16px 0 0;padding:0;background:white;color:#0f172a;overflow:hidden}
.m-picker-dialog::backdrop{background:rgb(15 23 42 / 45%)}
.m-picker-body{padding:16px 16px calc(16px + env(safe-area-inset-bottom));display:flex;flex-direction:column;max-height:85dvh}
.m-picker-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.m-picker-header .m-title{margin:0}
.m-picker-options{overflow-y:auto;overscroll-behavior:contain;min-height:0}
.m-picker-option{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;min-height:56px;padding:12px 8px;border:0;border-bottom:1px solid #e2e8f0;background:white;color:#0f172a;text-align:left}
.m-picker-option[aria-pressed=true]{color:#0369a1;background:#f0f9ff;font-weight:600}.m-picker-option small{display:block;font-size:14px;color:#475569}
.mobile-shell textarea{font:inherit}.m-field textarea{width:100%;border:1px solid #64748b;border-radius:8px;background:white;color:#0f172a;padding:10px 12px;resize:vertical;min-height:96px}
.m-quality-indicator{min-width:0}.m-quality-indicator legend{font-weight:650;padding:0 6px}.m-quality-indicator .m-grid .m-field{margin-bottom:0}
.m-order-button{width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;text-align:left;background:white;color:#0f172a;border:1px solid #e2e8f0;border-radius:12px;padding:16px;margin-bottom:12px;min-height:88px}.m-order-button small{grid-column:1/-1;color:#475569;font-size:14px}.mobile-shell summary{min-height:44px;padding:10px 0;cursor:pointer;color:#0369a1}
src/views/mobile/picker.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onDeactivated, ref, useId } from 'vue';
import Icon from '#/mobile/icon.vue';
export interface Choice { id?: number; name?: string; code?: string }
const props = withDefaults(defineProps<{
  modelValue?: number; label: string; options: Choice[]; disabled?: boolean;
  loading?: boolean; placeholder?: string; allowEmpty?: boolean;
}>(), { placeholder: '请选择', allowEmpty: false });
const emit = defineEmits<{ 'update:modelValue': [value: number | undefined] }>();
const dialog = ref<HTMLDialogElement>(), searchInput = ref<HTMLInputElement>();
const search = ref(''), opened = ref(false), titleId = useId();
const selected = computed(() => props.options.find(o => o.id === props.modelValue));
const filtered = computed(() => {
  const key = search.value.trim().toLocaleLowerCase();
  return props.options.filter(o => o.id != null && (!key || [o.name, o.code].some(v => v?.toLocaleLowerCase().includes(key))));
});
let previousOverflow = '';
async function open() {
  if (props.disabled || props.loading || opened.value) return;
  search.value = ''; opened.value = true;
  previousOverflow = document.body.style.overflow;
  document.body.style.overflow = 'hidden';
  await nextTick();
  dialog.value?.showModal();
  searchInput.value?.focus({ preventScroll: true });
}
function close() {
  if (!opened.value) return;
  dialog.value?.close(); opened.value = false;
  document.body.style.overflow = previousOverflow;
}
function choose(value?: number) { emit('update:modelValue', value); close(); }
function trapFocus(event: KeyboardEvent) {
  if(event.key!=='Tab')return;
  const controls=Array.from(dialog.value?.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled)')||[])
    .filter(el=>el.getClientRects().length>0);
  const first=controls[0],last=controls[controls.length-1];
  if(!first||!last)return;
  if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}
  else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}
}
function backdrop(e: MouseEvent) {
  if (e.target !== dialog.value) return;
  const rect = dialog.value.getBoundingClientRect();
  if (e.clientY < rect.top || e.clientX < rect.left || e.clientX > rect.right) close();
}
onBeforeUnmount(close);
onDeactivated(close);
</script>
<template>
  <div class="m-field">
    <span :id="titleId+'-label'">{{label}}</span>
    <button type="button" class="m-picker-trigger" :disabled="disabled||loading" aria-haspopup="dialog"
      :aria-labelledby="titleId+'-label '+titleId+'-value'" :aria-expanded="opened" @click="open">
      <span :id="titleId+'-value'">{{loading?'正在加载…':selected?.name||placeholder}}</span><Icon name="arrow"/>
    </button>
  </div>
  <dialog ref="dialog" class="m-picker-dialog" :aria-labelledby="titleId" @cancel.prevent="close" @click="backdrop" @keydown="trapFocus">
    <div class="m-picker-body">
      <header class="m-picker-header"><h2 :id="titleId" class="m-title">选择{{label}}</h2><button type="button" class="m-icon-button" :aria-label="'关闭'+label+'选择'" @click="close">×</button></header>
      <label class="m-field"><span>搜索{{label}}</span><input ref="searchInput" v-model="search" class="m-input" :placeholder="'输入'+label+'名称或编码'" autocomplete="off"/></label>
      <div class="m-picker-options" :aria-label="label+'选项'">
        <button v-if="allowEmpty" type="button" class="m-picker-option" :aria-pressed="modelValue==null" @click="choose()">{{placeholder}}<span v-if="modelValue==null">✓</span></button>
        <button v-for="option in filtered" :key="option.id" type="button" class="m-picker-option" :aria-pressed="option.id===modelValue" @click="choose(option.id)">
          <span>{{option.name||option.code||option.id}}<small v-if="option.code&&option.code!==option.name">{{option.code}}</small></span><span v-if="option.id===modelValue">✓</span>
        </button>
        <p v-if="!filtered.length" class="m-empty">{{options.length?'没有匹配的'+label:'暂无可选'+label}}</p>
      </div>
    </div>
  </dialog>
</template>
src/views/mobile/quality-records.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { getSeedQualityPage, type SeedQualityRecord } from '#/api/mes/qc/seedQuality';
import { errorText } from '#/mobile/api';
import Scanner from './scanner.vue';
const rows = ref<SeedQualityRecord[]>([]), busy = ref(false), error = ref('');
const code = ref(''), batchCode = ref(''), result = ref(''), page = ref(0), total = ref(0);
let filters: { code?: string; batchCode?: string; result?: number } = {};
async function load(reset = true) {
  if (busy.value) return;
  busy.value = true; error.value = '';
  const next = reset ? 1 : page.value + 1;
  const query = reset ? { code: code.value.trim() || undefined, batchCode: batchCode.value.trim() || undefined, result: result.value ? Number(result.value) : undefined } : filters;
  try {
    const data = await getSeedQualityPage({ ...query, pageNo: next, pageSize: 20 });
    rows.value = reset ? data.list : [...rows.value, ...data.list];
    page.value = next; total.value = data.total; filters = query;
  } catch (e) { error.value = errorText(e); } finally { busy.value = false; }
}
onMounted(() => load());
</script>
<template>
  <p class="m-muted">查看质检结果、批次与不合格原因。</p>
  <Scanner :disabled="busy" @code="value => { code = value; load(); }" />
  <form class="m-panel" @submit.prevent="load()">
    <label class="m-field"><span>业务码 / è¿½æº¯ç </span><input v-model="code" :disabled="busy" placeholder="输入袋码" /></label>
    <label class="m-field"><span>生产批次</span><input v-model="batchCode" :disabled="busy" placeholder="输入批次号" /></label>
    <label class="m-field"><span>质检结果</span><select v-model="result" :disabled="busy"><option value="">全部结果</option><option value="1">合格</option><option value="2">不合格</option></select></label>
    <button class="m-secondary" :disabled="busy">{{busy ? '正在查询…' : '查询记录'}}</button>
  </form>
  <p v-if="error" class="m-error" role="alert">{{error}}</p>
  <p class="m-muted" role="status">共 {{total}} æ¡è®°å½•</p>
  <p v-if="!rows.length && !busy && !error" class="m-empty">暂无匹配的质检记录</p>
  <article v-for="row in rows" :key="row.id" class="m-panel">
    <strong :class="['m-status', row.result === 1 ? 'success' : row.result === 2 ? 'failed' : '']">{{row.result === 1 ? '合格' : row.result === 2 ? '不合格' : '处理中'}}</strong>
    <dl><div class="m-row"><dt>追溯码</dt><dd>{{row.code}}</dd></div><div class="m-row"><dt>生产批次</dt><dd>{{row.batchCode || '—'}}</dd></div><div class="m-row"><dt>检验时间</dt><dd>{{row.inspectedAt || row.createTime || '—'}}</dd></div><div v-if="row.reason" class="m-row"><dt>原因 / å¤‡æ³¨</dt><dd>{{row.reason}}</dd></div></dl>
  </article>
  <button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">{{busy ? '正在加载…' : '加载更多'}}</button>
</template>
src/views/mobile/quality.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { useUserStore } from '@vben/stores';
import { resolveSeedQuality, submitSeedQualityGroup, type SeedQualityScan } from '#/api/mes/qc/seedQuality';
import { getWarehouseSimpleList, type MesWmWarehouseApi } from '#/api/mes/wm/warehouse';
import { allowedMobilePath, extractCode } from '#/mobile/access';
import { errorText } from '#/mobile/api';
import Scanner from './scanner.vue';
import Picker from './picker.vue';
const busy = ref(false), error = ref(''), notice = ref(''), bag = ref<SeedQualityScan>();
const warehouses = ref<MesWmWarehouseApi.Warehouse[]>([]), warehouseId = ref<number>();
const reason = ref(''), pendingCode = ref('');
type ScanType = 'BAG' | 'PALLET' | 'BATCH';
const scanType = ref<ScanType>('BAG');
const typeNames = { BAG: '袋码', PALLET: '托盘码', BATCH: '生产批次码' };
const pendingType = ref<ScanType>('BAG'), pendingIds = ref<number[]>([]);
const storageKey = 'mom-mobile-quality-' + useUserStore().userInfo?.id;
const indicators = ref([
  { code: 'purity', name: '纯度', value: '', minValue: '0', maxValue: '100' },
  { code: 'cleanliness', name: '净度', value: '', minValue: '0', maxValue: '100' },
  { code: 'germination_rate', name: '发芽率', value: '', minValue: '0', maxValue: '100' },
  { code: 'moisture', name: '水分', value: '', minValue: '0', maxValue: '100' },
]);
const complete = computed(() => indicators.value.every(i => [i.value, i.minValue, i.maxValue].every(v => String(v).trim() !== '' && Number.isFinite(Number(v))) && Number(i.minValue) >= 0 && Number(i.maxValue) <= 100 && Number(i.value) >= 0 && Number(i.value) <= 100 && Number(i.minValue) <= Number(i.maxValue)));
const passed = computed(() => complete.value && indicators.value.every(i => Number(i.value) >= Number(i.minValue) && Number(i.value) <= Number(i.maxValue)));
function clearPending() { pendingCode.value = ''; pendingIds.value = []; sessionStorage.removeItem(storageKey); }
async function identify(raw: string) {
  if (busy.value || pendingCode.value) return;
  busy.value = true; error.value = ''; notice.value = ''; bag.value = undefined;
  try {
    const data = await resolveSeedQuality(extractCode(raw), scanType.value);
    if (data.canceled || !data.inbound) throw new Error('该对象已作废或有效袋码尚未全部入库,不能质检');
    if (!data.pendingBagIds?.length) throw new Error('该对象没有待质检袋码,请在质检记录中查看已有结果');
    bag.value = data; indicators.value.forEach(i => i.value = ''); reason.value = '';
  } catch (e) { error.value = errorText(e); } finally { busy.value = false; }
}
async function submit() {
  if (busy.value || pendingCode.value || !bag.value || !complete.value || !warehouseId.value) return;
  if (!passed.value && !reason.value.trim()) { error.value = '请填写不合格原因'; return; }
  const count = bag.value.pendingBagIds!.length;
  if (!window.confirm(passed.value ? `确认对本次 ${count} è¢‹ç»Ÿä¸€åˆ¤å®šåˆæ ¼ï¼Ÿå…¨éƒ¨å°†è½¬å…¥æ‰€é€‰åˆæ ¼åº“。` : `确认对本次 ${count} è¢‹ç»Ÿä¸€åˆ¤å®šä¸åˆæ ¼ï¼Ÿå…¨éƒ¨å°†ä»Žæš‚存库存移除并标记报废。`)) return;
  error.value = ''; notice.value = '';
  pendingCode.value = bag.value.code; pendingType.value = scanType.value; pendingIds.value = [...bag.value.pendingBagIds!];
  sessionStorage.setItem(storageKey, JSON.stringify({ code: pendingCode.value, scanType: pendingType.value, bagIds: pendingIds.value })); busy.value = true;
  try {
    const result = await submitSeedQualityGroup({ code: bag.value.code, scanType: scanType.value, bagIds: pendingIds.value, qualifiedWarehouseId: warehouseId.value, reason: reason.value.trim() || undefined, indicators: indicators.value.map(i => ({ code: i.code, value: Number(i.value), minValue: Number(i.minValue), maxValue: Number(i.maxValue) })) });
    clearPending(); notice.value = `本次 ${result.processedCount} è¢‹` + (passed.value ? '质检合格,已转入合格库。' : '质检不合格,已记录原因并移除暂存库存。');
    bag.value = undefined; reason.value = ''; indicators.value.forEach(i => i.value = '');
  } catch (e) { error.value = '提交结果待确认:' + errorText(e) + '。请核实后再操作下一袋。'; } finally { busy.value = false; }
}
async function verify() {
  if (busy.value || !pendingCode.value) return;
  busy.value = true; error.value = '';
  try {
    const data = await resolveSeedQuality(pendingCode.value, pendingType.value);
    const completed = pendingIds.value.filter(id => data.completedBagIds?.includes(id));
    if (pendingIds.value.length ? completed.length === pendingIds.value.length : !!data.qualityId) { notice.value = '已核实:本次袋码均已有质检记录,请在质检记录中核对结果。'; bag.value = undefined; clearPending(); }
    else if (completed.length) error.value = '本次清单仅部分袋码已有质检记录,请核对质检记录后联系负责人处理,当前保留待确认操作。';
    else if (window.confirm('当前未查到本次袋码质检记录。确认原请求已结束后,可重新扫描并核对指标。继续?')) { clearPending(); bag.value = undefined; notice.value = '请重新扫描并录入指标;后端会拦截重复质检。'; }
  } catch (e) { error.value = errorText(e); } finally { busy.value = false; }
}
onMounted(async () => {
  const saved = sessionStorage.getItem(storageKey);
  if (saved) {
    try { const pending = JSON.parse(saved); if (pending && typeof pending === 'object' && pending.code) { pendingCode.value = pending.code; pendingType.value = pending.scanType; pendingIds.value = pending.bagIds || []; } else pendingCode.value = saved; }
    catch { pendingCode.value = saved; } // å…¼å®¹å‡çº§å‰ä¿å­˜çš„单袋待确认码值。
  }
  busy.value = true;
  try { warehouses.value = (await getWarehouseSimpleList()).filter(w => w.type === 20 && !w.frozen); }
  catch (e) { error.value = errorText(e); } finally { busy.value = false; }
});
onBeforeRouteLeave(() => !busy.value && (!bag.value || !!pendingCode.value || window.confirm('离开将放弃未提交的质检指标,是否继续?')));
</script>
<template>
  <p v-if="notice" class="m-success" role="status">{{notice}}</p>
  <p v-if="error" class="m-error" role="alert">{{error}}</p>
  <div v-if="pendingCode" class="m-panel"><h2 class="m-title">质检结果待确认</h2><p>{{pendingCode}}</p><button class="m-primary" :disabled="busy" @click="verify">核实质检结果</button></div>
  <template v-else>
    <label class="m-field"><span>质检扫码类型</span><select v-model="scanType" :disabled="busy" @change="bag = undefined; notice = ''; error = ''"><option value="BAG">袋码</option><option value="PALLET">托盘码</option><option value="BATCH">生产批次码</option></select></label>
    <p class="m-muted">扫描{{typeNames[scanType]}},核对待检袋数,统一录入四项指标。已检、作废袋码不会重复处理。</p>
    <Scanner :disabled="busy" @code="identify" />
    <form v-if="bag" @submit.prevent="submit">
      <div class="m-panel"><h2 class="m-title">本次检验对象</h2><dl><div class="m-row"><dt>对象类型</dt><dd>{{typeNames[scanType]}}</dd></div><div class="m-row"><dt>对象码值</dt><dd>{{bag.code}}</dd></div><div class="m-row"><dt>业务码</dt><dd>{{bag.businessCode || '—'}}</dd></div><div class="m-row"><dt>生产批次</dt><dd>{{bag.batchCode || '—'}}</dd></div><div class="m-row"><dt>有效袋数</dt><dd>{{bag.totalCount}}</dd></div><div class="m-row"><dt>已检袋数</dt><dd>{{bag.completedBagIds?.length || 0}}</dd></div><div class="m-row"><dt>本次待检</dt><dd>{{bag.pendingBagIds?.length || 0}} è¢‹</dd></div></dl><p v-if="scanType !== 'BAG'" class="m-warning">以下指标和检验结论将应用于本次全部待检袋码,请确认抽样结果适用于这{{bag.pendingBagIds?.length}}袋。</p></div>
      <fieldset v-for="i in indicators" :key="i.code" class="m-panel m-quality-indicator" :disabled="busy"><legend>{{i.name}}(%)</legend>
        <label class="m-field"><span>实测值</span><input v-model="i.value" type="number" inputmode="decimal" min="0" max="100" step="any" required placeholder="输入实测百分比" /></label>
        <div class="m-grid"><label class="m-field"><span>合格下限</span><input v-model="i.minValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><label class="m-field"><span>合格上限</span><input v-model="i.maxValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label></div>
      </fieldset>
      <div class="m-panel"><p class="m-muted">上下限沿用现有质检规则录入方式,请按该品种检验标准核对。</p><Picker v-model="warehouseId" label="目标合格库" :options="warehouses" :disabled="busy" /><p v-if="!warehouses.length && !busy" class="m-warning">暂无可用合格库,请联系管理员配置。</p><label class="m-field"><span>{{complete && !passed ? '不合格原因(必填)' : '检验备注'}}</span><textarea v-model="reason" :disabled="busy" :required="complete && !passed" rows="3" maxlength="255" /></label></div>
      <div class="m-action-bar"><p v-if="complete" :class="passed ? 'm-success' : 'm-warning'">预判:{{passed ? '合格,提交后转入合格库' : '不合格,提交后移除暂存库存'}}。最终以服务端结果为准。</p><button class="m-primary" :disabled="busy || !complete || !warehouseId">{{busy ? '正在提交…' : '确认提交质检'}}</button></div>
    </form>
  </template>
  <RouterLink v-if="allowedMobilePath('/mobile/quality-records')" to="/mobile/quality-records" class="m-link">查看质检记录</RouterLink>
</template>
src/views/mobile/query.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { onBeforeRouteLeave, useRoute } from 'vue-router';
import { useUserStore } from '@vben/stores';
import { bindBagCodePallet, getBagCode, getBagCodePage } from '#/api/mes/pro/bagCode';
import { bindPalletBatch, getPallet, getPalletPage } from '#/api/mes/pro/pallet';
import { getBatchPage } from '#/api/mes/pro/batch';
import { can } from '#/mobile/access';
import { lookup, errorText, writeBlockReason, type TraceResult, type TraceObject } from '#/mobile/api';
import Scanner from './scanner.vue';
defineOptions({name:'MobileQuery'});
const route=useRoute(),key=route.path.split('/')[2]||'bags';
const expected={bags:'bag',pallets:'pallet',batches:'batch'}[key];
const busy=ref(false),error=ref(''),notice=ref(''),detail=ref<TraceResult>(),rows=ref<TraceObject[]>([]),page=ref(1),total=ref(0),filter=ref('');
const binding=ref(false),target=ref<TraceResult>(),bags=ref<TraceResult[]>([]);
type PendingBind={kind:string;ids:number[];targetId:number};
const pending=ref<PendingBind>();
const storageKey='mom-mobile-bind-'+useUserStore().userInfo?.id+'-'+key;
const canBind=computed(()=>key==='bags'?can('mes:pro-bag-code:update')&&can('mes:pro-pallet:query'):key==='pallets'&&can('mes:pro-pallet:update')&&can('mes:pro-batch:query'));
let appliedFilter='';
async function load(reset=false){
  if(busy.value)return;busy.value=true;error.value='';
  const nextPage=reset?1:page.value+1;
  const search=reset?filter.value.trim():appliedFilter;
  try{
    const params={pageNo:nextPage,pageSize:20,code:search||undefined};
    const r=await (key==='bags'?getBagCodePage(params):key==='pallets'?getPalletPage(params):getBatchPage(params));
    rows.value=reset?r.list:[...rows.value,...r.list];total.value=r.total;page.value=nextPage;
    appliedFilter=search;
  }catch(e){error.value=errorText(e);}finally{busy.value=false;}
}
onMounted(()=>{try{pending.value=JSON.parse(sessionStorage.getItem(storageKey)||'null')||undefined;}catch{sessionStorage.removeItem(storageKey);}load(true);});
async function identify(code:string){
  if(busy.value||pending.value)return;busy.value=true;error.value='';notice.value='';
  try{
    const r=await lookup(code);
    if(binding.value&&writeBlockReason(r))throw new Error(writeBlockReason(r));
    if(!binding.value){
      if(r.type!==expected)throw new Error('该码类型与当前查询页面不一致,请使用对应查询入口');
      detail.value=r;
    }else if(!target.value){
      if(r.type!==(key==='bags'?'pallet':'batch'))throw new Error(key==='bags'?'请先扫描目标托盘':'请先扫描目标批次');
      target.value=r;
    }else{
      if(r.type!==expected)throw new Error('请扫描'+(key==='bags'?'袋码':'托盘码'));
      if(key==='bags'){
        if(r.data.palletId)throw new Error('该袋码已绑定托盘,请核实后操作');
        if(r.data.batchId&&target.value.data.batchId&&r.data.batchId!==target.value.data.batchId)throw new Error('袋码与目标托盘批次不一致');
        if(bags.value.some(b=>b.data.id===r.data.id))throw new Error('本次已经采集该袋码');
        bags.value.push(r);
      }else{
        if(r.data.batchId)throw new Error('该托盘已绑定批次');
        detail.value=r;
      }
    }
  }catch(e){error.value=errorText(e);}finally{busy.value=false;}
}
function startBind(){binding.value=true;detail.value=undefined;target.value=undefined;bags.value=[];notice.value='';error.value='';}
function clearPending(){pending.value=undefined;sessionStorage.removeItem(storageKey);}
async function submitBind(){
  if(busy.value||!target.value?.data.id)return;
  const ids=key==='bags'?bags.value.map(b=>b.data.id!):detail.value?.data.id?[detail.value.data.id]:[];
  if(!ids.length)return;
  const operation={kind:key,ids,targetId:target.value.data.id};
  pending.value=operation;sessionStorage.setItem(storageKey,JSON.stringify(operation));busy.value=true;error.value='';
  try{
    if(key==='bags')await bindBagCodePallet({ids,palletId:operation.targetId});
    else await bindPalletBatch({ids,batchId:operation.targetId});
    clearPending();binding.value=false;bags.value=[];detail.value=undefined;notice.value='绑定成功,已更新对象的绑定关系。';
  }catch(e){error.value='绑定结果待确认:'+errorText(e)+'。请先核实,勿重复提交。';}
  finally{busy.value=false;}
}
async function verifyBind(){
  if(!pending.value||busy.value)return;busy.value=true;error.value='';
  try{
    const p=pending.value;
    const states=await Promise.all(p.ids.map(id=>p.kind==='bags'?getBagCode(id):getPallet(id)));
    const all=states.every(s=>p.kind==='bags'?('palletId'in s&&s.palletId===p.targetId):s.batchId===p.targetId);
    const none=states.every(s=>p.kind==='bags'?(!('palletId'in s)||!s.palletId):!s.batchId);
    if(all){notice.value='已核实:所有对象绑定成功。';clearPending();binding.value=false;bags.value=[];detail.value=undefined;}
    else if(none){notice.value='已核实:对象仍未绑定。请重新查询对象后采集提交。';clearPending();binding.value=false;bags.value=[];detail.value=undefined;}
    else error.value='对象绑定结果不一致,请联系负责人核对。当前保留待确认操作。';
  }catch(e){error.value=errorText(e);}finally{busy.value=false;}
}
onBeforeRouteLeave(()=>!busy.value&&(!binding.value||!!pending.value||window.confirm('离开将放弃本次未提交的绑定清单,是否继续?')));
</script>
<template>
  <p v-if="notice" class="m-success" role="status">{{notice}}</p>
  <p v-if="error" class="m-error" role="alert">{{error}}</p>
  <div v-if="pending" class="m-panel"><h2 class="m-title">绑定结果待确认</h2><p>本次 {{pending.ids.length}} ä¸ªå¯¹è±¡ï¼Œç›®æ ‡ç¼–号 {{pending.targetId}}。</p><button class="m-primary" :disabled="busy" @click="verifyBind">核实绑定结果</button></div>
  <template v-else>
    <template v-if="binding">
      <h2 class="m-title">{{!target?(key==='bags'?'先扫描目标托盘':'先扫描目标批次'):(key==='bags'?'逐袋采集':'扫描待绑定托盘')}}</h2>
      <div v-if="target" class="m-panel"><p>目标:{{target.code}}</p><p class="m-muted">{{target.data.itemName||'未录入品种'}} Â· {{target.data.batchCode||'未绑定批次'}}</p></div>
    </template>
    <Scanner :disabled="busy" @code="identify"/>
    <div v-if="detail&&!binding" class="m-panel">
      <h2 class="m-title">{{detail.data.itemName||'对象详情'}}</h2>
      <p v-if="detail.type === 'batch'" class="m-status">{{detail.data.status === 30 ? '已完成' : detail.data.status === 20 ? '生产中' : detail.data.status === 40 ? '已作废' : '草稿'}} Â· å·²å…¥åº“ {{detail.data.inboundBagQuantity || 0}} è¢‹</p>
      <p v-else class="m-status">{{detail.data.inboundTime ? '已入库' : '未入库'}}<span v-if="detail.type === 'pallet'"> Â· å¾…入库 {{detail.data.pendingBagQuantity ?? '—'}} è¢‹</span></p>
      <p v-if="writeBlockReason(detail)" class="m-warning">{{writeBlockReason(detail)}}</p>
      <dl><div class="m-row"><dt>码值</dt><dd>{{detail.code}}</dd></div><div class="m-row"><dt>批次</dt><dd>{{detail.data.batchCode||detail.data.tempCode||'未绑定'}}</dd></div><div class="m-row"><dt>托盘</dt><dd>{{detail.data.palletCode||'—'}}</dd></div><div class="m-row"><dt>生产日期</dt><dd>{{detail.data.produceDate||'未录入'}}</dd></div><div class="m-row"><dt>产线</dt><dd>{{detail.data.lineName||'未录入'}}</dd></div></dl>
      <dl v-if="detail.type==='bag'"><div class="m-row"><dt>追溯码</dt><dd>{{detail.data.code}}</dd></div><div class="m-row"><dt>业务码</dt><dd>{{detail.data.businessCode||'未生成'}}</dd></div></dl>
      <dl v-else><div class="m-row"><dt>业务码</dt><dd>{{detail.data.businessCode||'未生成'}}</dd></div></dl>
    </div>
    <template v-if="binding">
      <p v-if="key==='bags'" class="m-muted">已采集 {{bags.length}} è¢‹ï¼Œå°šæœªæäº¤ç»‘定</p>
      <article v-for="(bag,i) in bags" :key="bag.data.id" class="m-record"><p>{{bag.code}}</p><button class="m-detail-toggle" :disabled="busy" @click="bags.splice(i,1)">移除</button></article>
      <p v-if="key==='pallets'&&detail">待绑定托盘:{{detail.code}}</p>
      <div class="m-action-bar"><button class="m-primary" :disabled="busy||!target||(key==='bags'?!bags.length:!detail)" @click="submitBind">{{busy?'正在绑定…':'确认绑定'}}</button><button class="m-secondary" :disabled="busy" @click="binding=false;bags=[];target=undefined;detail=undefined">取消本次绑定</button></div>
    </template>
    <template v-else>
      <button v-if="canBind" class="m-primary" :disabled="busy" @click="startBind">{{key==='bags'?'开始袋码绑定托盘':'开始托盘绑定批次'}}</button>
      <h2 class="m-section-title">对象列表</h2>
      <form class="m-stack" @submit.prevent="load(true)"><label class="m-field"><span>按码值查询</span><input v-model="filter" placeholder="输入码值" /></label><button class="m-secondary" :disabled="busy">查询列表</button></form>
      <p v-if="!rows.length&&!busy" class="m-empty">暂无匹配对象</p>
      <button v-for="row in rows" :key="row.id" class="m-feature" style="width:100%;background:transparent;border-top:0;border-left:0;border-right:0;text-align:left" :disabled="busy" @click="identify(row.code||row.tempCode||'')"><div><strong>{{row.itemName||row.code||row.tempCode}}</strong><small>{{row.code||row.tempCode}} Â· {{row.batchCode||'点击查看详情'}}</small></div></button>
      <button v-if="rows.length<total" class="m-secondary" :disabled="busy" @click="load()">{{busy?'正在加载…':'加载更多'}}</button>
    </template>
  </template>
</template>
src/views/mobile/records.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { onMounted,ref } from 'vue';
import type { MesProScanEventApi } from '#/api/mes/pro/scanEvent';
import { myRecords,errorText } from '#/mobile/api';
const now=new Date();
const day=ref(now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0')+'-'+String(now.getDate()).padStart(2,'0'));
const status=ref(''),page=ref(1),total=ref(0),busy=ref(false),error=ref(''),rows=ref<MesProScanEventApi.ScanEvent[]>([]);
let applied={day:day.value,status:status.value};
async function load(reset=false){
if(busy.value)return;busy.value=true;error.value='';const nextPage=reset?1:page.value+1;
const filters=reset?{day:day.value,status:status.value}:applied;
try{const r=await myRecords({pageNo:nextPage,pageSize:20,status:filters.status||undefined,receivedTime:filters.day?[filters.day+' 00:00:00',filters.day+' 23:59:59']:undefined});rows.value=reset?r.list:[...rows.value,...r.list];total.value=r.total;page.value=nextPage;applied=filters;}
catch(e){error.value=errorText(e);}finally{busy.value=false;}
}
onMounted(()=>load(true));
</script>
<template>
<form class="m-panel" @submit.prevent="load(true)"><label class="m-field"><span>作业日期</span><input v-model="day" type="date"/></label><label class="m-field"><span>结果</span><select v-model="status"><option value="">全部结果</option><option value="SUCCESS">已入库</option><option value="FAILED">失败</option></select></label><button class="m-secondary" :disabled="busy">{{busy?'正在查询…':'查询本人记录'}}</button></form>
<p v-if="error" class="m-error" role="alert">{{error}}</p><p v-if="!rows.length&&!busy" class="m-empty">该日期暂无扫码入库记录</p>
<article v-for="r in rows" :key="r.id" class="m-panel"><span :class="['m-status',r.status?.toLowerCase()]">{{r.status==='SUCCESS'?'已入库':'处理失败'}}</span><h2 class="m-title" style="margin-top:8px">{{r.bagCode}}</h2><p class="m-muted">{{r.receivedTime}}</p><p v-if="r.errorMessage" class="m-error">{{r.errorMessage}}</p><details><summary class="m-link">查看本次作业明细</summary><dl><div class="m-row"><dt>操作号</dt><dd>{{r.eventId}}</dd></div><div class="m-row"><dt>批次</dt><dd>{{r.batchCode||'未解析'}}</dd></div><div class="m-row"><dt>扫码类型</dt><dd>{{r.scanType}}</dd></div><div class="m-row"><dt>数量</dt><dd>{{r.quantity}}</dd></div><div class="m-row"><dt>完成时间</dt><dd>{{r.eventTime||'未完成'}}</dd></div></dl></details></article>
<button v-if="rows.length<total" class="m-secondary" :disabled="busy" @click="load()">加载更多</button>
</template>
src/views/mobile/sales.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { useUserStore } from '@vben/stores';
import { getProductSalesPage, getProductSales, previewProductSalesScan, scanProductSales, checkProductSalesQuantity, stockProductSales, shippingProductSales, finishProductSales, type MesWmProductSalesApi } from '#/api/mes/wm/productsales';
import { getProductSalesLinePage, type MesWmProductSalesLineApi } from '#/api/mes/wm/productsales/line';
import { getProductSalesDetailListByLineId, type MesWmProductSalesDetailApi } from '#/api/mes/wm/productsales/detail';
import { can, extractCode } from '#/mobile/access';
import { errorText } from '#/mobile/api';
import Scanner from './scanner.vue';
import PickingBags from '../wls/productsales/modules/picking-bags.vue';
type Sales = MesWmProductSalesApi.ProductSales;
type Preview = MesWmProductSalesApi.ProductSalesScanResponse;
type Line = MesWmProductSalesLineApi.ProductSalesLine;
type Detail = MesWmProductSalesDetailApi.ProductSalesDetail;
type Action = 'scan' | 'stock' | 'shipping' | 'finish';
type Pending = { salesId: number; action: Action; before: number; preview?: Preview; carrier?: string; shippingNumber?: string };
const statusNames: Record<number, string> = { 0: '草稿', 1: '待检测', 2: '待拣货', 10: '待填写运单', 3: '待执行出库', 4: '已完成', 5: '已取消' };
const busy = ref(false), error = ref(''), notice = ref(''), code = ref(''), status = ref('');
const rows = ref<Sales[]>([]), page = ref(0), total = ref(0), sales = ref<Sales>();
const lines = ref<Line[]>([]), details = ref<Detail[]>([]), preview = ref<Preview>(), pending = ref<Pending>();
const carrier = ref(''), shippingNumber = ref('');
const scanType = ref<'BAG' | 'PALLET' | 'BATCH'>('BAG'), selectedBagIds = ref<number[]>([]);
const storageKey = 'mom-mobile-sales-' + useUserStore().userInfo?.id;
let filters: { code?: string; status?: number } = {};
const picking = computed(() => sales.value?.status === 2 && can('mes:wm-product-sales:stock'));
function clearPending() { pending.value = undefined; sessionStorage.removeItem(storageKey); }
async function load(reset = true) {
  if (busy.value || pending.value) return;
  busy.value = true; error.value = '';
  const next = reset ? 1 : page.value + 1;
  const query = reset ? { code: code.value.trim() || undefined, status: status.value === '' ? undefined : Number(status.value) } : filters;
  try { const data = await getProductSalesPage({ ...query, pageNo: next, pageSize: 20 }); rows.value = reset ? data.list : [...rows.value, ...data.list]; page.value = next; total.value = data.total; filters = query; }
  catch (e) { error.value = errorText(e); } finally { busy.value = false; }
}
async function readOrder(id: number) {
  const order = await getProductSales(id);
  if (!order) throw new Error('出库单不存在或已被删除');
  const all: Line[] = [];
  for (let n = 1; ; n++) {
    const data = await getProductSalesLinePage({ salesId: id, pageNo: n, pageSize: 100 });
    all.push(...data.list);
    if (all.length >= data.total || !data.list.length) break;
  }
  const picked = (await Promise.all(all.filter(l => l.id != null).map(l => getProductSalesDetailListByLineId(l.id!)))).flat();
  sales.value = order; lines.value = all; details.value = picked;
  carrier.value = order.carrier || ''; shippingNumber.value = order.shippingNumber || '';
}
async function open(id?: number) {
  if (!id || busy.value || pending.value) return;
  busy.value = true; error.value = ''; notice.value = ''; preview.value = undefined;
  try { await readOrder(id); } catch (e) { error.value = errorText(e); } finally { busy.value = false; }
}
function pickedQuantity(lineId?: number) { return details.value.filter(d => d.lineId === lineId).reduce((sum, d) => sum + Number(d.quantity || 0), 0); }
async function identify(raw: string) {
  if (busy.value || !picking.value || pending.value) return;
  busy.value = true; error.value = ''; notice.value = ''; preview.value = undefined;
  try {
    selectedBagIds.value = [];
    const data = await previewProductSalesScan(extractCode(raw), scanType.value, sales.value?.id);
    if (!data.codeId || !data.scanQuantity || data.scanQuantity <= 0) throw new Error('无法确认扫码数量,请核对码值');
    const line = lines.value.find(l => l.itemId === data.itemId);
    if (!line) throw new Error('扫码品种不在当前出库单中');
    data.itemName = line.itemName; preview.value = data;
    if (data.scanType === 'BAG') selectedBagIds.value = data.bags?.filter(b => b.selectable).map(b => b.id) || [];
  } catch (e) { error.value = errorText(e); } finally { busy.value = false; }
}
async function execute(action: Action) {
  const order = sales.value;
  if (!order?.id || busy.value || pending.value) return;
  const permission = action === 'scan' ? 'stock' : action;
  if (!can('mes:wm-product-sales:' + permission)) return;
  if (action === 'scan' && (!picking.value || !preview.value)) return;
  if (action === 'scan' && !selectedBagIds.value.length) { error.value = '请选择本次拣货对应的袋码'; return; }
  if (action === 'stock' && order.status !== 2 || action === 'shipping' && order.status !== 10 || action === 'finish' && order.status !== 3) return;
  if (action === 'shipping' && (!carrier.value.trim() || !shippingNumber.value.trim())) { error.value = '请填写承运商和运输单号'; return; }
  const message = action === 'scan' ? `确认将所选 ${selectedBagIds.value.length} è¢‹åŠ å…¥æœ¬å•æ‹£è´§å¹¶å…³è”è¿½æº¯è¢‹ç ï¼Ÿ` : action === 'stock' ? '确认完成拣货?系统将核对各品种数量。' : action === 'shipping' ? '确认保存运单并进入待执行出库?' : '确认执行出库?本单将扣减库存并标记已完成。';
  if (!window.confirm(message)) return;
  busy.value = true; error.value = ''; notice.value = '';
  try {
    if (action === 'stock' && !await checkProductSalesQuantity(order.id)) throw new Error('拣货数量与出库数量不一致,请核对每个品种');
    const operation: Pending = { salesId: order.id, action, before: order.status!, preview: action === 'scan' ? { ...preview.value, selectedBagIds: [...selectedBagIds.value], scanQuantity: selectedBagIds.value.length } : undefined, carrier: carrier.value.trim(), shippingNumber: shippingNumber.value.trim() };
    pending.value = operation; sessionStorage.setItem(storageKey, JSON.stringify(operation));
    if (action === 'scan') {
      const result = await scanProductSales({ salesId: order.id, scanContent: operation.preview!.scanContent!, scanType: operation.preview!.scanType as 'BAG' | 'PALLET' | 'BATCH', bagIds: operation.preview!.selectedBagIds, quantity: operation.preview!.scanQuantity });
      notice.value = result.alreadyScanned ? '该码已在拣货清单中,本次未重复添加。' : `已加入拣货:${result.outboundQuantity || operation.preview!.scanQuantity} è¢‹ã€‚`;
    } else if (action === 'stock') { await stockProductSales(order.id); notice.value = '拣货完成,请填写运单。'; }
    else if (action === 'shipping') { await shippingProductSales({ id: order.id, carrier: operation.carrier, shippingNumber: operation.shippingNumber }); notice.value = '运单已保存,请核对后执行出库。'; }
    else { await finishProductSales(order.id); notice.value = '出库完成,库存已更新。'; }
    clearPending(); preview.value = undefined;
    await readOrder(order.id);
  } catch (e) { error.value = (pending.value ? '操作结果待确认,请先核实:' : '') + errorText(e); } finally { busy.value = false; }
}
async function verify() {
  if (!pending.value || busy.value) return;
  busy.value = true; error.value = '';
  try {
    const operation = pending.value;
    await readOrder(operation.salesId);
    const requestedIds = operation.preview?.selectedBagIds;
    const found = operation.action === 'scan' ? requestedIds?.length ? requestedIds.every(id => details.value.some(d => d.bagCodeId === id)) : details.value.some(d => operation.preview?.scanType === 'PALLET' ? d.palletCodeId === operation.preview.codeId : d.bagCodeId === operation.preview?.codeId) : sales.value?.status !== operation.before;
    if (operation.action === 'scan' && !found && requestedIds?.some(id => details.value.some(d => d.bagCodeId === id))) { error.value = '所选袋码仅部分存在于拣货明细,请联系负责人核对,不能自动重试。'; return; }
    if (found) {
      notice.value = operation.action === 'scan' ? '已核实:该码已在拣货清单中。' : '当前单据状态:' + statusNames[sales.value!.status!] + '。请核对明细和运单后继续。';
      clearPending(); preview.value = undefined;
    } else if (window.confirm('当前尚未发现该操作结果。确认原请求已结束后,可重新核对再操作。继续?')) { clearPending(); preview.value = undefined; notice.value = '请核对当前单据与拣货清单后重新操作。'; }
  } catch (e) { error.value = errorText(e); } finally { busy.value = false; }
}
onMounted(async () => {
  try { pending.value = JSON.parse(sessionStorage.getItem(storageKey) || 'null') || undefined; } catch { sessionStorage.removeItem(storageKey); }
  if (pending.value) { busy.value = true; try { await readOrder(pending.value.salesId); } catch (e) { error.value = errorText(e); } finally { busy.value = false; } }
  else await load();
});
onBeforeRouteLeave(() => !busy.value && (!preview.value || !!pending.value || window.confirm('离开将放弃尚未确认的扫码对象,是否继续?')));
</script>
<template>
  <p v-if="notice" class="m-success" role="status">{{notice}}</p>
  <p v-if="error" class="m-error" role="alert">{{error}}</p>
  <section v-if="pending" class="m-panel"><h2 class="m-title">出库操作结果待确认</h2><p>单据编号 {{pending.salesId}},请先核实再继续操作。</p><button class="m-primary" :disabled="busy" @click="verify">核实操作结果</button></section>
  <template v-if="sales">
    <button v-if="!pending" class="m-secondary" :disabled="busy" @click="sales = undefined; preview = undefined; load()">返回出库单列表</button>
    <section class="m-panel" style="margin-top:16px"><h2 class="m-title">{{sales.code}}</h2><strong class="m-status">{{statusNames[sales.status!] || '未知状态'}}</strong><dl><div class="m-row"><dt>客户</dt><dd>{{sales.clientName || '—'}}</dd></div><div class="m-row"><dt>收货人</dt><dd>{{sales.contactName || '—'}} {{sales.contactTelephone}}</dd></div><div class="m-row"><dt>收货地址</dt><dd>{{sales.contactAddress || '—'}}</dd></div><div v-if="sales.shippingNumber" class="m-row"><dt>运单</dt><dd>{{sales.carrier}} Â· {{sales.shippingNumber}}</dd></div></dl><button v-if="!pending" class="m-secondary" :disabled="busy" @click="open(sales.id)">刷新单据</button></section>
    <h2 class="m-section-title">出库品种与拣货进度</h2>
    <p v-if="!lines.length" class="m-empty">该单暂无品种明细</p>
    <article v-for="line in lines" :key="line.id" class="m-panel"><h3 class="m-title">{{line.itemName || line.itemCode}}</h3><p>计划 {{line.quantity || 0}} {{line.unitMeasureName || '袋'}} Â· å·²æ‹£ {{pickedQuantity(line.id)}} {{line.unitMeasureName || '袋'}}</p><details><summary>查看拣货明细</summary><div v-for="detail in details.filter(d => d.lineId === line.id)" :key="detail.id" class="m-record"><p>{{detail.batchCode || '未录入批次'}} Â· {{detail.quantity}} {{line.unitMeasureName || '袋'}}</p><p class="m-muted">{{detail.warehouseName || '—'}} / {{detail.locationName || '—'}} / {{detail.areaName || '—'}}</p><p class="m-muted">{{detail.bagCodeId ? '袋码编号 ' + detail.bagCodeId : '托盘编号 ' + detail.palletCodeId}}</p><p v-if="detail.remark" class="m-muted" style="overflow-wrap:anywhere">{{detail.remark}}</p></div></details></article>
    <template v-if="!pending">
      <template v-if="picking"><h2 class="m-section-title">扫码拣货</h2><label class="m-field"><span>拣货扫码类型</span><select v-model="scanType" :disabled="busy" @change="preview = undefined; selectedBagIds = []"><option value="BAG">袋码</option><option value="PALLET">托盘码</option><option value="BATCH">生产批次码</option></select></label><p class="m-muted">支持业务码与追溯码。托盘、批次识别后选择实际拣货袋码,数量按所选袋数计入。</p><Scanner :disabled="busy" @code="identify" /><section v-if="preview" class="m-panel"><h3 class="m-title">核对拣货对象</h3><p>{{preview.itemName}}</p><p style="overflow-wrap:anywhere">{{preview.scanContent}}</p><p>批次 {{preview.batchCode}} Â· å¯æ‹£ {{preview.scanQuantity}} è¢‹</p><PickingBags v-if="preview.scanType !== 'BAG'" v-model="selectedBagIds" :bags="preview.bags || []" :disabled="busy" /><p v-else>袋码 {{preview.bags?.[0]?.code}}</p><p role="status">本次拣货 {{selectedBagIds.length}} è¢‹</p><button class="m-primary" :disabled="busy || !selectedBagIds.length" @click="execute('scan')">确认加入拣货</button><button class="m-detail-toggle" :disabled="busy" @click="preview = undefined">取消本次扫码</button></section></template>
      <form v-if="sales.status === 10 && can('mes:wm-product-sales:shipping')" class="m-panel" @submit.prevent="execute('shipping')"><h2 class="m-title">填写运单</h2><label class="m-field"><span>承运商</span><input v-model="carrier" :disabled="busy" maxlength="100" required /></label><label class="m-field"><span>运输单号</span><input v-model="shippingNumber" :disabled="busy" maxlength="100" required /></label><button class="m-primary" :disabled="busy">保存运单</button></form>
      <div v-if="!preview" class="m-action-bar"><button v-if="picking" class="m-primary" :disabled="busy || !lines.length" @click="execute('stock')">完成拣货并核对数量</button><button v-if="sales.status === 3 && can('mes:wm-product-sales:finish')" class="m-primary" :disabled="busy" @click="execute('finish')">确认执行出库</button></div>
      <p v-if="sales.status === 0 || sales.status === 1" class="m-warning">{{sales.status === 0 ? '该单为草稿,请在 PC ç«¯ç»´æŠ¤å“ç§å¹¶æäº¤åŽè¿›è¡Œç§»åŠ¨æ‹£è´§ã€‚' : '该单待检测,检测完成后才能拣货。'}}</p>
      <p v-if="sales.status === 4" class="m-success">该单已完成出库。</p>
    </template>
  </template>
  <template v-else-if="!pending">
    <form class="m-panel" @submit.prevent="load()"><label class="m-field"><span>出库单号</span><input v-model="code" :disabled="busy" placeholder="输入出库单号" /></label><label class="m-field"><span>单据状态</span><select v-model="status" :disabled="busy"><option value="">全部状态</option><option v-for="(name, value) in statusNames" :key="value" :value="String(value)">{{name}}</option></select></label><button class="m-secondary" :disabled="busy">{{busy ? '正在查询…' : '查询出库单'}}</button></form>
    <p class="m-muted" role="status">共 {{total}} å¼ å‡ºåº“单</p><p v-if="!rows.length && !busy && !error" class="m-empty">暂无匹配的出库单</p>
    <button v-for="row in rows" :key="row.id" class="m-order-button" :disabled="busy" @click="open(row.id)"><strong>{{row.code}}</strong><span>{{statusNames[row.status!] || '未知状态'}}</span><small>{{row.clientName || '未录入客户'}}</small></button>
    <button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">{{busy ? '正在加载…' : '加载更多'}}</button>
  </template>
</template>
src/views/mobile/scanner.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { onBeforeUnmount, onDeactivated, onMounted, ref } from 'vue';
import Icon from '#/mobile/icon.vue';
const props=defineProps<{disabled?:boolean}>();
const emit=defineEmits<{code:[value:string]}>();
const input=ref(''),error=ref(''),active=ref(false),starting=ref(false),video=ref<HTMLVideoElement>();
let stream:MediaStream|undefined,frame=0,generation=0,last=0;
function stop(){generation++;active.value=false;starting.value=false;cancelAnimationFrame(frame);stream?.getTracks().forEach(t=>t.stop());stream=undefined;if(video.value)video.value.srcObject=null;}
function accept(value:string){if(props.disabled)return;stop();input.value=value;emit('code',value);}
async function start(){
  error.value='';stop();const ticket=generation;starting.value=true;
  try{
    if(!window.isSecureContext||!navigator.mediaDevices?.getUserMedia)throw new Error('摄像头需要 HTTPS å’Œç›¸æœºæŽˆæƒï¼Œè¯·ä½¿ç”¨æ‰‹åŠ¨è¾“å…¥');
    const decode=(await import('jsqr')).default;
    const media=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:'environment'}},audio:false});
    if(ticket!==generation){media.getTracks().forEach(t=>t.stop());return;}
    stream=media;active.value=true;starting.value=false;
    await new Promise<void>(r=>requestAnimationFrame(()=>r()));
    if(!video.value||ticket!==generation){stop();return;}
    video.value.srcObject=media;await video.value.play();
    const canvas=document.createElement('canvas'),ctx=canvas.getContext('2d',{willReadFrequently:true});
    function tick(time:number){
      if(ticket!==generation||!active.value)return;
      const v=video.value;
      if(v&&ctx&&v.readyState>=2&&time-last>180&&!props.disabled){
        last=time;canvas.width=Math.min(v.videoWidth,640);canvas.height=Math.round(v.videoHeight*canvas.width/v.videoWidth);
        ctx.drawImage(v,0,0,canvas.width,canvas.height);
        const pixels=ctx.getImageData(0,0,canvas.width,canvas.height);
        const result=decode(pixels.data,canvas.width,canvas.height);
        if(result?.data){accept(result.data);return;}
      }
      frame=requestAnimationFrame(tick);
    }
    frame=requestAnimationFrame(tick);
  }catch(e){
    if(ticket!==generation)return;stop();
    const name=e instanceof DOMException?e.name:'';
    error.value=name==='NotAllowedError'?'相机权限未开启,请在浏览器设置中允许相机,或手动输入码值':
      name==='NotFoundError'?'未找到可用摄像头,请手动输入或使用扫码设备':
      name==='NotReadableError'?'摄像头正被其他程序使用,请关闭后重试或手动输入':
      e instanceof Error?e.message:'相机不可用,请检查权限或手动输入';
  }
}
function visibility(){if(document.hidden)stop();}
onMounted(()=>document.addEventListener('visibilitychange',visibility));
onBeforeUnmount(()=>{stop();document.removeEventListener('visibilitychange',visibility);});
onDeactivated(stop);
</script>
<template>
<div class="m-panel">
  <button v-if="!active" type="button" class="m-secondary" :disabled="disabled||starting" @click="start"><Icon name="scan"/>{{starting?'正在请求相机权限…':'打开摄像头扫码'}}</button>
  <template v-else><video ref="video" class="m-camera" muted playsinline aria-label="二维码取景"/><button type="button" class="m-secondary" @click="stop">关闭摄像头</button></template>
  <p v-if="error" class="m-error" role="alert">{{error}}</p>
  <form @submit.prevent="accept(input)">
    <label class="m-field" style="margin-top:16px"><span>码值 / æº¯æºé“¾æŽ¥</span><input v-model="input" autocomplete="off" :disabled="disabled" placeholder="手动输入或使用扫码设备" maxlength="2048" /></label>
    <button class="m-secondary" :disabled="disabled||!input.trim()" type="submit">{{disabled?'正在处理…':'识别码值'}}</button>
  </form>
</div>
</template>
src/views/mobile/stock.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { onMounted,ref } from 'vue';
import { requestClient } from '#/api/request';
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import { errorText } from '#/mobile/api';
import { getWarehouseSimpleList, type MesWmWarehouseApi } from '#/api/mes/wm/warehouse';
import { getWarehouseLocationSimpleList, type MesWmWarehouseLocationApi } from '#/api/mes/wm/warehouse/location';
import { getWarehouseAreaSimpleList, type MesWmWarehouseAreaApi } from '#/api/mes/wm/warehouse/area';
import Picker from './picker.vue';
defineOptions({name:'MobileStock'});
const positionLoading=ref(false);
let positionRequest=0;
const warehouses=ref<MesWmWarehouseApi.Warehouse[]>([]),locations=ref<MesWmWarehouseLocationApi.WarehouseLocation[]>([]),areas=ref<MesWmWarehouseAreaApi.WarehouseArea[]>([]);
const warehouseId=ref<number>(),locationId=ref<number>(),areaId=ref<number>();
async function changeWarehouse(){
 const ticket=++positionRequest,id=warehouseId.value;locationId.value=undefined;areaId.value=undefined;locations.value=[];areas.value=[];positionLoading.value=!!id;
 try{if(id){const list=await getWarehouseLocationSimpleList(id);if(ticket===positionRequest)locations.value=list;}}
 catch(e){if(ticket===positionRequest)error.value=errorText(e);}finally{if(ticket===positionRequest)positionLoading.value=false;}
}
async function changeLocation(){
 const ticket=++positionRequest,id=locationId.value;areaId.value=undefined;areas.value=[];positionLoading.value=!!id;
 try{if(id){const list=await getWarehouseAreaSimpleList(id);if(ticket===positionRequest)areas.value=list;}}
 catch(e){if(ticket===positionRequest)error.value=errorText(e);}finally{if(ticket===positionRequest)positionLoading.value=false;}
}
const keyword=ref(''),batch=ref(''),page=ref(1),busy=ref(false),error=ref(''),total=ref(0),rows=ref<MesWmMaterialStockApi.MaterialStock[]>([]),expanded=ref<number>();
type Filters={itemName?:string;batchCode?:string;warehouseId?:number;locationId?:number;areaId?:number};
let applied:Filters={};
async function load(reset=false){
  if(busy.value)return;busy.value=true;error.value='';const nextPage=reset?1:page.value+1;
  const filters=reset?{itemName:keyword.value.trim()||undefined,batchCode:batch.value.trim()||undefined,warehouseId:warehouseId.value,locationId:locationId.value,areaId:areaId.value}:applied;
  try{
    const r=await requestClient.get<{list:MesWmMaterialStockApi.MaterialStock[];total:number}>('/mes/wm/material-stock/page',{params:{pageNo:nextPage,pageSize:20,...filters}});
    rows.value=reset?r.list:[...rows.value,...r.list];total.value=r.total;page.value=nextPage;
    applied=filters;
  }catch(e){error.value=errorText(e);}finally{busy.value=false;}
}
onMounted(async()=>{void load(true);try{warehouses.value=await getWarehouseSimpleList();}catch(e){error.value=errorText(e);}});
</script>
<template>
<form class="m-panel" @submit.prevent="load(true)">
<label class="m-field"><span>品种名称</span><input v-model="keyword" placeholder="搜索品种"/></label>
<label class="m-field"><span>批次号</span><input v-model="batch" placeholder="可选,输入批次号"/></label>
<details><summary class="m-link">按仓库 / åº“位筛选</summary>
<Picker v-model="warehouseId" label="仓库" :options="warehouses" :disabled="busy" allow-empty placeholder="全部仓库" @update:model-value="changeWarehouse"/>
<Picker v-model="locationId" label="库区" :options="locations" :disabled="busy||!warehouseId" :loading="positionLoading" allow-empty placeholder="全部库区" @update:model-value="changeLocation"/>
<Picker v-model="areaId" label="库位" :options="areas" :disabled="busy||!locationId" :loading="positionLoading" allow-empty placeholder="全部库位"/>
</details>
<button class="m-primary" :disabled="busy">{{busy?'正在查询…':'查询库存'}}</button>
</form>
<p v-if="error" class="m-error" role="alert">{{error}}</p>
<p v-if="!rows.length&&!busy" class="m-empty">暂无匹配库存</p>
<article v-for="row in rows" :key="row.id" class="m-panel">
<h2 class="m-title">{{row.itemName}}</h2><p><strong>{{row.availableQuantity??row.quantity??0}}</strong> {{row.unitMeasureName}} <span class="m-muted">{{row.availableQuantity!=null?'可用':'在库'}}</span></p>
<p class="m-muted">{{row.warehouseName}} / {{row.locationName}} / {{row.areaName}}</p><p class="m-muted">批次:{{row.batchCode||'未录入'}}</p>
<button class="m-detail-toggle" :aria-expanded="expanded===row.id" @click="expanded=expanded===row.id?undefined:row.id"> {{expanded===row.id?'收起明细':'查看数量明细'}}</button>
<dl v-if="expanded===row.id"><div class="m-row"><dt>在库数量</dt><dd>{{row.quantity}} {{row.unitMeasureName}}</dd></div><div class="m-row"><dt>冻结数量</dt><dd>{{row.frozenQuantity??0}}</dd></div><div class="m-row"><dt>占用数量</dt><dd>{{row.reservedQuantity??0}}</dd></div><div class="m-row"><dt>入库时间</dt><dd>{{row.receiptTime||'未录入'}}</dd></div></dl>
</article>
<button v-if="rows.length<total" class="m-secondary" :disabled="busy" @click="load()">加载更多</button>
</template>
src/views/wls/productsales/data.ts
@@ -534,7 +534,7 @@
      title: '扫码来源',
      minWidth: 110,
      formatter: ({ row }) =>
        row.palletCodeId ? `托盘码 #${row.palletCodeId}` : row.bagCodeId ? `袋码 #${row.bagCodeId}` : '手工拣货',
         row.bagCodeId ? `袋码 #${row.bagCodeId}${row.palletCodeId ? ` / æ‰˜ç›˜ #${row.palletCodeId}` : ''}` : row.palletCodeId ? `托盘码 #${row.palletCodeId}` : '手工拣货',
    },
    {
      field: 'warehouseName',
@@ -556,6 +556,7 @@
      title: '数量',
      width: 100,
    },
    { field: 'remark', title: '袋码与扫码来源', minWidth: 240 },
    ...(stockable
      ? [
          {
src/views/wls/productsales/modules/detail-list.vue
@@ -83,6 +83,7 @@
              label: $t('common.edit'),
              type: 'link',
              icon: ACTION_ICON.EDIT,
              ifShow: !row.bagCodeId,
              onClick: handleEdit.bind(null, row),
            },
            {
src/views/wls/productsales/modules/form.vue
@@ -16,6 +16,7 @@
  createProductSales,
  finishProductSales,
  getProductSales,
  previewProductSalesScan,
  scanProductSales,
  shippingProductSales,
  stockProductSales,
@@ -26,12 +27,15 @@
import { useFormSchema } from '../data';
import LineList from './line-list.vue';
import PickingBags from './picking-bags.vue';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const formData = ref<MesWmProductSalesApi.ProductSales>();
const scanContent = ref('');
const scanQuantity = ref<number>();
const scanType = ref<'BAG' | 'PALLET' | 'BATCH'>('BAG');
const scanPreview = ref<MesWmProductSalesApi.ProductSalesScanResponse>();
const selectedBagIds = ref<number[]>([]);
const scanLoading = ref(false);
const scanRefreshKey = ref(0);
const originalSnapshot = ref(''); // è¡¨å•原始数据快照,用于提交时跳过未变更的保存请求
@@ -104,25 +108,45 @@
  }
}
/** æ‰«ç æ‹£è´§ï¼šæ‰«ç æžªé€šå¸¸ä»¥å›žè½¦ç»“束输入 */
/** å…ˆè¯†åˆ«å¯¹è±¡å¹¶é€‰æ‹©è¢‹ç ï¼Œå›žè½¦ä¸ä¼šç›´æŽ¥å†™å…¥æ‹£è´§ã€‚ */
async function handleScan() {
  if (!formData.value?.id || !scanContent.value.trim() || scanLoading.value) {
    return;
  }
  scanLoading.value = true;
  try {
    scanPreview.value = undefined;
    selectedBagIds.value = [];
    const result = await previewProductSalesScan(scanContent.value.trim(), scanType.value, formData.value.id);
    scanPreview.value = result;
    if (result.scanType === 'BAG') selectedBagIds.value = result.bags?.filter(b => b.selectable).map(b => b.id) || [];
    if (!result.scanQuantity) message.warning('当前对象没有可拣袋码,请核对质检和库存状态');
  } finally { scanLoading.value = false; }
}
async function confirmScan() {
  if (!formData.value?.id || !scanPreview.value || !selectedBagIds.value.length || scanLoading.value) return;
  const source = scanPreview.value;
  const bagIds = [...selectedBagIds.value];
  scanLoading.value = true;
  try { await confirm(`确认将所选 ${bagIds.length} è¢‹åŠ å…¥æ‹£è´§å¹¶å…³è”è¿½æº¯è¢‹ç ï¼Ÿ`); }
  catch { scanLoading.value = false; return; }
  try {
    const result = await scanProductSales({
      quantity: scanQuantity.value,
      quantity: bagIds.length,
      salesId: formData.value.id,
      scanContent: scanContent.value.trim(),
      scanContent: source.scanContent!,
      scanType: source.scanType as 'BAG' | 'PALLET' | 'BATCH',
      bagIds,
    });
    message[result.alreadyScanned ? 'warning' : 'success'](
      result.alreadyScanned
        ? `该码已扫描,明细编号:${result.detailId ?? '-'}`
        : `${result.message || '扫码成功'}${result.scanType ? `(${result.scanType === 'PALLET' ? '托盘码' : '袋码'})` : ''}`,
        : `已加入拣货 ${result.outboundQuantity} è¢‹ï¼Œé€è¢‹å…³è”追溯码`,
    );
    scanContent.value = '';
    scanQuantity.value = undefined;
    scanPreview.value = undefined;
    selectedBagIds.value = [];
    scanRefreshKey.value += 1;
  } finally {
    scanLoading.value = false;
@@ -228,6 +252,9 @@
    }
  },
  async onOpenChange(isOpen: boolean) {
    scanPreview.value = undefined;
    selectedBagIds.value = [];
    scanContent.value = '';
    if (!isOpen) {
      formData.value = undefined;
      originalSnapshot.value = '';
@@ -262,25 +289,28 @@
      <Divider>物料信息</Divider>
      <div class="mx-4">
        <div v-if="isStock" class="mb-3 flex items-center gap-2 px-4">
          <label for="sales-picking-type" class="sr-only">拣货扫码类型</label>
          <a-select id="sales-picking-type" v-model:value="scanType" :disabled="scanLoading" style="width:140px" @change="scanPreview = undefined; selectedBagIds = []">
            <a-select-option value="BAG">袋码</a-select-option><a-select-option value="PALLET">托盘码</a-select-option><a-select-option value="BATCH">生产批次码</a-select-option>
          </a-select>
          <a-input
            v-model:value="scanContent"
            :disabled="scanLoading"
            autofocus
            class="flex-1"
            placeholder="请扫描袋码或托盘码,回车提交"
            placeholder="扫描所选类型码,回车识别"
            @press-enter="handleScan"
          />
          <a-input-number
            v-model:value="scanQuantity"
            :disabled="scanLoading"
            :min="0"
            placeholder="数量(可选)"
            style="width: 140px"
          />
          <Button :loading="scanLoading" type="default" @click="handleScan">
            æ‰«ç 
            è¯†åˆ«ç å€¼
          </Button>
        </div>
        <section v-if="isStock && scanPreview" class="mb-4 rounded-lg border border-border p-4">
          <h3 class="font-semibold">核对拣货对象</h3><p>批次 {{scanPreview.batchCode}} Â· å¯æ‹£ {{scanPreview.scanQuantity}} è¢‹</p>
          <PickingBags v-if="scanPreview.scanType !== 'BAG'" v-model="selectedBagIds" :bags="scanPreview.bags || []" :disabled="scanLoading" />
          <p v-else>袋码 {{scanPreview.bags?.[0]?.code}} Â· æœ¬æ¬¡ {{selectedBagIds.length}} è¢‹</p>
          <div class="mt-3 flex gap-2"><Button type="primary" :loading="scanLoading" :disabled="!selectedBagIds.length" @click="confirmScan">确认加入拣货</Button><Button :disabled="scanLoading" @click="scanPreview = undefined">取消本次扫码</Button></div>
        </section>
        <LineList
          :form-type="formType"
          :notice-id="formData.noticeId"
@@ -298,7 +328,7 @@
        >
          <Button type="primary">提交</Button>
        </Popconfirm>
        <Button v-if="isStock" type="primary" @click="handleStock">
        <Button v-if="isStock" type="primary" :disabled="scanLoading || !!scanPreview" @click="handleStock">
          æ‰§è¡Œæ‹£è´§
        </Button>
        <Popconfirm
src/views/wls/productsales/modules/picking-bags.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { MesWmProductSalesApi } from '#/api/mes/wm/productsales';
const props = defineProps<{ bags: MesWmProductSalesApi.PickingBag[]; modelValue: number[]; disabled?: boolean }>();
const emit = defineEmits<{ 'update:modelValue': [ids: number[]] }>();
const search = ref(''), desired = ref<number>(), limit = ref(50), selectionError = ref('');
const eligible = computed(() => props.bags.filter(b => b.selectable));
const filtered = computed(() => props.bags.filter(b => !search.value || b.code.includes(search.value.trim()) || b.businessCode?.includes(search.value.trim())));
watch(search, () => { limit.value = 50; });
watch(() => props.modelValue, ids => { desired.value = ids.length || undefined; });
function choose(bag: MesWmProductSalesApi.PickingBag, checked: boolean) {
  selectionError.value = '';
  emit('update:modelValue', checked ? [...props.modelValue, bag.id] : props.modelValue.filter(id => id !== bag.id));
}
function chooseQuantity() {
  const count = Number(desired.value);
  if (!Number.isInteger(count) || count < 1 || count > eligible.value.length) {
    selectionError.value = `请输入 1 è‡³ ${eligible.value.length} çš„æ•´æ•°è¢‹æ•°`; return;
  }
  selectionError.value = '';
  emit('update:modelValue', eligible.value.slice(0, count).map(b => b.id));
}
</script>
<template>
  <fieldset class="pick-bags" :disabled="disabled">
    <legend>选择追溯袋码</legend>
    <p class="pick-summary" role="status">可选 {{eligible.length}} è¢‹ Â· æœ¬æ¬¡å·²é€‰ <strong>{{modelValue.length}}</strong> è¢‹</p>
    <div class="pick-controls">
      <label>本次拣货数量<input v-model="desired" type="number" min="1" :max="eligible.length" step="1" placeholder="输入袋数" /></label>
      <button type="button" :disabled="!eligible.length" @click="chooseQuantity">按数量选择袋码</button>
      <button type="button" :disabled="!modelValue.length" @click="emit('update:modelValue', [])">清空选择</button>
    </div>
    <p class="pick-help">按数量选择将替换当前选择,按袋码顺序选取可选袋。请核对实物,或逐袋勾选。</p>
    <p v-if="selectionError" class="pick-error" role="alert">{{selectionError}}</p>
    <label class="pick-search">查找袋码<input v-model="search" type="search" placeholder="输入业务码或追溯码" /></label>
    <div class="pick-list">
      <label v-for="bag in filtered.slice(0, limit)" :key="bag.id" class="pick-option" :class="{ 'pick-unavailable': !bag.selectable, 'pick-selected': modelValue.includes(bag.id) }">
        <input type="checkbox" :checked="modelValue.includes(bag.id)" :disabled="!bag.selectable" :aria-label="'选择袋码 ' + bag.code" @change="choose(bag, ($event.target as HTMLInputElement).checked)" />
        <span><strong>{{bag.code}}</strong><small v-if="bag.businessCode">业务码 {{bag.businessCode}}</small><small v-if="bag.unavailableReason">{{bag.unavailableReason}}</small></span>
      </label>
      <p v-if="!filtered.length" class="pick-help">没有匹配的袋码</p>
    </div>
    <button v-if="filtered.length > limit" type="button" @click="limit += 50">显示更多袋码({{limit}} / {{filtered.length}})</button>
    <details v-if="modelValue.length"><summary>核对全部已选袋码({{modelValue.length}} è¢‹ï¼‰</summary><p v-for="bag in bags.filter(b => modelValue.includes(b.id))" :key="bag.id" class="pick-code">{{bag.code}}<small v-if="bag.businessCode"> Â· {{bag.businessCode}}</small></p></details>
  </fieldset>
</template>
<style scoped>
.pick-bags{border:0;padding:0;margin:16px 0;min-width:0;color:inherit}.pick-bags legend{font-weight:600}.pick-summary{margin:8px 0}.pick-controls{display:flex;align-items:flex-end;gap:8px;flex-wrap:wrap}.pick-controls label{flex:1;min-width:130px}.pick-bags label{font-weight:500}.pick-bags input[type=number],.pick-bags input[type=search]{display:block;width:100%;min-width:0;min-height:44px;border:1px solid #64748b;border-radius:6px;background:var(--background, white);color:inherit;padding:8px 10px;font:inherit}.pick-bags button{min-height:44px;padding:8px 12px;border:1px solid #64748b;border-radius:6px;color:#0369a1;background:var(--background, white);cursor:pointer}.pick-bags button:disabled{opacity:.5;cursor:default}.pick-help{font-size:14px;color:#475569;line-height:1.5;margin:10px 0}.pick-search{display:block;margin:12px 0}.pick-list{max-height:360px;overflow:auto;border-top:1px solid #e2e8f0}.pick-option{display:flex;align-items:center;gap:12px;min-height:64px;padding:10px 4px;border-bottom:1px solid #e2e8f0;cursor:pointer}.pick-option input{width:20px;height:20px;flex-shrink:0;accent-color:#0369a1}.pick-option span{min-width:0;overflow-wrap:anywhere}.pick-option strong{display:block;font-weight:500}.pick-option small{display:block;color:#475569;font-size:14px}.pick-selected{background:#f0f9ff;color:#0f172a}.pick-unavailable{cursor:default;background:#f8fafc;color:#475569}.pick-error{color:#b91c1c}.pick-code{overflow-wrap:anywhere;font-size:14px;padding:6px 0}.pick-bags summary{cursor:pointer;min-height:44px;padding:10px 0;color:#0369a1}.pick-bags :focus-visible{outline:2px solid #0369a1;outline-offset:2px}
</style>