From fb4db4ae9b6a7f113ba049f6789e6916c0dcc276 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期一, 12 十月 2026 09:15:08 +0800
Subject: [PATCH] feat(mes): 新增业务码支持并优化追溯查询功能

---
 src/views/mobile/stock.vue                                     |   59 +
 src/mobile/routes.ts                                           |   17 
 src/views/mes/pro/batch/index.vue                              |   70 +
 src/views/wls/productsales/modules/detail-list.vue             |    1 
 src/views/wls/productsales/modules/form.vue                    |   62 +
 src/views/mes/pro/components/business-qr.vue                   |   19 
 src/views/mobile/home.vue                                      |   48 +
 pnpm-lock.yaml                                                 |   83 +-
 src/api/mes/pro/pallet/index.ts                                |   18 
 src/views/mobile/mobile.css                                    |   36 +
 src/api/request.ts                                             |    2 
 index.html                                                     |    3 
 src/views/mes/pro/pallet/index.vue                             |   86 +-
 public/startup-check.js                                        |   51 +
 src/api/mes/wm/productsales/index.ts                           |   19 
 src/router/index.ts                                            |    3 
 src/views/mes/pro/pallet/data.ts                               |    5 
 src/views/mes/pro/bagCode/index.vue                            |  123 +-
 src/views/mobile/layout.vue                                    |   38 +
 src/views/mobile/inbound.vue                                   |  119 +++
 src/views/mobile/records.vue                                   |   22 
 src/packages/stores/src/modules/tabbar.ts                      |    2 
 src/components/table-action/table-action.vue                   |    6 
 src/views/_core/authentication/login.vue                       |   44 +
 src/api/mes/pro/batch/index.ts                                 |   17 
 src/store/auth.ts                                              |   17 
 src/views/mes/pro/batch/data.ts                                |   34 
 src/mobile/icon.vue                                            |   16 
 src/mobile/scroll.ts                                           |    5 
 src/mobile/access.ts                                           |   33 +
 src/views/wls/productsales/modules/picking-bags.vue            |   50 +
 src/router/guard.ts                                            |   33 
 src/views/mobile/mine.vue                                      |   16 
 src/views/mobile/picker.vue                                    |   72 ++
 src/views/mobile/quality-records.vue                           |   40 +
 src/api/mes/pro/scanEvent/index.ts                             |    3 
 src/mobile/api.ts                                              |   40 +
 src/api/mes/pro/bagCode/index.ts                               |   15 
 src/views/mes/pro/components/qr-trace-modal.vue                |   11 
 src/api/core/auth.ts                                           |    3 
 public/browser-check.html                                      |   47 +
 src/mobile/terminal.ts                                         |   29 
 src/views/mobile/quality.vue                                   |   95 ++
 src/api/mes/qc/seedQuality/index.ts                            |   13 
 src/views/mes/pro/batch/modules/form.vue                       |   19 
 src/main.ts                                                    |   12 
 src/views/mobile/scanner.vue                                   |   59 +
 package.json                                                   |    3 
 src/api/mes/wm/productsales/line/index.ts                      |    2 
 src/packages/effects/common-ui/src/ui/authentication/login.vue |    7 
 src/layouts/auth.vue                                           |   17 
 src/views/mobile/query.vue                                     |  121 +++
 src/router/routes/core.ts                                      |    2 
 src/views/mes/pro/bagCode/data.ts                              |   12 
 src/views/mobile/sales.vue                                     |  141 ++++
 src/views/wls/productsales/data.ts                             |    3 
 56 files changed, 1,702 insertions(+), 221 deletions(-)

diff --git a/index.html b/index.html
index f50b9c8..1729ed9 100644
--- a/index.html
+++ b/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>
diff --git a/package.json b/package.json
index 24978f2..a4ad6ff 100644
--- a/package.json
+++ b/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:",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 29cb323..cf1f71e 100644
--- a/pnpm-lock.yaml
+++ b/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:
diff --git a/public/browser-check.html b/public/browser-check.html
new file mode 100644
index 0000000..1d42559
--- /dev/null
+++ b/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>
diff --git a/public/startup-check.js b/public/startup-check.js
new file mode 100644
index 0000000..95f4f1f
--- /dev/null
+++ b/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);
+}());
diff --git a/src/api/core/auth.ts b/src/api/core/auth.ts
index cec93f5..f0389d8 100644
--- a/src/api/core/auth.ts
+++ b/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 } },
   );
 }
 
diff --git a/src/api/mes/pro/bagCode/index.ts b/src/api/mes/pro/bagCode/index.ts
index 493647c..57f2d3b 100644
--- a/src/api/mes/pro/bagCode/index.ts
+++ b/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 });
+}
diff --git a/src/api/mes/pro/batch/index.ts b/src/api/mes/pro/batch/index.ts
index 0f9aae7..d681c62 100644
--- a/src/api/mes/pro/batch/index.ts
+++ b/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; // 鎵规鍙凤紙琛ュ綍姝e紡淇℃伅鍚庤嚜鍔ㄧ敓鎴愶級
     tempCode?: string; // 涓存椂鎵规鐮侊紙棰勭敓鎴愪簩缁寸爜闃舵浣跨敤锛屾壂鐮佽拷婧吋瀹癸級
     lineId?: number; // 浜х嚎缂栧彿锛堥〉闈㈠睍绀轰负銆屽姞宸ュ崟浣嶃�嶏級
@@ -39,7 +43,10 @@
 
   /** 鐢熶骇鎵规鎵爜杩芥函 */
   export interface BatchTrace {
+    inboundBagQuantity?: number;
+    pendingBagQuantity?: number;
     id?: number; // 鐢熶骇鎵规缂栧彿
+    businessCode?: string; // 鍐呴儴鍑哄叆搴撲笟鍔$爜
     code?: string; // 鎵规鍙凤紙琛ュ綍鍚庣殑姝e紡鎵规鍙凤級
     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 } });
+}
diff --git a/src/api/mes/pro/pallet/index.ts b/src/api/mes/pro/pallet/index.ts
index 8fca583..60324b9 100644
--- a/src/api/mes/pro/pallet/index.ts
+++ b/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; // 鎵樼洏鐮侊紙缁戝畾鎵规鍚庣殑姝e紡鎵樼洏鐮侊級
     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 } });
+}
diff --git a/src/api/mes/pro/scanEvent/index.ts b/src/api/mes/pro/scanEvent/index.ts
index e19bb1f..4be513d 100644
--- a/src/api/mes/pro/scanEvent/index.ts
+++ b/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;
diff --git a/src/api/mes/qc/seedQuality/index.ts b/src/api/mes/qc/seedQuality/index.ts
index 4979bed..c32f0e1 100644
--- a/src/api/mes/qc/seedQuality/index.ts
+++ b/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);
+}
diff --git a/src/api/mes/wm/productsales/index.ts b/src/api/mes/wm/productsales/index.ts
index ab2c08e..a5296ef 100644
--- a/src/api/mes/wm/productsales/index.ts
+++ b/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 } });
+}
+
 /** 鎵ц鎷h揣 */
 export function stockProductSales(id: number) {
   return requestClient.put(`/mes/wm/product-sales/stock?id=${id}`);
diff --git a/src/api/mes/wm/productsales/line/index.ts b/src/api/mes/wm/productsales/line/index.ts
index 1f8e46a..cb69cae 100644
--- a/src/api/mes/wm/productsales/line/index.ts
+++ b/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 });
diff --git a/src/api/request.ts b/src/api/request.ts
index 7389c31..1687786 100644
--- a/src/api/request.ts
+++ b/src/api/request.ts
@@ -44,7 +44,7 @@
     ) {
       accessStore.setLoginExpired(true);
     } else {
-      await authStore.logout();
+      await authStore.logout(true, true);
     }
   }
 
diff --git a/src/components/table-action/table-action.vue b/src/components/table-action/table-action.vue
index 55e2183..724e62c 100644
--- a/src/components/table-action/table-action.vue
+++ b/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>
diff --git a/src/layouts/auth.vue b/src/layouts/auth.vue
index e8bdb59..cb63894 100644
--- a/src/layouts/auth.vue
+++ b/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>
diff --git a/src/main.ts b/src/main.ts
index 6a42557..1239af9 100644
--- a/src/main.ts
+++ b/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 @@
 
   // 绉婚櫎骞堕攢姣乴oading
   unmountGlobalLoading();
+  window.__MOM_STARTUP__?.finish();
 }
 
-initApplication();
+initApplication().catch((error: unknown) => {
+  window.__MOM_STARTUP__?.fail(error);
+  console.error('Application startup failed', error);
+});
diff --git a/src/mobile/access.ts b/src/mobile/access.ts
new file mode 100644
index 0000000..c5868a5
--- /dev/null
+++ b/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: '鎷h揣銆佽繍鍗曚笌鍑哄簱', 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('鐮佸�兼牸寮忎笉姝g‘');
+  return value;
+}
diff --git a/src/mobile/api.ts b/src/mobile/api.ts
new file mode 100644
index 0000000..20422b7
--- /dev/null
+++ b/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 || '璇锋眰澶辫触锛岃绋嶅悗閲嶈瘯';
+}
diff --git a/src/mobile/icon.vue b/src/mobile/icon.vue
new file mode 100644
index 0000000..eed35fa
--- /dev/null
+++ b/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>
diff --git a/src/mobile/routes.ts b/src/mobile/routes.ts
new file mode 100644
index 0000000..e533b17
--- /dev/null
+++ b/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: '閿�鍞嚭搴�' } },
+  ],
+}];
diff --git a/src/mobile/scroll.ts b/src/mobile/scroll.ts
new file mode 100644
index 0000000..2372f61
--- /dev/null
+++ b/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(); }
diff --git a/src/mobile/terminal.ts b/src/mobile/terminal.ts
new file mode 100644
index 0000000..b1cc62c
--- /dev/null
+++ b/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;
+}
diff --git a/src/packages/effects/common-ui/src/ui/authentication/login.vue b/src/packages/effects/common-ui/src/ui/authentication/login.vue
index 0f414df..cd38900 100644
--- a/src/packages/effects/common-ui/src/ui/authentication/login.vue
+++ b/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"
     >
diff --git a/src/packages/stores/src/modules/tabbar.ts b/src/packages/stores/src/modules/tabbar.ts
index 99685fb..bcf95a3 100644
--- a/src/packages/stores/src/modules/tabbar.ts
+++ b/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;
diff --git a/src/router/guard.ts b/src/router/guard.ts
index 8b88c83..3e27562 100644
--- a/src/router/guard.ts
+++ b/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;
diff --git a/src/router/index.ts b/src/router/index.ts
index 5acec55..efc3f45 100644
--- a/src/router/index.ts
+++ b/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 };
   },
   // 鏄惁搴旇绂佹灏鹃儴鏂滄潬銆�
diff --git a/src/router/routes/core.ts b/src/router/routes/core.ts
index 7e14b88..3b3eb39 100644
--- a/src/router/routes/core.ts
+++ b/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銆�
diff --git a/src/store/auth.ts b/src/store/auth.ts
index 7d09f0e..a4776ec 100644
--- a/src/store/auth.ts
+++ b/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,
   };
 });
diff --git a/src/views/_core/authentication/login.vue b/src/views/_core/authentication/login.vue
index 883e6ea..92c9711 100644
--- a/src/views/_core/authentication/login.vue
+++ b/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>
diff --git a/src/views/mes/pro/bagCode/data.ts b/src/views/mes/pro/bagCode/data.ts
index 8af998d..2f23149 100644
--- a/src/views/mes/pro/bagCode/data.ts
+++ b/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: '琚嬬爜鐘舵��',
diff --git a/src/views/mes/pro/bagCode/index.vue b/src/views/mes/pro/bagCode/index.vue
index 65ae024..cf22f99 100644
--- a/src/views/mes/pro/bagCode/index.vue
+++ b/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: '瑙g粦鎵樼洏',
-              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: '瑙g粦鎵樼洏', 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>
diff --git a/src/views/mes/pro/batch/data.ts b/src/views/mes/pro/batch/data.ts
index 9e0803c..408d018 100644
--- a/src/views/mes/pro/batch/data.ts
+++ b/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: '缁戝畾鍚庢壒娆$洿鎺ヨ繘鍏ョ敓浜т腑锛氭墭鐩樼敓鎴愭寮忔墭鐩樼爜锛屾墭鐩樹笅鏈綍鎵规鐨勮鐮佽嚜鍔ㄧ骇鑱斿綍鍏ユ湰鎵规锛堣鐮佸搧绉嶉』涓�鑷达級锛涚紪杈戞椂浠呴渶鍕鹃�夋柊澧炴墭鐩橈紝宸茬粦瀹氱殑涓嶅彲瑙g粦',
+      help: '鍙兘缁戝畾鐩稿悓鍝佺鐨勬墭鐩橈紝鍝佺淇℃伅鑷姩甯﹀叆锛涚己灏戝搧绉嶇殑鎵樼洏璇峰厛瑁呰銆傜粦瀹氬悗鎵规鐩存帴杩涘叆鐢熶骇涓細鎵樼洏鐢熸垚姝e紡鎵樼洏鐮侊紝鎵樼洏涓嬫湭褰曟壒娆$殑琚嬬爜鑷姩绾ц仈褰曞叆鏈壒娆★紙琚嬬爜鍝佺椤讳竴鑷达級锛涚紪杈戞椂浠呴渶鍕鹃�夋柊澧炴墭鐩橈紝宸茬粦瀹氱殑涓嶅彲瑙g粦',
     },
     {
       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: '鍔犲伐鍗曚綅',
diff --git a/src/views/mes/pro/batch/index.vue b/src/views/mes/pro/batch/index.vue
index 3f15dda..49e137d 100644
--- a/src/views/mes/pro/batch/index.vue
+++ b/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>
diff --git a/src/views/mes/pro/batch/modules/form.vue b/src/views/mes/pro/batch/modules/form.vue
index f08e03d..b909f02 100644
--- a/src/views/mes/pro/batch/modules/form.vue
+++ b/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);
diff --git a/src/views/mes/pro/components/business-qr.vue b/src/views/mes/pro/components/business-qr.vue
new file mode 100644
index 0000000..249354f
--- /dev/null
+++ b/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>
diff --git a/src/views/mes/pro/components/qr-trace-modal.vue b/src/views/mes/pro/components/qr-trace-modal.vue
index 24f892e..f701da6 100644
--- a/src/views/mes/pro/components/qr-trace-modal.vue
+++ b/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: "琚嬪簭鍙�",
diff --git a/src/views/mes/pro/pallet/data.ts b/src/views/mes/pro/pallet/data.ts
index 73f0d69..6f8fbac 100644
--- a/src/views/mes/pro/pallet/data.ts
+++ b/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: '鎵樼洏鐘舵��',
diff --git a/src/views/mes/pro/pallet/index.vue b/src/views/mes/pro/pallet/index.vue
index 0d2e0f5..82adce6 100644
--- a/src/views/mes/pro/pallet/index.vue
+++ b/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>
diff --git a/src/views/mobile/home.vue b/src/views/mobile/home.vue
new file mode 100644
index 0000000..5a885f4
--- /dev/null
+++ b/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">姝e湪鑾峰彇鏈�杩戜綔涓氣��</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>
diff --git a/src/views/mobile/inbound.vue b/src/views/mobile/inbound.vue
new file mode 100644
index 0000000..32954a6
--- /dev/null
+++ b/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">姝e湪鑾峰彇浣滀笟浣嶇疆</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?'姝e湪鍏ュ簱鈥�':'纭鍏ュ簱'}}</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>
diff --git a/src/views/mobile/layout.vue b/src/views/mobile/layout.vue
new file mode 100644
index 0000000..43b0af5
--- /dev/null
+++ b/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>
diff --git a/src/views/mobile/mine.vue b/src/views/mobile/mine.vue
new file mode 100644
index 0000000..5c3760a
--- /dev/null
+++ b/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>
diff --git a/src/views/mobile/mobile.css b/src/views/mobile/mobile.css
new file mode 100644
index 0000000..92f383b
--- /dev/null
+++ b/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}
diff --git a/src/views/mobile/picker.vue b/src/views/mobile/picker.vue
new file mode 100644
index 0000000..0778f6e
--- /dev/null
+++ b/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?'姝e湪鍔犺浇鈥�':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>
diff --git a/src/views/mobile/quality-records.vue b/src/views/mobile/quality-records.vue
new file mode 100644
index 0000000..a25cae6
--- /dev/null
+++ b/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 ? '姝e湪鏌ヨ鈥�' : '鏌ヨ璁板綍'}}</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 ? '姝e湪鍔犺浇鈥�' : '鍔犺浇鏇村'}}</button>
+</template>
diff --git a/src/views/mobile/quality.vue b/src/views/mobile/quality.vue
new file mode 100644
index 0000000..4e870f2
--- /dev/null
+++ b/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 = '鏈娓呭崟浠呴儴鍒嗚鐮佸凡鏈夎川妫�璁板綍锛岃鏍稿璐ㄦ璁板綍鍚庤仈绯昏礋璐d汉澶勭悊锛屽綋鍓嶄繚鐣欏緟纭鎿嶄綔銆�';
+    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 ? '姝e湪鎻愪氦鈥�' : '纭鎻愪氦璐ㄦ'}}</button></div>
+    </form>
+  </template>
+  <RouterLink v-if="allowedMobilePath('/mobile/quality-records')" to="/mobile/quality-records" class="m-link">鏌ョ湅璐ㄦ璁板綍</RouterLink>
+</template>
diff --git a/src/views/mobile/query.vue b/src/views/mobile/query.vue
new file mode 100644
index 0000000..207fee3
--- /dev/null
+++ b/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='瀵硅薄缁戝畾缁撴灉涓嶄竴鑷达紝璇疯仈绯昏礋璐d汉鏍稿銆傚綋鍓嶄繚鐣欏緟纭鎿嶄綔銆�';
+  }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?'姝e湪缁戝畾鈥�':'纭缁戝畾'}}</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?'姝e湪鍔犺浇鈥�':'鍔犺浇鏇村'}}</button>
+    </template>
+  </template>
+</template>
diff --git a/src/views/mobile/records.vue b/src/views/mobile/records.vue
new file mode 100644
index 0000000..6f90209
--- /dev/null
+++ b/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?'姝e湪鏌ヨ鈥�':'鏌ヨ鏈汉璁板綍'}}</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>
diff --git a/src/views/mobile/sales.vue b/src/views/mobile/sales.vue
new file mode 100644
index 0000000..a806d2d
--- /dev/null
+++ b/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 = '璇烽�夋嫨鏈鎷h揣瀵瑰簲鐨勮鐮�'; 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' ? '纭瀹屾垚鎷h揣锛熺郴缁熷皢鏍稿鍚勫搧绉嶆暟閲忋��' : action === 'shipping' ? '纭淇濆瓨杩愬崟骞惰繘鍏ュ緟鎵ц鍑哄簱锛�' : '纭鎵ц鍑哄簱锛熸湰鍗曞皢鎵e噺搴撳瓨骞舵爣璁板凡瀹屾垚銆�';
+  if (!window.confirm(message)) return;
+  busy.value = true; error.value = ''; notice.value = '';
+  try {
+    if (action === 'stock' && !await checkProductSalesQuantity(order.id)) throw new Error('鎷h揣鏁伴噺涓庡嚭搴撴暟閲忎笉涓�鑷达紝璇锋牳瀵规瘡涓搧绉�');
+    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 ? '璇ョ爜宸插湪鎷h揣娓呭崟涓紝鏈鏈噸澶嶆坊鍔犮��' : `宸插姞鍏ユ嫞璐э細${result.outboundQuantity || operation.preview!.scanQuantity} 琚嬨�俙;
+    } else if (action === 'stock') { await stockProductSales(order.id); notice.value = '鎷h揣瀹屾垚锛岃濉啓杩愬崟銆�'; }
+    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 = '鎵�閫夎鐮佷粎閮ㄥ垎瀛樺湪浜庢嫞璐ф槑缁嗭紝璇疯仈绯昏礋璐d汉鏍稿锛屼笉鑳借嚜鍔ㄩ噸璇曘��'; return; }
+    if (found) {
+      notice.value = operation.action === 'scan' ? '宸叉牳瀹烇細璇ョ爜宸插湪鎷h揣娓呭崟涓��' : '褰撳墠鍗曟嵁鐘舵�侊細' + statusNames[sales.value!.status!] + '銆傝鏍稿鏄庣粏鍜岃繍鍗曞悗缁х画銆�';
+      clearPending(); preview.value = undefined;
+    } else if (window.confirm('褰撳墠灏氭湭鍙戠幇璇ユ搷浣滅粨鏋溿�傜‘璁ゅ師璇锋眰宸茬粨鏉熷悗锛屽彲閲嶆柊鏍稿鍐嶆搷浣溿�傜户缁紵')) { clearPending(); preview.value = undefined; notice.value = '璇锋牳瀵瑰綋鍓嶅崟鎹笌鎷h揣娓呭崟鍚庨噸鏂版搷浣溿��'; }
+  } 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>鏌ョ湅鎷h揣鏄庣粏</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">鎵爜鎷h揣</h2><label class="m-field"><span>鎷h揣鎵爜绫诲瀷</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">鏀寔涓氬姟鐮佷笌杩芥函鐮併�傛墭鐩樸�佹壒娆¤瘑鍒悗閫夋嫨瀹為檯鎷h揣琚嬬爜锛屾暟閲忔寜鎵�閫夎鏁拌鍏ャ��</p><Scanner :disabled="busy" @code="identify" /><section v-if="preview" class="m-panel"><h3 class="m-title">鏍稿鎷h揣瀵硅薄</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">鏈鎷h揣 {{selectedBagIds.length}} 琚�</p><button class="m-primary" :disabled="busy || !selectedBagIds.length" @click="execute('scan')">纭鍔犲叆鎷h揣</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')">瀹屾垚鎷h揣骞舵牳瀵规暟閲�</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 绔淮鎶ゅ搧绉嶅苟鎻愪氦鍚庤繘琛岀Щ鍔ㄦ嫞璐с��' : '璇ュ崟寰呮娴嬶紝妫�娴嬪畬鎴愬悗鎵嶈兘鎷h揣銆�'}}</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 ? '姝e湪鏌ヨ鈥�' : '鏌ヨ鍑哄簱鍗�'}}</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 ? '姝e湪鍔犺浇鈥�' : '鍔犺浇鏇村'}}</button>
+  </template>
+</template>
diff --git a/src/views/mobile/scanner.vue b/src/views/mobile/scanner.vue
new file mode 100644
index 0000000..e01c066
--- /dev/null
+++ b/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?'姝e湪璇锋眰鐩告満鏉冮檺鈥�':'鎵撳紑鎽勫儚澶存壂鐮�'}}</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?'姝e湪澶勭悊鈥�':'璇嗗埆鐮佸��'}}</button>
+  </form>
+</div>
+</template>
diff --git a/src/views/mobile/stock.vue b/src/views/mobile/stock.vue
new file mode 100644
index 0000000..56d6eb8
--- /dev/null
+++ b/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?'姝e湪鏌ヨ鈥�':'鏌ヨ搴撳瓨'}}</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>
diff --git a/src/views/wls/productsales/data.ts b/src/views/wls/productsales/data.ts
index 9a4ac8b..192d780 100644
--- a/src/views/wls/productsales/data.ts
+++ b/src/views/wls/productsales/data.ts
@@ -534,7 +534,7 @@
       title: '鎵爜鏉ユ簮',
       minWidth: 110,
       formatter: ({ row }) =>
-        row.palletCodeId ? `鎵樼洏鐮� #${row.palletCodeId}` : row.bagCodeId ? `琚嬬爜 #${row.bagCodeId}` : '鎵嬪伐鎷h揣',
+         row.bagCodeId ? `琚嬬爜 #${row.bagCodeId}${row.palletCodeId ? ` / 鎵樼洏 #${row.palletCodeId}` : ''}` : row.palletCodeId ? `鎵樼洏鐮� #${row.palletCodeId}` : '鎵嬪伐鎷h揣',
     },
     {
       field: 'warehouseName',
@@ -556,6 +556,7 @@
       title: '鏁伴噺',
       width: 100,
     },
+    { field: 'remark', title: '琚嬬爜涓庢壂鐮佹潵婧�', minWidth: 240 },
     ...(stockable
       ? [
           {
diff --git a/src/views/wls/productsales/modules/detail-list.vue b/src/views/wls/productsales/modules/detail-list.vue
index 8a153bd..1b6b253 100644
--- a/src/views/wls/productsales/modules/detail-list.vue
+++ b/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),
             },
             {
diff --git a/src/views/wls/productsales/modules/form.vue b/src/views/wls/productsales/modules/form.vue
index dcb1abc..27ee471 100644
--- a/src/views/wls/productsales/modules/form.vue
+++ b/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 @@
   }
 }
 
-/** 鎵爜鎷h揣锛氭壂鐮佹灙閫氬父浠ュ洖杞︾粨鏉熻緭鍏� */
+/** 鍏堣瘑鍒璞″苟閫夋嫨琚嬬爜锛屽洖杞︿笉浼氱洿鎺ュ啓鍏ユ嫞璐с�� */
 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">鎷h揣鎵爜绫诲瀷</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">鏍稿鎷h揣瀵硅薄</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">纭鍔犲叆鎷h揣</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">
           鎵ц鎷h揣
         </Button>
         <Popconfirm
diff --git a/src/views/wls/productsales/modules/picking-bags.vue b/src/views/wls/productsales/modules/picking-bags.vue
new file mode 100644
index 0000000..3bc2de1
--- /dev/null
+++ b/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>鏈鎷h揣鏁伴噺<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>

--
Gitblit v1.9.3