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