feat(mes): 新增业务码支持并优化追溯查询功能
- 在批次、托盘、袋码表中新增 business_code 字段及唯一约束
- 实现内部扫码兼容业务码与历史追溯码的查询机制
- 添加补生成已有码业务码的功能接口
- 优化托盘绑定时品种信息的自动解析和合并逻辑
- 完善批次入库时有效袋码的进度计算机制
- 更新追溯响应VO增加入库时间等关键字段
- 重构批次预生成逻辑使用统一编码生成器分配码值
- 优化页面列表查询支持业务码模糊匹配搜索
| | |
| | | <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> |
| | |
| | | </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> |
| | |
| | | "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:", |
| | |
| | | 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) |
| | |
| | | '@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==} |
| | |
| | | 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 |
| | |
| | | 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==} |
| | |
| | | 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: |
| | |
| | | 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==} |
| | |
| | | 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: |
| | |
| | | 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==} |
| | |
| | | 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)': |
| | |
| | | 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: |
| | |
| | | '@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 |
| | | |
| | |
| | | |
| | | '@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 |
| | | |
| | |
| | | '@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: |
| | |
| | | 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)': |
| | |
| | | |
| | | '@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': |
| | |
| | | '@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': {} |
| | | |
| | |
| | | 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 |
| | |
| | | '@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 |
| | |
| | | core-js: 3.49.0 |
| | | dompurify: 3.4.11 |
| | | html2canvas: 1.4.1 |
| | | |
| | | jsqr@1.4.0: {} |
| | | |
| | | katex@0.16.47: |
| | | dependencies: |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | |
| | | strnum@1.0.5: {} |
| | | |
| | | style-mod@4.1.3: {} |
| | | style-mod@4.1.4: {} |
| | | |
| | | style-search@0.1.0: {} |
| | | |
| | |
| | | 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' |
| | | |
| | |
| | | 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 |
| | | |
| | |
| | | |
| | | zeebe-bpmn-moddle@1.15.0: {} |
| | | |
| | | zimmerframe@1.1.4: {} |
| | | zimmerframe@1.1.5: {} |
| | | |
| | | zip-stream@6.0.1: |
| | | dependencies: |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | /* 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); |
| | | }()); |
| | |
| | | } |
| | | |
| | | /** è·åæéä¿¡æ¯ */ |
| | | export async function getAuthPermissionInfoApi() { |
| | | export async function getAuthPermissionInfoApi(terminalType: 'pc' | 'mobile' = 'pc') { |
| | | return requestClient.get<AuthPermissionInfo>( |
| | | '/system/auth/get-permission-info', |
| | | { params: { terminalType } }, |
| | | ); |
| | | } |
| | | |
| | |
| | | export namespace MesProBagCodeApi { |
| | | /** è¢ç ï¼ä¸è¢ä¸ç ï¼ */ |
| | | export interface BagCode { |
| | | inboundTime?: string; |
| | | id?: number; // ç¼å· |
| | | uuid?: string; // çæç» UUID |
| | | code?: string; // è¢ç ï¼äºç»´ç å
å®¹ï¼ |
| | | businessCode?: string; // å
é¨åºå
¥åºä¸å¡ç ï¼çº¯ç å¼ï¼ |
| | | bagNo?: number; // æ¹æ¬¡å
è¢åºå· |
| | | batchId?: number; // çäº§æ¹æ¬¡ç¼å· |
| | | batchCode?: string; // æ¹æ¬¡å· |
| | |
| | | /** è¢ç å页æ¥è¯¢åæ° */ |
| | | export interface PageParams extends PageParam { |
| | | code?: string; |
| | | businessCode?: string; |
| | | batchId?: number; |
| | | batchCode?: string; |
| | | uuid?: string; |
| | |
| | | |
| | | /** æ«ç è¿½æº¯ä¿¡æ¯ */ |
| | | export interface BagCodeTrace { |
| | | inboundTime?: string; |
| | | id?: number; // è¢ç ç¼å· |
| | | code?: string; // è¢ç ï¼äºç»´ç å
å®¹ï¼ |
| | | businessCode?: string; // å
é¨åºå
¥åºä¸å¡ç |
| | | bagNo?: number; // è¢åºå· |
| | | status?: number; // è¢ç ç¶æ |
| | | printTime?: string; // 导åºå°å·æ¶é´ |
| | |
| | | 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 }); |
| | | } |
| | |
| | | /** çäº§æ¹æ¬¡ */ |
| | | export interface Batch { |
| | | id?: number; // ç¼å· |
| | | itemId?: number; // æç带å
¥çåç§ç¼å· |
| | | itemCode?: string; |
| | | itemName?: string; |
| | | businessCode?: string; // å
é¨åºå
¥åºä¸å¡ç |
| | | code?: string; // æ¹æ¬¡å·ï¼è¡¥å½æ£å¼ä¿¡æ¯åèªå¨çæï¼ |
| | | tempCode?: string; // ä¸´æ¶æ¹æ¬¡ç ï¼é¢çæäºç»´ç é¶æ®µä½¿ç¨ï¼æ«ç 追溯å
¼å®¹ï¼ |
| | | lineId?: number; // 产线ç¼å·ï¼é¡µé¢å±ç¤ºä¸ºãå å·¥åä½ãï¼ |
| | |
| | | |
| | | /** çäº§æ¹æ¬¡æ«ç 追溯 */ |
| | | export interface BatchTrace { |
| | | inboundBagQuantity?: number; |
| | | pendingBagQuantity?: number; |
| | | id?: number; // çäº§æ¹æ¬¡ç¼å· |
| | | businessCode?: string; // å
é¨åºå
¥åºä¸å¡ç |
| | | code?: string; // æ¹æ¬¡å·ï¼è¡¥å½åçæ£å¼æ¹æ¬¡å·ï¼ |
| | | tempCode?: string; // ä¸´æ¶æ¹æ¬¡ç ï¼äºç»´ç å
å®¹ï¼æªè¡¥å½ä¿¡æ¯æ¶å±ç¤ºï¼ |
| | | status?: number; // æ¹æ¬¡ç¶æï¼åå
¸ mes_pro_batch_statusï¼ |
| | |
| | | |
| | | /** çäº§æ¹æ¬¡å页æ¥è¯¢åæ° */ |
| | | export interface PageParams extends PageParam { |
| | | businessCode?: string; // å
é¨åºå
¥åºä¸å¡ç |
| | | code?: string; |
| | | tempCode?: string; |
| | | lineId?: number; |
| | |
| | | |
| | | /** é¢çææ¹æ¬¡äºç»´ç è¿å项 */ |
| | | export interface PreGenerateResult { |
| | | businessCode?: string; |
| | | id: number; // çäº§æ¹æ¬¡ç¼å· |
| | | tempCode: string; // ä¸´æ¶æ¹æ¬¡ç ï¼äºç»´ç å
å®¹ï¼ |
| | | } |
| | |
| | | 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 } }); |
| | | } |
| | |
| | | export namespace MesProPalletApi { |
| | | /** æçç */ |
| | | export interface Pallet { |
| | | inboundTime?: string; |
| | | id?: number; // ç¼å· |
| | | itemId?: number; // æç带å
¥çåç§ç¼å· |
| | | itemCode?: string; |
| | | itemName?: string; |
| | | businessCode?: string; // å
é¨åºå
¥åºä¸å¡ç |
| | | code?: string; // æçç ï¼ç»å®æ¹æ¬¡åçæï¼ |
| | | tempCode?: string; // ä¸´æ¶æçç ï¼é¢çæäºç»´ç é¶æ®µä½¿ç¨ï¼æ«ç 追溯å
¼å®¹ï¼ |
| | | batchId?: number; // çäº§æ¹æ¬¡ç¼å· |
| | |
| | | |
| | | /** æçç å页æ¥è¯¢åæ° */ |
| | | export interface PageParams extends PageParam { |
| | | businessCode?: string; // å
é¨åºå
¥åºä¸å¡ç |
| | | code?: string; |
| | | tempCode?: string; |
| | | batchId?: number; |
| | |
| | | |
| | | /** æçç æ«ç 追溯 */ |
| | | export interface PalletTrace { |
| | | inboundTime?: string; |
| | | pendingBagQuantity?: number; |
| | | id?: number; // ç¼å· |
| | | businessCode?: string; // å
é¨åºå
¥åºä¸å¡ç |
| | | code?: string; // æçç ï¼ç»å®æ¹æ¬¡åçæ£å¼æçç ï¼ |
| | | tempCode?: string; // ä¸´æ¶æçç ï¼äºç»´ç å
å®¹ï¼æªç»å®æ¹æ¬¡æ¶å±ç¤ºï¼ |
| | | palletNo?: number; // æ¹æ¬¡å
æçåºå· |
| | |
| | | |
| | | /** é¢çææçäºç»´ç è¿å项 */ |
| | | export interface PreGenerateResult { |
| | | businessCode?: string; |
| | | id: number; // æçç ç¼å· |
| | | tempCode: string; // ä¸´æ¶æçç ï¼äºç»´ç å
å®¹ï¼ |
| | | } |
| | |
| | | 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 } }); |
| | | } |
| | |
| | | deviceCode?: string; |
| | | lineCode?: string; |
| | | warehouseId?: number; |
| | | eventTime?: string; |
| | | locationId?: number; |
| | | areaId?: number; |
| | | transactionId?: number; |
| | | status?: 'SUCCESS' | 'FAILED'; |
| | | errorCode?: string; |
| | |
| | | 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); |
| | | } |
| | |
| | | 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 { |
| | |
| | | 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 }, |
| | |
| | | ); |
| | | } |
| | | |
| | | export function previewProductSalesScan(code: string, scanType?: string, salesId?: number) { |
| | | return requestClient.get<MesWmProductSalesApi.ProductSalesScanResponse>('/mes/wm/product-sales/scan-preview', { params: { code, scanType, salesId } }); |
| | | } |
| | | |
| | | /** æ§è¡æ£è´§ */ |
| | | export function stockProductSales(id: number) { |
| | | return requestClient.put(`/mes/wm/product-sales/stock?id=${id}`); |
| | |
| | | } |
| | | |
| | | /** æ¥è¯¢éå®åºåºåè¡å页 */ |
| | | 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 }); |
| | |
| | | ) { |
| | | accessStore.setLoginExpired(true); |
| | | } else { |
| | | await authStore.logout(); |
| | | await authStore.logout(true, true); |
| | | } |
| | | } |
| | | |
| | |
| | | type: Boolean, |
| | | default: true, |
| | | }, |
| | | dropdownTrigger: { |
| | | type: Array as PropType<('click' | 'hover' | 'contextmenu')[]>, |
| | | default: () => ['hover'], |
| | | }, |
| | | }); |
| | | |
| | | const { hasAccessByCodes } = useAccess(); |
| | |
| | | </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> |
| | |
| | | import { preferences } from '@vben/preferences'; |
| | | |
| | | import { $t } from '#/locales'; |
| | | import { terminalType } from '#/mobile/terminal'; |
| | | |
| | | declare const __APP_LOGO__: string; |
| | | |
| | |
| | | </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" |
| | |
| | | <!-- <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> |
| | |
| | | declare const __APP_FAVICON__: string; |
| | | declare const __APP_NAME__: string; |
| | | |
| | | declare global { |
| | | interface Window { |
| | | __MOM_STARTUP__?: { finish: () => void; fail: (error: unknown) => void }; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * åºç¨åå§å宿ä¹ååè¿è¡é¡µé¢å 载渲æ |
| | | */ |
| | |
| | | |
| | | // ç§»é¤å¹¶éæ¯loading |
| | | unmountGlobalLoading(); |
| | | window.__MOM_STARTUP__?.finish(); |
| | | } |
| | | |
| | | initApplication(); |
| | | initApplication().catch((error: unknown) => { |
| | | window.__MOM_STARTUP__?.fail(error); |
| | | console.error('Application startup failed', error); |
| | | }); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { useAccessStore } from '@vben/stores'; |
| | | export const features = [ |
| | | { key: 'inbound', title: 'æ«ç å
¥åº', hint: 'ç产æååº', icon: 'scan', permission: 'mes:pro-scan-in:inbound' }, |
| | | { key: 'bags', title: 'è¢ç æ¥è¯¢', hint: 'æ¥è¯¢ä¸ç»å®æç', icon: 'bag', permission: 'mes:pro-bag-code:query' }, |
| | | { key: 'pallets', title: 'æçæ¥è¯¢', hint: 'æ¥è¯¢ä¸ç»å®æ¹æ¬¡', icon: 'box', permission: 'mes:pro-pallet:query' }, |
| | | { key: 'batches', title: 'æ¹æ¬¡æ¥è¯¢', hint: 'æ¥çç产信æ¯', icon: 'layers', permission: 'mes:pro-batch:query' }, |
| | | { key: 'stock', title: 'åºåæ¥è¯¢', hint: 'åç§ãæ¹æ¬¡ä¸åºä½', icon: 'warehouse', permission: 'mes:wm-material-stock:query' }, |
| | | { key: 'quality', title: 'ç§åè´¨æ£', hint: 'è¢ç ãæç䏿¹æ¬¡', icon: 'scan', permission: 'mes:qc-seed-quality:submit' }, |
| | | { key: 'quality-records', title: 'è´¨æ£è®°å½', hint: 'ç»æä¸ä¸åæ ¼åå ', icon: 'records', permission: 'mes:qc-seed-quality:query' }, |
| | | { key: 'sales', title: 'éå®åºåº', hint: 'æ£è´§ãè¿åä¸åºåº', icon: 'box', permission: 'mes:wm-product-sales:query' }, |
| | | ] as const; |
| | | export function can(permission: string) { return useAccessStore().accessCodes.includes(permission); } |
| | | export function availableFeatures() { |
| | | const store = useAccessStore(); |
| | | return features.filter(f => can(f.permission) && store.accessMenus.some(m => m.path === '/mobile/' + f.key)); |
| | | } |
| | | export function allowedMobilePath(path: string) { |
| | | const key = path.split('/')[2]; |
| | | if (['home', 'work', 'mine'].includes(key || '')) return true; |
| | | if (key === 'records') return can('mes:pro-scan-in:query') && useAccessStore().accessMenus.some(m => m.path === '/mobile/inbound'); |
| | | return availableFeatures().some(f => f.key === key); |
| | | } |
| | | export function extractCode(raw: string) { |
| | | let value = raw.trim(); |
| | | if (!value) throw new Error('è¯·æ«ææè¾å
¥ç å¼'); |
| | | if (/^https?:\/\//i.test(value)) { |
| | | const code = new URL(value).searchParams.get('code'); |
| | | if (!code) throw new Error('è¯¥é¾æ¥ä¸å
å«ä¸å¡ç å¼'); |
| | | value = code.trim(); |
| | | } |
| | | if (!value || value.length > 64 || /\s/.test(value)) throw new Error('ç 弿 ¼å¼ä¸æ£ç¡®'); |
| | | return value; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 || '请æ±å¤±è´¥ï¼è¯·ç¨åéè¯'; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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: 'éå®åºåº' } }, |
| | | ], |
| | | }]; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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(); } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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; |
| | | } |
| | |
| | | |
| | | interface Props extends AuthenticationProps { |
| | | formSchema?: VbenFormSchema[]; |
| | | showFieldLabels?: boolean; |
| | | } |
| | | |
| | | defineOptions({ |
| | |
| | | qrCodeLoginPath: '/auth/qrcode-login', |
| | | registerPath: '/auth/register', |
| | | showCodeLogin: true, |
| | | showFieldLabels: false, |
| | | showDocLink: true, |
| | | showForgetPassword: true, |
| | | showQrcodeLogin: true, |
| | |
| | | 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, |
| | | }), |
| | | ); |
| | |
| | | '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" |
| | | > |
| | |
| | | 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; |
| | |
| | | import { useAuthStore } from '#/store'; |
| | | |
| | | import { generateAccess } from './access'; |
| | | import { safeDestination, terminalHome, terminalType } from '#/mobile/terminal'; |
| | | import { allowedMobilePath } from '#/mobile/access'; |
| | | |
| | | /** |
| | | * éç¨å®å«é
ç½® |
| | |
| | | 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; |
| | | } |
| | |
| | | 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; |
| | |
| | | import { createRouterGuard } from './guard'; |
| | | import { routes } from './routes'; |
| | | import { setupBaiduTongJi } from './tongji'; |
| | | import { mobileScroll } from '#/mobile/scroll'; |
| | | |
| | | /** |
| | | * @zh_CN å建vue-routerå®ä¾ |
| | |
| | | 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 }; |
| | | }, |
| | | // æ¯å¦åºè¯¥ç¦æ¢å°¾é¨ææ ã |
| | |
| | | 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'); |
| | |
| | | |
| | | /** åºæ¬è·¯ç±ï¼è¿äºè·¯ç±æ¯å¿
é¡»åå¨ç */ |
| | | const coreRoutes: RouteRecordRaw[] = [ |
| | | ...mobileRoutes, |
| | | /** |
| | | * æ ¹è·¯ç± |
| | | * 使ç¨åºç¡å¸å±ï¼ä½ä¸ºææé¡µé¢çç¶çº§å®¹å¨ï¼å级就ä¸å¿
é
ç½®BasicLayoutã |
| | |
| | | socialLogin, |
| | | } from '#/api'; |
| | | import { $t } from '#/locales'; |
| | | import { resetTerminal, safeDestination, terminalHome, terminalType } from '#/mobile/terminal'; |
| | | |
| | | export const useAuthStore = defineStore('auth', () => { |
| | | const accessStore = useAccessStore(); |
| | |
| | | const router = useRouter(); |
| | | |
| | | const loginLoading = ref(false); |
| | | const loadedTerminal = ref<string>(); |
| | | |
| | | /** |
| | | * 弿¥å¤çç»å½æä½ |
| | |
| | | |
| | | // 妿æåè·åå° accessToken |
| | | if (accessToken) { |
| | | accessStore.setIsAccessChecked(false); |
| | | accessStore.setAccessToken(accessToken); |
| | | accessStore.setRefreshToken(refreshToken); |
| | | |
| | |
| | | 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, |
| | |
| | | }; |
| | | } |
| | | |
| | | async function logout(redirect: boolean = true) { |
| | | async function logout(redirect: boolean = true, preserveTerminal: boolean = false) { |
| | | try { |
| | | const accessToken = accessStore.accessToken as string; |
| | | if (accessToken) { |
| | |
| | | // ä¸åä»»ä½å¤ç |
| | | } |
| | | resetAllStores(); |
| | | if (!preserveTerminal) resetTerminal(); |
| | | loadedTerminal.value = undefined; |
| | | accessStore.setLoginExpired(false); |
| | | |
| | | // åç»å½é¡µå¸¦ä¸å½åè·¯ç±å°å |
| | |
| | | // å è½½ |
| | | // 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 { |
| | |
| | | authLogin, |
| | | fetchUserInfo, |
| | | loginLoading, |
| | | loadedTerminal, |
| | | logout, |
| | | }; |
| | | }); |
| | |
| | | |
| | | 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(); |
| | |
| | | verifyRef.value.show(); |
| | | return; |
| | | } |
| | | await authStore.authLogin('username', values); |
| | | loginError.value = ''; |
| | | try { await authStore.authLogin('username', values); } |
| | | catch (e) { loginError.value = errorText(e); } |
| | | } |
| | | |
| | | /** éªè¯ç éè¿ï¼æ§è¡ç»å½ */ |
| | |
| | | }: { |
| | | 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[] => { |
| | |
| | | |
| | | <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" |
| | |
| | | :show-doc-link="false" |
| | | @submit="handleLogin" |
| | | /> |
| | | <p v-if="loginError" class="login-error" role="alert">{{loginError}}</p> |
| | | <Verification |
| | | ref="verifyRef" |
| | | v-if="captchaEnable" |
| | |
| | | /> |
| | | </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> |
| | |
| | | return [ |
| | | { |
| | | fieldName: 'code', |
| | | label: 'è¢ç ', |
| | | label: '追溯ç ', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥è¢ç ï¼äºç»´ç å
容ï¼', |
| | | placeholder: '请è¾å
¥è¿½æº¯ç ', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'businessCode', label: 'ä¸å¡ç ', component: 'Input', |
| | | componentProps: { allowClear: true, placeholder: '请è¾å
¥åºå
¥åºä¸å¡ç ' }, |
| | | }, |
| | | { |
| | | fieldName: 'batchCode', |
| | |
| | | { type: 'checkbox', width: 50 }, |
| | | { |
| | | field: 'code', |
| | | title: 'è¢ç ï¼äºç»´ç å
容ï¼', |
| | | title: '追溯ç ', |
| | | fixed: 'left', |
| | | minWidth: 220, |
| | | }, |
| | | { field: 'businessCode', title: 'ä¸å¡ç ï¼åºå
¥åºï¼', minWidth: 210 }, |
| | | { |
| | | field: 'bagNo', |
| | | title: 'è¢åºå·', |
| | |
| | | title: 'ç»å®æçç ', |
| | | minWidth: 190, |
| | | }, |
| | | { field: 'inboundTime', title: 'å
¥åºç¶æ', width: 100, formatter: ({ cellValue }) => cellValue ? 'å·²å
¥åº' : 'æªå
¥åº' }, |
| | | { |
| | | field: 'status', |
| | | title: 'è¢ç ç¶æ', |
| | |
| | | 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"; |
| | |
| | | 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" }); |
| | | |
| | |
| | | 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(); |
| | |
| | | } |
| | | |
| | | /** 导åºäºç»´ç 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) { |
| | |
| | | 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ï¼äº¤ä»å°å·ï¼ï¼å¯¼åºåè¢ç ç¶æå为已å°å·ï¼éå·æ°å表 */ |
| | |
| | | <BindPalletFormModal @success="handleRefresh" /> |
| | | <ReplaceFormModal @success="handleReplaceSuccess" /> |
| | | <QrTraceQueryModal /> |
| | | <BusinessQrViewModal /> |
| | | <Grid table-title="è¢ç å表ï¼ä¸è¢ä¸ç ï¼"> |
| | | <template #table-title><span class="text-[1rem] font-bold" style="white-space:nowrap">è¢ç å表ï¼ä¸è¢ä¸ç ï¼</span></template> |
| | | <template #toolbar-tools> |
| | | <TableAction :actions="[ |
| | | { |
| | | label: 'çææ°æ®å
', |
| | | type: 'primary', |
| | | icon: ACTION_ICON.ADD, |
| | | auth: ['mes:pro-bag-code:create'], |
| | | onClick: handleGenerate, |
| | | }, |
| | | { |
| | | label: 'çæåå§è¢ç ', |
| | | icon: ACTION_ICON.ADD, |
| | | auth: ['mes:pro-bag-code:create'], |
| | | onClick: handleRawGenerate, |
| | | }, |
| | | { |
| | | label: 'å½å
¥æ¹æ¬¡', |
| | | type: 'primary', |
| | | auth: ['mes:pro-bag-code:update'], |
| | | onClick: handleBindBatch, |
| | | }, |
| | | { |
| | | label: 'ç»å®æç', |
| | | type: 'primary', |
| | | auth: ['mes:pro-bag-code:update'], |
| | | onClick: handleBindPallet, |
| | | }, |
| | | { |
| | | label: 'ç ´ææ¿æ¢', |
| | | type: 'primary', |
| | | auth: ['mes:pro-bag-code:replace'], |
| | | onClick: handleReplace, |
| | | }, |
| | | { |
| | | label: 'è§£ç»æç', |
| | | auth: ['mes:pro-bag-code:update'], |
| | | onClick: handleUnbindPallet, |
| | | }, |
| | | { |
| | | label: 'æ«ç 追溯', |
| | | icon: ACTION_ICON.SEARCH, |
| | | auth: ['mes:pro-bag-code:query'], |
| | | onClick: handleTrace, |
| | | }, |
| | | { |
| | | label: '导åºäºç»´ç ', |
| | | icon: ACTION_ICON.DOWNLOAD, |
| | | auth: ['mes:pro-bag-code:export'], |
| | | onClick: handleExportQr, |
| | | }, |
| | | { |
| | | label: $t('ui.actionTitle.export'), |
| | | type: 'primary', |
| | | icon: ACTION_ICON.DOWNLOAD, |
| | | auth: ['mes:pro-bag-code:export'], |
| | | onClick: handleExport, |
| | | }, |
| | | ]" /> |
| | | <div class="mes-code-toolbar"> |
| | | <TableAction :actions="[{ label: 'çææ°æ®å
', type: 'primary', icon: ACTION_ICON.ADD, auth: ['mes:pro-bag-code:create'], onClick: handleGenerate }, |
| | | { label: 'å½å
¥æ¹æ¬¡', auth: ['mes:pro-bag-code:update'], onClick: handleBindBatch }, |
| | | { label: 'ç»å®æç', auth: ['mes:pro-bag-code:update'], onClick: handleBindPallet }, |
| | | { label: 'æ«ç 追溯', icon: ACTION_ICON.SEARCH, auth: ['mes:pro-bag-code:query'], onClick: handleTrace }]" /> |
| | | <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: '追溯äºç»´ç ', auth: ['mes:pro-bag-code:export'], onClick: () => handleExportQr('trace') }, |
| | | { label: 'ä¸å¡äºç»´ç ', auth: ['mes:pro-bag-code:export'], onClick: () => handleExportQr('business') }, |
| | | { label: 'Excel æ°æ®å
', auth: ['mes:pro-bag-code:export'], onClick: handleExport }]"> |
| | | <template #more><Button>å¯¼åº <IconifyIcon icon="lucide:chevron-down" /></Button></template> |
| | | </TableAction> |
| | | <TableAction :dropdown-trigger="['click']" :drop-down-actions="[{ label: 'çæåå§è¢ç ', auth: ['mes:pro-bag-code:create'], onClick: handleRawGenerate }, |
| | | { label: 'è¡¥çæä¸å¡ç ', auth: ['mes:pro-bag-code:create'], onClick: handleGenerateBusinessCodes }, |
| | | { label: 'ç ´ææ¿æ¢', auth: ['mes:pro-bag-code:replace'], onClick: handleReplace }, |
| | | { label: 'è§£ç»æç', auth: ['mes:pro-bag-code:update'], onClick: handleUnbindPallet }]"> |
| | | <template #more><Button>æ´å¤æä½ <IconifyIcon icon="lucide:chevron-down" /></Button></template> |
| | | </TableAction> |
| | | </div> |
| | | </template> |
| | | <template #actions="{ row }"> |
| | | <TableAction :actions="[ |
| | | { |
| | | label: 'æ¥çäºç»´ç ', |
| | | label: '追溯äºç»´ç ', |
| | | type: 'link', |
| | | auth: ['mes:pro-bag-code:query'], |
| | | onClick: handleViewQr.bind(null, row), |
| | | }, |
| | | { |
| | | label: 'ä¸å¡äºç»´ç ', type: 'link', auth: ['mes:pro-bag-code:query'], |
| | | ifShow: !!row.businessCode, onClick: handleBusinessQr.bind(null, row), |
| | | }, |
| | | { |
| | | label: $t('common.delete'), |
| | |
| | | </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> |
| | |
| | | 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 [ |
| | |
| | | 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', |
| | |
| | | allowClear: true, |
| | | disabled, |
| | | maxTagCount: 3, |
| | | dropdownMatchSelectWidth: false, |
| | | dropdownStyle: { minWidth: '360px', maxWidth: 'calc(100vw - 32px)' }, |
| | | mode: 'multiple', |
| | | optionFilterProp: 'label', |
| | | options: palletOptions, |
| | | placeholder: 'è¯·éæ©æçï¼å¯ä¸éï¼', |
| | | onChange: onPalletChange, |
| | | placeholder: 'è¯·éæ©ç¸ååç§çæç', |
| | | showSearch: true, |
| | | }, |
| | | dependencies: { |
| | | triggerFields: [''], |
| | | triggerFields: ['itemId'], |
| | | show: () => formType !== 'detail', |
| | | componentProps: (values) => ({ |
| | | options: palletOptions.map(option => ({ ...option, |
| | | disabled: !option.itemId || (!!values.itemId && option.itemId !== values.itemId), |
| | | })), |
| | | }), |
| | | }, |
| | | help: 'ç»å®åæ¹æ¬¡ç´æ¥è¿å
¥ç产ä¸ï¼æççææ£å¼æçç ï¼æç䏿ªå½æ¹æ¬¡çè¢ç èªå¨çº§èå½å
¥æ¬æ¹æ¬¡ï¼è¢ç åç§é¡»ä¸è´ï¼ï¼ç¼è¾æ¶ä»
éå¾éæ°å¢æçï¼å·²ç»å®çä¸å¯è§£ç»', |
| | | help: 'åªè½ç»å®ç¸ååç§çæçï¼åç§ä¿¡æ¯èªå¨å¸¦å
¥ï¼ç¼ºå°åç§çæç请å
è£
è¢ãç»å®åæ¹æ¬¡ç´æ¥è¿å
¥ç产ä¸ï¼æççææ£å¼æçç ï¼æç䏿ªå½æ¹æ¬¡çè¢ç èªå¨çº§èå½å
¥æ¬æ¹æ¬¡ï¼è¢ç åç§é¡»ä¸è´ï¼ï¼ç¼è¾æ¶ä»
éå¾éæ°å¢æçï¼å·²ç»å®çä¸å¯è§£ç»', |
| | | }, |
| | | { |
| | | fieldName: 'remark', |
| | |
| | | /** å表çæç´¢è¡¨å */ |
| | | export function useGridFormSchema(): VbenFormSchema[] { |
| | | return [ |
| | | { fieldName: 'businessCode', label: 'ä¸å¡ç ', component: 'Input', componentProps: { allowClear: true, placeholder: '请è¾å
¥ä½ä¸æ«ç ä¸å¡ç ' } }, |
| | | { |
| | | fieldName: 'code', |
| | | label: 'æ¹æ¬¡å·', |
| | |
| | | /** å表çåæ®µ */ |
| | | export function useGridColumns(): VxeTableGridOptions<MesProBatchApi.Batch>['columns'] { |
| | | return [ |
| | | { field: 'businessCode', title: 'ä¸å¡ç ï¼ä½ä¸æ«ç ï¼', minWidth: 210 }, |
| | | { |
| | | field: 'code', |
| | | title: 'æ¹æ¬¡å·', |
| | |
| | | minWidth: 200, |
| | | slots: { default: 'code' }, |
| | | }, |
| | | { field: 'itemName', title: 'åç§åç§°', minWidth: 150 }, |
| | | { field: 'itemCode', title: 'åç§ç¼ç ', minWidth: 150 }, |
| | | { |
| | | field: 'lineName', |
| | | title: 'å å·¥åä½', |
| | |
| | | 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, |
| | |
| | | }); |
| | | |
| | | /** å·æ°è¡¨æ ¼ */ |
| | | 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(); |
| | | } |
| | |
| | | <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)"> |
| | |
| | | <template #actions="{ row }"> |
| | | <TableAction :actions="[ |
| | | { |
| | | label: 'æ¥çäºç»´ç ', |
| | | label: '追溯äºç»´ç ', |
| | | type: 'link', |
| | | auth: ['mes:pro-batch:query'], |
| | | onClick: handleViewQr.bind(null, row), |
| | |
| | | }, |
| | | ]" |
| | | :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'], |
| | |
| | | }, |
| | | }, |
| | | { |
| | | label: 'æå°æ´å¥äºç»´ç ', |
| | | label: 'æå°æ´å¥è¿½æº¯äºç»´ç ', |
| | | auth: ['mes:pro-batch:export'], |
| | | onClick: () => handlePrintQrSet(row), |
| | | }, |
| | |
| | | </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> |
| | |
| | | |
| | | 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) |
| | |
| | | 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); |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | |
| | | /** äºç»´ç å±ç¤ºå
容ï¼é
ç½®äºæº¯æºå°ååæ¼ 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 龿¥ä¸æå追溯ç ï¼å
¼å®¹æ«ç æªæ«ææå°æ ç¾é¾æ¥ï¼ */ |
| | |
| | | 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; |
| | |
| | | schema: [ |
| | | { |
| | | field: "code", |
| | | label: "è¢ç ï¼äºç»´ç å
容ï¼", |
| | | label: "追溯ç ", |
| | | span: 2, |
| | | }, |
| | | { field: 'businessCode', label: 'ä¸å¡ç ï¼åºå
¥åºï¼', span: 2 }, |
| | | { |
| | | field: "bagNo", |
| | | label: "è¢åºå·", |
| | |
| | | /** å表çæç´¢è¡¨å */ |
| | | export function useGridFormSchema(): VbenFormSchema[] { |
| | | return [ |
| | | { fieldName: 'businessCode', label: 'ä¸å¡ç ', component: 'Input', componentProps: { allowClear: true, placeholder: '请è¾å
¥åºå
¥åºä¸å¡ç ' } }, |
| | | { |
| | | fieldName: 'code', |
| | | label: 'æçç ', |
| | |
| | | export function useGridColumns(): VxeTableGridOptions<MesProPalletApi.Pallet>['columns'] { |
| | | return [ |
| | | { type: 'checkbox', width: 50 }, |
| | | { field: 'businessCode', title: 'ä¸å¡ç ï¼åºå
¥åºï¼', minWidth: 210 }, |
| | | { |
| | | field: 'code', |
| | | title: 'æçç ', |
| | |
| | | minWidth: 210, |
| | | slots: { default: 'code' }, |
| | | }, |
| | | { field: 'itemName', title: 'åç§åç§°', minWidth: 150 }, |
| | | { field: 'itemCode', title: 'åç§ç¼ç ', minWidth: 150 }, |
| | | { |
| | | field: 'palletNo', |
| | | title: 'æçåºå·', |
| | |
| | | title: 'å·²ç»è¢ç æ°é', |
| | | width: 120, |
| | | }, |
| | | { field: 'inboundTime', title: 'å
¥åºç¶æ', width: 100, formatter: ({ cellValue }) => cellValue ? 'å·²å
¥åº' : 'æªå
¥åº' }, |
| | | { |
| | | field: 'status', |
| | | title: 'æçç¶æ', |
| | |
| | | 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 { |
| | |
| | | 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" }); |
| | | |
| | |
| | | }); |
| | | |
| | | /** å·æ°è¡¨æ ¼ */ |
| | | 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(); |
| | | } |
| | |
| | | <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> |
| | |
| | | <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', |
| | |
| | | </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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { useRoute } from 'vue-router'; |
| | | import { useUserStore } from '@vben/stores'; |
| | | import { availableFeatures, allowedMobilePath } from '#/mobile/access'; |
| | | import { myRecords, errorText } from '#/mobile/api'; |
| | | import Icon from '#/mobile/icon.vue'; |
| | | import type { MesProScanEventApi } from '#/api/mes/pro/scanEvent'; |
| | | const route = useRoute(), user = useUserStore(); |
| | | const position = ref('å°æªéæ©ä½ä¸ä½ç½®'); |
| | | const items = computed(availableFeatures); |
| | | const records = ref<MesProScanEventApi.ScanEvent[]>([]); |
| | | const error = ref(''), loading = ref(true); |
| | | onMounted(async () => { |
| | | try { const saved=JSON.parse(sessionStorage.getItem('mom-mobile-position-'+user.userInfo?.id)||'null');if(saved?.name)position.value=saved.name; } catch { /* Position will be selected before submitting. */ } |
| | | if(allowedMobilePath('/mobile/records')) try { records.value = (await myRecords({pageNo:1,pageSize:2})).list; } catch(e){error.value=errorText(e);} finally {loading.value=false;} |
| | | }); |
| | | </script> |
| | | <template> |
| | | <p class="m-muted">{{ user.userInfo?.nickname }}ï¼æ¬¢è¿è¿å
¥ç°åºä½ä¸</p> |
| | | <p v-if="route.query.forbidden" class="m-warning" role="alert">å½åè´¦å·æ²¡æè¯¥åè½çè®¿é®æéã</p> |
| | | <div v-if="items.length === 0" class="m-panel m-empty"><h2 class="m-title">ææ ç§»å¨ç«¯å¯ç¨åè½</h2><p>请è系管çåé
置已éé
èååæä½æéã</p><RouterLink to="/mobile/mine" class="m-link">åæ¢ä½¿ç¨ç«¯æéåº</RouterLink></div> |
| | | <template v-else> |
| | | <template v-if="route.path.endsWith('/home')"> |
| | | <div class="m-panel"> |
| | | <p class="m-muted">ç产ä¸ä»åºç°åºä½ä¸ · {{position}}</p> |
| | | <h2 class="m-title">ä»ä¸æ¬¡æ«ç å¼å§</h2> |
| | | <RouterLink v-if="items.some(f=>f.key==='inbound')" class="m-primary" to="/mobile/inbound"><Icon name="scan"/>æ«ç å
¥åº</RouterLink> |
| | | <RouterLink v-else :to="'/mobile/'+items[0]!.key" class="m-primary">{{items[0]!.title}}</RouterLink> |
| | | <p class="m-muted" style="margin:12px 0 0">å
¥åºåæ ¸å¯¹å¯¹è±¡ãæ°éååºä½ï¼æäº¤åæ¥çå¤çç»æã</p> |
| | | </div> |
| | | </template> |
| | | <h2 class="m-section-title">{{route.path.endsWith('/work')?'å
¨é¨ä½ä¸':'常ç¨åè½'}}</h2> |
| | | <div class="m-grid"> |
| | | <RouterLink v-for="item in items" :key="item.key" :to="'/mobile/'+item.key" class="m-feature"> |
| | | <Icon :name="item.icon"/><div><strong>{{item.title}}</strong><small>{{item.hint}}</small></div> |
| | | </RouterLink> |
| | | </div> |
| | | <template v-if="route.path.endsWith('/home') && allowedMobilePath('/mobile/records')"> |
| | | <h2 class="m-section-title">æè¿ä½ä¸</h2> |
| | | <p v-if="error" class="m-error" role="alert">{{error}}</p> |
| | | <p v-else-if="loading" class="m-muted">æ£å¨è·åæè¿ä½ä¸â¦</p> |
| | | <p v-else-if="!records.length" class="m-empty">ææ æ«ç å
¥åºè®°å½</p> |
| | | <article v-for="r in records" :key="r.id" class="m-record"><span :class="['m-status',r.status?.toLowerCase()]">{{r.status==='SUCCESS'?'å·²å
¥åº':'å¤ç失败'}}</span><p>{{r.bagCode}}</p><p class="m-muted">{{r.receivedTime}}</p></article> |
| | | <RouterLink to="/mobile/records" class="m-link">æ¥çå
¨é¨è®°å½</RouterLink> |
| | | </template> |
| | | </template> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { onBeforeRouteLeave } from 'vue-router'; |
| | | import { useUserStore } from '@vben/stores'; |
| | | import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse'; |
| | | import { getWarehouseLocationSimpleList, type MesWmWarehouseLocationApi } from '#/api/mes/wm/warehouse/location'; |
| | | import { getWarehouseAreaSimpleList, type MesWmWarehouseAreaApi } from '#/api/mes/wm/warehouse/area'; |
| | | import { inboundScanEvent, type MesProScanEventApi } from '#/api/mes/pro/scanEvent'; |
| | | import { lookup, myEvent, errorText, writeBlockReason, type TraceResult } from '#/mobile/api'; |
| | | import Scanner from './scanner.vue'; |
| | | import Picker from './picker.vue'; |
| | | const user=useUserStore(),storageKey='mom-mobile-inbound-'+user.userInfo?.id; |
| | | const positionKey='mom-mobile-position-'+user.userInfo?.id; |
| | | const locations=ref<MesWmWarehouseLocationApi.WarehouseLocation[]>([]),areas=ref<MesWmWarehouseAreaApi.WarehouseArea[]>([]); |
| | | const locationId=ref<number>(),areaId=ref<number>(),busy=ref(false),error=ref(''),trace=ref<TraceResult>(); |
| | | const quantity=ref(1),result=ref<MesProScanEventApi.ScanEvent>(),pending=ref<MesProScanEventApi.InboundParams>(),unknown=ref(false),ready=ref(false); |
| | | const remembered=ref(''); |
| | | const areaLoading=ref(false),positionLoading=ref(false); |
| | | let areaRequest=0; |
| | | const place=computed(()=>[locations.value.find(l=>l.id===locationId.value)?.name,areas.value.find(a=>a.id===areaId.value)?.name].filter(Boolean).join(' / ')||remembered.value); |
| | | async function loadAreas(){ |
| | | const ticket=++areaRequest, id=locationId.value; |
| | | areas.value=[];areaId.value=undefined;areaLoading.value=!!id; |
| | | try{if(id){const list=await getWarehouseAreaSimpleList(id);if(ticket===areaRequest)areas.value=list.filter(a=>!a.frozen&&(a.status==null||a.status===0));}} |
| | | finally{if(ticket===areaRequest)areaLoading.value=false;} |
| | | } |
| | | async function changeLocation(){error.value='';trace.value=undefined;try{await loadAreas();}catch(e){error.value=errorText(e);}} |
| | | async function loadPositions(){ |
| | | if(positionLoading.value)return;positionLoading.value=true; |
| | | error.value=''; |
| | | try{ |
| | | const warehouses=await getWarehouseSimpleList(); |
| | | const wip=warehouses.find(w=>w.code==='WIP_VIRTUAL_WAREHOUSE'); |
| | | if(!wip?.id)throw new Error('æªé
ç½®ç产æåä»ï¼è¯·è系管çå'); |
| | | locations.value=(await getWarehouseLocationSimpleList(wip.id)).filter(l=>!l.frozen); |
| | | const saved=JSON.parse(sessionStorage.getItem(positionKey)||'null'); |
| | | if(saved&&locations.value.some(l=>l.id===saved.locationId)){locationId.value=saved.locationId;await loadAreas();if(areas.value.some(a=>a.id===saved.areaId))areaId.value=saved.areaId;} |
| | | ready.value=true; |
| | | }catch(e){error.value=errorText(e);}finally{positionLoading.value=false;} |
| | | } |
| | | onMounted(async()=>{ |
| | | try { |
| | | const savedPosition=JSON.parse(sessionStorage.getItem(positionKey)||'null'); |
| | | remembered.value=savedPosition?.name||''; |
| | | const savedPending=JSON.parse(sessionStorage.getItem(storageKey)||'null'); |
| | | if(savedPending){pending.value=savedPending;unknown.value=true;return;} |
| | | } catch { error.value='䏿¬¡æä½ä¿¡æ¯æ æ³è¯»åï¼è¯·éè¿ä½ä¸è®°å½æ ¸å®ååå
¥åºã'; return; } |
| | | await loadPositions(); |
| | | }); |
| | | function eventId(){ |
| | | const bytes=crypto.getRandomValues(new Uint8Array(16)); |
| | | return Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join(''); |
| | | } |
| | | async function identify(code:string){ |
| | | if(busy.value||pending.value)return;busy.value=true;error.value='';trace.value=undefined;result.value=undefined; |
| | | try{ |
| | | const found=await lookup(code),reason=writeBlockReason(found); |
| | | if(reason)throw new Error(reason); |
| | | if (found.type === 'bag' && found.data.inboundTime || found.type !== 'bag' && found.data.pendingBagQuantity === 0) throw new Error('该对象没æå¾
å
¥åºè¢ç ï¼è¯·å¿éå¤å
¥åº'); |
| | | trace.value=found;quantity.value=1; |
| | | }catch(e){error.value=errorText(e);}finally{busy.value=false;} |
| | | } |
| | | function complete(r:MesProScanEventApi.ScanEvent){ |
| | | result.value=r;unknown.value=false;pending.value=undefined;sessionStorage.removeItem(storageKey); |
| | | error.value=r.status==='SUCCESS'?'':(r.errorMessage||'å
¥åºå¤±è´¥'); |
| | | } |
| | | async function submit(){ |
| | | if(busy.value)return; |
| | | if(!pending.value){ |
| | | if(!trace.value||!locationId.value||!areaId.value){error.value='请å
éæ©åºåºãåºä½å¹¶è¯å«ç å¼';return;} |
| | | if(!Number.isFinite(quantity.value)||quantity.value<=0){error.value='å
¥åºæ°éå¿
须大äºé¶';return;} |
| | | pending.value={eventId:eventId(),bagCode:trace.value.code,scanType:trace.value.type.toUpperCase() as MesProScanEventApi.ScanType,quantity:quantity.value,locationId:locationId.value,areaId:areaId.value}; |
| | | sessionStorage.setItem(positionKey,JSON.stringify({locationId:locationId.value,areaId:areaId.value,name:place.value})); |
| | | } |
| | | sessionStorage.setItem(storageKey,JSON.stringify(pending.value)); |
| | | busy.value=true;error.value=''; |
| | | try{complete(await inboundScanEvent(pending.value));} |
| | | catch(e){unknown.value=true;error.value='ç»æå¾
确认ï¼'+errorText(e)+'ã请å
æ ¸å®åæä½ï¼å¿éæ°æ«ç å
¥åºã';} |
| | | finally{busy.value=false;} |
| | | } |
| | | async function verify(){ |
| | | if(!pending.value||busy.value)return;busy.value=true;error.value=''; |
| | | try{const r=await myEvent(pending.value.eventId);if(r)complete(r);else error.value='å°æªæ¥å°è®°å½ã请ç¨åæ ¸å®ï¼æä½¿ç¨åæä½å·éè¯ã';} |
| | | catch(e){error.value=errorText(e);}finally{busy.value=false;} |
| | | } |
| | | function next(){result.value=undefined;trace.value=undefined;error.value='';if(!ready.value)void loadPositions();} |
| | | onBeforeRouteLeave(()=>!busy.value&&(!trace.value||!!result.value||unknown.value||window.confirm('离å¼å°æ¾å¼å°æªæäº¤çå¯¹è±¡ï¼æ¯å¦ç»§ç»ï¼'))); |
| | | </script> |
| | | <template> |
| | | <p class="m-muted">ç产æåå
¥åº · è´¨æ£åæå</p> |
| | | <div v-if="pending && unknown" class="m-panel"> |
| | | <h2 class="m-title">æ ¸å®ä¸æ¬¡å
¥åºç»æ</h2><p>{{pending.bagCode}}</p><p>ç®æ ä½ç½®ï¼{{place||('åºåº '+pending.locationId+' / åºä½ '+pending.areaId)}}</p><p class="m-muted">æä½å·ï¼{{pending.eventId}}</p> |
| | | <p class="m-warning">å䏿ä½éè¯å°æ²¿ç¨åæä½å·ï¼ä¸å建æ°çå
¥åºè¯·æ±ã</p> |
| | | <div class="m-stack"><button class="m-primary" :disabled="busy" @click="verify">æ ¸å®ç»æ</button><button class="m-secondary" :disabled="busy" @click="submit">使ç¨åæä½å·éè¯</button></div> |
| | | </div> |
| | | <template v-else-if="result"> |
| | | <div :class="result.status==='SUCCESS'?'m-success':'m-error'" role="status"><h2 class="m-title">{{result.status==='SUCCESS'?'å
¥åºæå':'å
¥åºå¤±è´¥'}}</h2><p>{{result.bagCode}}</p><p>{{result.errorMessage||place}}</p><p class="m-muted">{{result.eventTime||result.receivedTime}}</p></div> |
| | | <button class="m-primary" @click="next">ç»§ç»æ«ç </button> |
| | | <RouterLink to="/mobile/records" class="m-link">æ¥çä½ä¸è®°å½</RouterLink> |
| | | </template> |
| | | <template v-else> |
| | | <div v-if="!trace" class="m-panel"> |
| | | <h2 class="m-title">ç®æ ä½ç½®</h2> |
| | | <Picker v-model="locationId" label="åºåº" :options="locations" :disabled="busy||!!trace" :loading="positionLoading" placeholder="è¯·éæ©åºåº" @update:model-value="changeLocation"/> |
| | | <Picker v-model="areaId" label="åºä½" :options="areas" :disabled="busy||!!trace||!locationId" :loading="areaLoading" placeholder="è¯·éæ©åºä½"/> |
| | | <p v-if="!ready" class="m-muted">æ£å¨è·åä½ä¸ä½ç½®</p> |
| | | <button v-if="!ready&&error" class="m-secondary" @click="loadPositions">éæ°è·åä½ç½®</button> |
| | | </div> |
| | | <Scanner v-if="!trace" :disabled="busy||!areaId||!ready" @code="identify"/> |
| | | <div v-else class="m-panel"> |
| | | <h2 class="m-title">æ ¸å¯¹å
¥åºå¯¹è±¡</h2> |
| | | <dl><div class="m-row"><dt>åç§</dt><dd>{{trace.data.itemName||'æªå½å
¥'}}</dd></div><div class="m-row"><dt>ç å¼</dt><dd>{{trace.code}}</dd></div><div class="m-row"><dt>ç±»å</dt><dd>{{{bag:'è¢ç ',pallet:'æçç ',batch:'æ¹æ¬¡ç '}[trace.type]}}</dd></div><div class="m-row"><dt>ç®æ ä½ç½®</dt><dd>{{place}}</dd></div><div class="m-row"><dt>æ¹æ¬¡</dt><dd>{{trace.data.batchCode||'æªç»å®'}}</dd></div></dl> |
| | | <label v-if="trace.type==='bag'" class="m-field"><span>å
¥åºæ°éï¼æåºå计éåä½ï¼</span><input v-model.number="quantity" type="number" inputmode="decimal" min="0.000001" step="any" :disabled="busy"/></label> |
| | | <p v-else class="m-warning">æ´æ / æ´æ¹å
¥åºï¼æ¬æ¬¡å¾
å
¥åº {{trace.data.pendingBagQuantity ?? 'â'}} è¢ï¼æç»ç±æå¡ç«¯ææªå
¥åºææè¢æ°è®¡ç®ã</p> |
| | | <div class="m-action-bar"><button class="m-primary" :disabled="busy" @click="submit">{{busy?'æ£å¨å
¥åºâ¦':'确认å
¥åº'}}</button><button class="m-secondary" :disabled="busy" @click="next">è¿åéæ°æ«æ</button></div> |
| | | </div> |
| | | </template> |
| | | <p v-if="error" class="m-error" role="alert">{{error}}</p> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | .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} |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, nextTick, onBeforeUnmount, onDeactivated, ref, useId } from 'vue'; |
| | | import Icon from '#/mobile/icon.vue'; |
| | | |
| | | export interface Choice { id?: number; name?: string; code?: string } |
| | | const props = withDefaults(defineProps<{ |
| | | modelValue?: number; label: string; options: Choice[]; disabled?: boolean; |
| | | loading?: boolean; placeholder?: string; allowEmpty?: boolean; |
| | | }>(), { placeholder: 'è¯·éæ©', allowEmpty: false }); |
| | | const emit = defineEmits<{ 'update:modelValue': [value: number | undefined] }>(); |
| | | const dialog = ref<HTMLDialogElement>(), searchInput = ref<HTMLInputElement>(); |
| | | const search = ref(''), opened = ref(false), titleId = useId(); |
| | | const selected = computed(() => props.options.find(o => o.id === props.modelValue)); |
| | | const filtered = computed(() => { |
| | | const key = search.value.trim().toLocaleLowerCase(); |
| | | return props.options.filter(o => o.id != null && (!key || [o.name, o.code].some(v => v?.toLocaleLowerCase().includes(key)))); |
| | | }); |
| | | let previousOverflow = ''; |
| | | async function open() { |
| | | if (props.disabled || props.loading || opened.value) return; |
| | | search.value = ''; opened.value = true; |
| | | previousOverflow = document.body.style.overflow; |
| | | document.body.style.overflow = 'hidden'; |
| | | await nextTick(); |
| | | dialog.value?.showModal(); |
| | | searchInput.value?.focus({ preventScroll: true }); |
| | | } |
| | | function close() { |
| | | if (!opened.value) return; |
| | | dialog.value?.close(); opened.value = false; |
| | | document.body.style.overflow = previousOverflow; |
| | | } |
| | | function choose(value?: number) { emit('update:modelValue', value); close(); } |
| | | function trapFocus(event: KeyboardEvent) { |
| | | if(event.key!=='Tab')return; |
| | | const controls=Array.from(dialog.value?.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled)')||[]) |
| | | .filter(el=>el.getClientRects().length>0); |
| | | const first=controls[0],last=controls[controls.length-1]; |
| | | if(!first||!last)return; |
| | | if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();} |
| | | else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();} |
| | | } |
| | | function backdrop(e: MouseEvent) { |
| | | if (e.target !== dialog.value) return; |
| | | const rect = dialog.value.getBoundingClientRect(); |
| | | if (e.clientY < rect.top || e.clientX < rect.left || e.clientX > rect.right) close(); |
| | | } |
| | | onBeforeUnmount(close); |
| | | onDeactivated(close); |
| | | </script> |
| | | <template> |
| | | <div class="m-field"> |
| | | <span :id="titleId+'-label'">{{label}}</span> |
| | | <button type="button" class="m-picker-trigger" :disabled="disabled||loading" aria-haspopup="dialog" |
| | | :aria-labelledby="titleId+'-label '+titleId+'-value'" :aria-expanded="opened" @click="open"> |
| | | <span :id="titleId+'-value'">{{loading?'æ£å¨å è½½â¦':selected?.name||placeholder}}</span><Icon name="arrow"/> |
| | | </button> |
| | | </div> |
| | | <dialog ref="dialog" class="m-picker-dialog" :aria-labelledby="titleId" @cancel.prevent="close" @click="backdrop" @keydown="trapFocus"> |
| | | <div class="m-picker-body"> |
| | | <header class="m-picker-header"><h2 :id="titleId" class="m-title">éæ©{{label}}</h2><button type="button" class="m-icon-button" :aria-label="'å
³é'+label+'éæ©'" @click="close">Ã</button></header> |
| | | <label class="m-field"><span>æç´¢{{label}}</span><input ref="searchInput" v-model="search" class="m-input" :placeholder="'è¾å
¥'+label+'åç§°æç¼ç '" autocomplete="off"/></label> |
| | | <div class="m-picker-options" :aria-label="label+'é项'"> |
| | | <button v-if="allowEmpty" type="button" class="m-picker-option" :aria-pressed="modelValue==null" @click="choose()">{{placeholder}}<span v-if="modelValue==null">â</span></button> |
| | | <button v-for="option in filtered" :key="option.id" type="button" class="m-picker-option" :aria-pressed="option.id===modelValue" @click="choose(option.id)"> |
| | | <span>{{option.name||option.code||option.id}}<small v-if="option.code&&option.code!==option.name">{{option.code}}</small></span><span v-if="option.id===modelValue">â</span> |
| | | </button> |
| | | <p v-if="!filtered.length" class="m-empty">{{options.length?'没æå¹é
ç'+label:'ææ å¯é'+label}}</p> |
| | | </div> |
| | | </div> |
| | | </dialog> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { onMounted, ref } from 'vue'; |
| | | import { getSeedQualityPage, type SeedQualityRecord } from '#/api/mes/qc/seedQuality'; |
| | | import { errorText } from '#/mobile/api'; |
| | | import Scanner from './scanner.vue'; |
| | | |
| | | const rows = ref<SeedQualityRecord[]>([]), busy = ref(false), error = ref(''); |
| | | const code = ref(''), batchCode = ref(''), result = ref(''), page = ref(0), total = ref(0); |
| | | let filters: { code?: string; batchCode?: string; result?: number } = {}; |
| | | async function load(reset = true) { |
| | | if (busy.value) return; |
| | | busy.value = true; error.value = ''; |
| | | const next = reset ? 1 : page.value + 1; |
| | | const query = reset ? { code: code.value.trim() || undefined, batchCode: batchCode.value.trim() || undefined, result: result.value ? Number(result.value) : undefined } : filters; |
| | | try { |
| | | const data = await getSeedQualityPage({ ...query, pageNo: next, pageSize: 20 }); |
| | | rows.value = reset ? data.list : [...rows.value, ...data.list]; |
| | | page.value = next; total.value = data.total; filters = query; |
| | | } catch (e) { error.value = errorText(e); } finally { busy.value = false; } |
| | | } |
| | | onMounted(() => load()); |
| | | </script> |
| | | <template> |
| | | <p class="m-muted">æ¥çè´¨æ£ç»æãæ¹æ¬¡ä¸ä¸åæ ¼åå ã</p> |
| | | <Scanner :disabled="busy" @code="value => { code = value; load(); }" /> |
| | | <form class="m-panel" @submit.prevent="load()"> |
| | | <label class="m-field"><span>ä¸å¡ç / 追溯ç </span><input v-model="code" :disabled="busy" placeholder="è¾å
¥è¢ç " /></label> |
| | | <label class="m-field"><span>çäº§æ¹æ¬¡</span><input v-model="batchCode" :disabled="busy" placeholder="è¾å
¥æ¹æ¬¡å·" /></label> |
| | | <label class="m-field"><span>è´¨æ£ç»æ</span><select v-model="result" :disabled="busy"><option value="">å
¨é¨ç»æ</option><option value="1">åæ ¼</option><option value="2">ä¸åæ ¼</option></select></label> |
| | | <button class="m-secondary" :disabled="busy">{{busy ? 'æ£å¨æ¥è¯¢â¦' : 'æ¥è¯¢è®°å½'}}</button> |
| | | </form> |
| | | <p v-if="error" class="m-error" role="alert">{{error}}</p> |
| | | <p class="m-muted" role="status">å
± {{total}} æ¡è®°å½</p> |
| | | <p v-if="!rows.length && !busy && !error" class="m-empty">ææ å¹é
çè´¨æ£è®°å½</p> |
| | | <article v-for="row in rows" :key="row.id" class="m-panel"> |
| | | <strong :class="['m-status', row.result === 1 ? 'success' : row.result === 2 ? 'failed' : '']">{{row.result === 1 ? 'åæ ¼' : row.result === 2 ? 'ä¸åæ ¼' : 'å¤çä¸'}}</strong> |
| | | <dl><div class="m-row"><dt>追溯ç </dt><dd>{{row.code}}</dd></div><div class="m-row"><dt>çäº§æ¹æ¬¡</dt><dd>{{row.batchCode || 'â'}}</dd></div><div class="m-row"><dt>æ£éªæ¶é´</dt><dd>{{row.inspectedAt || row.createTime || 'â'}}</dd></div><div v-if="row.reason" class="m-row"><dt>åå / 夿³¨</dt><dd>{{row.reason}}</dd></div></dl> |
| | | </article> |
| | | <button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">{{busy ? 'æ£å¨å è½½â¦' : 'å è½½æ´å¤'}}</button> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { onBeforeRouteLeave } from 'vue-router'; |
| | | import { useUserStore } from '@vben/stores'; |
| | | import { resolveSeedQuality, submitSeedQualityGroup, type SeedQualityScan } from '#/api/mes/qc/seedQuality'; |
| | | import { getWarehouseSimpleList, type MesWmWarehouseApi } from '#/api/mes/wm/warehouse'; |
| | | import { allowedMobilePath, extractCode } from '#/mobile/access'; |
| | | import { errorText } from '#/mobile/api'; |
| | | import Scanner from './scanner.vue'; |
| | | import Picker from './picker.vue'; |
| | | |
| | | const busy = ref(false), error = ref(''), notice = ref(''), bag = ref<SeedQualityScan>(); |
| | | const warehouses = ref<MesWmWarehouseApi.Warehouse[]>([]), warehouseId = ref<number>(); |
| | | const reason = ref(''), pendingCode = ref(''); |
| | | type ScanType = 'BAG' | 'PALLET' | 'BATCH'; |
| | | const scanType = ref<ScanType>('BAG'); |
| | | const typeNames = { BAG: 'è¢ç ', PALLET: 'æçç ', BATCH: 'çäº§æ¹æ¬¡ç ' }; |
| | | const pendingType = ref<ScanType>('BAG'), pendingIds = ref<number[]>([]); |
| | | const storageKey = 'mom-mobile-quality-' + useUserStore().userInfo?.id; |
| | | const indicators = ref([ |
| | | { code: 'purity', name: '纯度', value: '', minValue: '0', maxValue: '100' }, |
| | | { code: 'cleanliness', name: 'å度', value: '', minValue: '0', maxValue: '100' }, |
| | | { code: 'germination_rate', name: 'åè½ç', value: '', minValue: '0', maxValue: '100' }, |
| | | { code: 'moisture', name: 'æ°´å', value: '', minValue: '0', maxValue: '100' }, |
| | | ]); |
| | | const complete = computed(() => indicators.value.every(i => [i.value, i.minValue, i.maxValue].every(v => String(v).trim() !== '' && Number.isFinite(Number(v))) && Number(i.minValue) >= 0 && Number(i.maxValue) <= 100 && Number(i.value) >= 0 && Number(i.value) <= 100 && Number(i.minValue) <= Number(i.maxValue))); |
| | | const passed = computed(() => complete.value && indicators.value.every(i => Number(i.value) >= Number(i.minValue) && Number(i.value) <= Number(i.maxValue))); |
| | | function clearPending() { pendingCode.value = ''; pendingIds.value = []; sessionStorage.removeItem(storageKey); } |
| | | async function identify(raw: string) { |
| | | if (busy.value || pendingCode.value) return; |
| | | busy.value = true; error.value = ''; notice.value = ''; bag.value = undefined; |
| | | try { |
| | | const data = await resolveSeedQuality(extractCode(raw), scanType.value); |
| | | if (data.canceled || !data.inbound) throw new Error('该对象已ä½åºæææè¢ç å°æªå
¨é¨å
¥åºï¼ä¸è½è´¨æ£'); |
| | | if (!data.pendingBagIds?.length) throw new Error('该对象没æå¾
è´¨æ£è¢ç ï¼è¯·å¨è´¨æ£è®°å½ä¸æ¥çå·²æç»æ'); |
| | | bag.value = data; indicators.value.forEach(i => i.value = ''); reason.value = ''; |
| | | } catch (e) { error.value = errorText(e); } finally { busy.value = false; } |
| | | } |
| | | async function submit() { |
| | | if (busy.value || pendingCode.value || !bag.value || !complete.value || !warehouseId.value) return; |
| | | if (!passed.value && !reason.value.trim()) { error.value = '请填åä¸åæ ¼åå '; return; } |
| | | const count = bag.value.pendingBagIds!.length; |
| | | if (!window.confirm(passed.value ? `ç¡®è®¤å¯¹æ¬æ¬¡ ${count} è¢ç»ä¸å¤å®åæ ¼ï¼å
¨é¨å°è½¬å
¥æéåæ ¼åºã` : `ç¡®è®¤å¯¹æ¬æ¬¡ ${count} è¢ç»ä¸å¤å®ä¸åæ ¼ï¼å
¨é¨å°ä»æååºåç§»é¤å¹¶æ è®°æ¥åºã`)) return; |
| | | error.value = ''; notice.value = ''; |
| | | pendingCode.value = bag.value.code; pendingType.value = scanType.value; pendingIds.value = [...bag.value.pendingBagIds!]; |
| | | sessionStorage.setItem(storageKey, JSON.stringify({ code: pendingCode.value, scanType: pendingType.value, bagIds: pendingIds.value })); busy.value = true; |
| | | try { |
| | | const result = await submitSeedQualityGroup({ code: bag.value.code, scanType: scanType.value, bagIds: pendingIds.value, qualifiedWarehouseId: warehouseId.value, reason: reason.value.trim() || undefined, indicators: indicators.value.map(i => ({ code: i.code, value: Number(i.value), minValue: Number(i.minValue), maxValue: Number(i.maxValue) })) }); |
| | | clearPending(); notice.value = `æ¬æ¬¡ ${result.processedCount} è¢` + (passed.value ? 'è´¨æ£åæ ¼ï¼å·²è½¬å
¥åæ ¼åºã' : 'è´¨æ£ä¸åæ ¼ï¼å·²è®°å½åå å¹¶ç§»é¤æååºåã'); |
| | | bag.value = undefined; reason.value = ''; indicators.value.forEach(i => i.value = ''); |
| | | } catch (e) { error.value = 'æäº¤ç»æå¾
确认ï¼' + errorText(e) + 'ãè¯·æ ¸å®ååæä½ä¸ä¸è¢ã'; } finally { busy.value = false; } |
| | | } |
| | | async function verify() { |
| | | if (busy.value || !pendingCode.value) return; |
| | | busy.value = true; error.value = ''; |
| | | try { |
| | | const data = await resolveSeedQuality(pendingCode.value, pendingType.value); |
| | | const completed = pendingIds.value.filter(id => data.completedBagIds?.includes(id)); |
| | | if (pendingIds.value.length ? completed.length === pendingIds.value.length : !!data.qualityId) { notice.value = 'å·²æ ¸å®ï¼æ¬æ¬¡è¢ç åå·²æè´¨æ£è®°å½ï¼è¯·å¨è´¨æ£è®°å½ä¸æ ¸å¯¹ç»æã'; bag.value = undefined; clearPending(); } |
| | | else if (completed.length) error.value = 'æ¬æ¬¡æ¸
åä»
é¨åè¢ç å·²æè´¨æ£è®°å½ï¼è¯·æ ¸å¯¹è´¨æ£è®°å½åèç³»è´è´£äººå¤çï¼å½åä¿çå¾
确认æä½ã'; |
| | | else if (window.confirm('å½åæªæ¥å°æ¬æ¬¡è¢ç è´¨æ£è®°å½ã确认å请æ±å·²ç»æåï¼å¯éæ°æ«æå¹¶æ ¸å¯¹ææ ãç»§ç»ï¼')) { clearPending(); bag.value = undefined; notice.value = 'è¯·éæ°æ«æå¹¶å½å
¥ææ ï¼åç«¯ä¼æ¦æªéå¤è´¨æ£ã'; } |
| | | } catch (e) { error.value = errorText(e); } finally { busy.value = false; } |
| | | } |
| | | onMounted(async () => { |
| | | const saved = sessionStorage.getItem(storageKey); |
| | | if (saved) { |
| | | try { const pending = JSON.parse(saved); if (pending && typeof pending === 'object' && pending.code) { pendingCode.value = pending.code; pendingType.value = pending.scanType; pendingIds.value = pending.bagIds || []; } else pendingCode.value = saved; } |
| | | catch { pendingCode.value = saved; } // å
¼å®¹å级åä¿åçåè¢å¾
确认ç å¼ã |
| | | } |
| | | busy.value = true; |
| | | try { warehouses.value = (await getWarehouseSimpleList()).filter(w => w.type === 20 && !w.frozen); } |
| | | catch (e) { error.value = errorText(e); } finally { busy.value = false; } |
| | | }); |
| | | onBeforeRouteLeave(() => !busy.value && (!bag.value || !!pendingCode.value || window.confirm('离å¼å°æ¾å¼æªæäº¤çè´¨æ£ææ ï¼æ¯å¦ç»§ç»ï¼'))); |
| | | </script> |
| | | <template> |
| | | <p v-if="notice" class="m-success" role="status">{{notice}}</p> |
| | | <p v-if="error" class="m-error" role="alert">{{error}}</p> |
| | | <div v-if="pendingCode" class="m-panel"><h2 class="m-title">è´¨æ£ç»æå¾
确认</h2><p>{{pendingCode}}</p><button class="m-primary" :disabled="busy" @click="verify">æ ¸å®è´¨æ£ç»æ</button></div> |
| | | <template v-else> |
| | | <label class="m-field"><span>è´¨æ£æ«ç ç±»å</span><select v-model="scanType" :disabled="busy" @change="bag = undefined; notice = ''; error = ''"><option value="BAG">è¢ç </option><option value="PALLET">æçç </option><option value="BATCH">çäº§æ¹æ¬¡ç </option></select></label> |
| | | <p class="m-muted">æ«æ{{typeNames[scanType]}}ï¼æ ¸å¯¹å¾
æ£è¢æ°ï¼ç»ä¸å½å
¥åé¡¹ææ ãå·²æ£ãä½åºè¢ç ä¸ä¼éå¤å¤çã</p> |
| | | <Scanner :disabled="busy" @code="identify" /> |
| | | <form v-if="bag" @submit.prevent="submit"> |
| | | <div class="m-panel"><h2 class="m-title">æ¬æ¬¡æ£éªå¯¹è±¡</h2><dl><div class="m-row"><dt>对象类å</dt><dd>{{typeNames[scanType]}}</dd></div><div class="m-row"><dt>对象ç å¼</dt><dd>{{bag.code}}</dd></div><div class="m-row"><dt>ä¸å¡ç </dt><dd>{{bag.businessCode || 'â'}}</dd></div><div class="m-row"><dt>çäº§æ¹æ¬¡</dt><dd>{{bag.batchCode || 'â'}}</dd></div><div class="m-row"><dt>ææè¢æ°</dt><dd>{{bag.totalCount}}</dd></div><div class="m-row"><dt>å·²æ£è¢æ°</dt><dd>{{bag.completedBagIds?.length || 0}}</dd></div><div class="m-row"><dt>æ¬æ¬¡å¾
æ£</dt><dd>{{bag.pendingBagIds?.length || 0}} è¢</dd></div></dl><p v-if="scanType !== 'BAG'" class="m-warning">以䏿æ 忣éªç»è®ºå°åºç¨äºæ¬æ¬¡å
¨é¨å¾
æ£è¢ç ï¼è¯·ç¡®è®¤æ½æ ·ç»æéç¨äºè¿{{bag.pendingBagIds?.length}}è¢ã</p></div> |
| | | <fieldset v-for="i in indicators" :key="i.code" class="m-panel m-quality-indicator" :disabled="busy"><legend>{{i.name}}ï¼%ï¼</legend> |
| | | <label class="m-field"><span>宿µå¼</span><input v-model="i.value" type="number" inputmode="decimal" min="0" max="100" step="any" required placeholder="è¾å
¥å®æµç¾åæ¯" /></label> |
| | | <div class="m-grid"><label class="m-field"><span>åæ ¼ä¸é</span><input v-model="i.minValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label><label class="m-field"><span>åæ ¼ä¸é</span><input v-model="i.maxValue" type="number" inputmode="decimal" min="0" max="100" step="any" required /></label></div> |
| | | </fieldset> |
| | | <div class="m-panel"><p class="m-muted">ä¸ä¸é沿ç¨ç°æè´¨æ£è§åå½å
¥æ¹å¼ï¼è¯·æè¯¥åç§æ£éªæ åæ ¸å¯¹ã</p><Picker v-model="warehouseId" label="ç®æ åæ ¼åº" :options="warehouses" :disabled="busy" /><p v-if="!warehouses.length && !busy" class="m-warning">ææ å¯ç¨åæ ¼åºï¼è¯·è系管çåé
ç½®ã</p><label class="m-field"><span>{{complete && !passed ? 'ä¸åæ ¼åå ï¼å¿
å¡«ï¼' : 'æ£éªå¤æ³¨'}}</span><textarea v-model="reason" :disabled="busy" :required="complete && !passed" rows="3" maxlength="255" /></label></div> |
| | | <div class="m-action-bar"><p v-if="complete" :class="passed ? 'm-success' : 'm-warning'">é¢å¤ï¼{{passed ? 'åæ ¼ï¼æäº¤å转å
¥åæ ¼åº' : 'ä¸åæ ¼ï¼æäº¤åç§»é¤æååºå'}}ãæç»ä»¥æå¡ç«¯ç»æä¸ºåã</p><button class="m-primary" :disabled="busy || !complete || !warehouseId">{{busy ? 'æ£å¨æäº¤â¦' : '确认æäº¤è´¨æ£'}}</button></div> |
| | | </form> |
| | | </template> |
| | | <RouterLink v-if="allowedMobilePath('/mobile/quality-records')" to="/mobile/quality-records" class="m-link">æ¥çè´¨æ£è®°å½</RouterLink> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { onBeforeRouteLeave, useRoute } from 'vue-router'; |
| | | import { useUserStore } from '@vben/stores'; |
| | | import { bindBagCodePallet, getBagCode, getBagCodePage } from '#/api/mes/pro/bagCode'; |
| | | import { bindPalletBatch, getPallet, getPalletPage } from '#/api/mes/pro/pallet'; |
| | | import { getBatchPage } from '#/api/mes/pro/batch'; |
| | | import { can } from '#/mobile/access'; |
| | | import { lookup, errorText, writeBlockReason, type TraceResult, type TraceObject } from '#/mobile/api'; |
| | | import Scanner from './scanner.vue'; |
| | | defineOptions({name:'MobileQuery'}); |
| | | const route=useRoute(),key=route.path.split('/')[2]||'bags'; |
| | | const expected={bags:'bag',pallets:'pallet',batches:'batch'}[key]; |
| | | const busy=ref(false),error=ref(''),notice=ref(''),detail=ref<TraceResult>(),rows=ref<TraceObject[]>([]),page=ref(1),total=ref(0),filter=ref(''); |
| | | const binding=ref(false),target=ref<TraceResult>(),bags=ref<TraceResult[]>([]); |
| | | type PendingBind={kind:string;ids:number[];targetId:number}; |
| | | const pending=ref<PendingBind>(); |
| | | const storageKey='mom-mobile-bind-'+useUserStore().userInfo?.id+'-'+key; |
| | | const canBind=computed(()=>key==='bags'?can('mes:pro-bag-code:update')&&can('mes:pro-pallet:query'):key==='pallets'&&can('mes:pro-pallet:update')&&can('mes:pro-batch:query')); |
| | | let appliedFilter=''; |
| | | async function load(reset=false){ |
| | | if(busy.value)return;busy.value=true;error.value=''; |
| | | const nextPage=reset?1:page.value+1; |
| | | const search=reset?filter.value.trim():appliedFilter; |
| | | try{ |
| | | const params={pageNo:nextPage,pageSize:20,code:search||undefined}; |
| | | const r=await (key==='bags'?getBagCodePage(params):key==='pallets'?getPalletPage(params):getBatchPage(params)); |
| | | rows.value=reset?r.list:[...rows.value,...r.list];total.value=r.total;page.value=nextPage; |
| | | appliedFilter=search; |
| | | }catch(e){error.value=errorText(e);}finally{busy.value=false;} |
| | | } |
| | | onMounted(()=>{try{pending.value=JSON.parse(sessionStorage.getItem(storageKey)||'null')||undefined;}catch{sessionStorage.removeItem(storageKey);}load(true);}); |
| | | async function identify(code:string){ |
| | | if(busy.value||pending.value)return;busy.value=true;error.value='';notice.value=''; |
| | | try{ |
| | | const r=await lookup(code); |
| | | if(binding.value&&writeBlockReason(r))throw new Error(writeBlockReason(r)); |
| | | if(!binding.value){ |
| | | if(r.type!==expected)throw new Error('该ç ç±»åä¸å½åæ¥è¯¢é¡µé¢ä¸ä¸è´ï¼è¯·ä½¿ç¨å¯¹åºæ¥è¯¢å
¥å£'); |
| | | detail.value=r; |
| | | }else if(!target.value){ |
| | | if(r.type!==(key==='bags'?'pallet':'batch'))throw new Error(key==='bags'?'请å
æ«æç®æ æç':'请å
æ«æç®æ æ¹æ¬¡'); |
| | | target.value=r; |
| | | }else{ |
| | | if(r.type!==expected)throw new Error('è¯·æ«æ'+(key==='bags'?'è¢ç ':'æçç ')); |
| | | if(key==='bags'){ |
| | | if(r.data.palletId)throw new Error('该è¢ç å·²ç»å®æçï¼è¯·æ ¸å®åæä½'); |
| | | if(r.data.batchId&&target.value.data.batchId&&r.data.batchId!==target.value.data.batchId)throw new Error('è¢ç ä¸ç®æ æçæ¹æ¬¡ä¸ä¸è´'); |
| | | if(bags.value.some(b=>b.data.id===r.data.id))throw new Error('æ¬æ¬¡å·²ç»éé该è¢ç '); |
| | | bags.value.push(r); |
| | | }else{ |
| | | if(r.data.batchId)throw new Error('该æçå·²ç»å®æ¹æ¬¡'); |
| | | detail.value=r; |
| | | } |
| | | } |
| | | }catch(e){error.value=errorText(e);}finally{busy.value=false;} |
| | | } |
| | | function startBind(){binding.value=true;detail.value=undefined;target.value=undefined;bags.value=[];notice.value='';error.value='';} |
| | | function clearPending(){pending.value=undefined;sessionStorage.removeItem(storageKey);} |
| | | async function submitBind(){ |
| | | if(busy.value||!target.value?.data.id)return; |
| | | const ids=key==='bags'?bags.value.map(b=>b.data.id!):detail.value?.data.id?[detail.value.data.id]:[]; |
| | | if(!ids.length)return; |
| | | const operation={kind:key,ids,targetId:target.value.data.id}; |
| | | pending.value=operation;sessionStorage.setItem(storageKey,JSON.stringify(operation));busy.value=true;error.value=''; |
| | | try{ |
| | | if(key==='bags')await bindBagCodePallet({ids,palletId:operation.targetId}); |
| | | else await bindPalletBatch({ids,batchId:operation.targetId}); |
| | | clearPending();binding.value=false;bags.value=[];detail.value=undefined;notice.value='ç»å®æåï¼å·²æ´æ°å¯¹è±¡çç»å®å
³ç³»ã'; |
| | | }catch(e){error.value='ç»å®ç»æå¾
确认ï¼'+errorText(e)+'ã请å
æ ¸å®ï¼å¿éå¤æäº¤ã';} |
| | | finally{busy.value=false;} |
| | | } |
| | | async function verifyBind(){ |
| | | if(!pending.value||busy.value)return;busy.value=true;error.value=''; |
| | | try{ |
| | | const p=pending.value; |
| | | const states=await Promise.all(p.ids.map(id=>p.kind==='bags'?getBagCode(id):getPallet(id))); |
| | | const all=states.every(s=>p.kind==='bags'?('palletId'in s&&s.palletId===p.targetId):s.batchId===p.targetId); |
| | | const none=states.every(s=>p.kind==='bags'?(!('palletId'in s)||!s.palletId):!s.batchId); |
| | | if(all){notice.value='å·²æ ¸å®ï¼ææå¯¹è±¡ç»å®æåã';clearPending();binding.value=false;bags.value=[];detail.value=undefined;} |
| | | else if(none){notice.value='å·²æ ¸å®ï¼å¯¹è±¡ä»æªç»å®ãè¯·éæ°æ¥è¯¢å¯¹è±¡åééæäº¤ã';clearPending();binding.value=false;bags.value=[];detail.value=undefined;} |
| | | else error.value='对象ç»å®ç»æä¸ä¸è´ï¼è¯·èç³»è´è´£äººæ ¸å¯¹ãå½åä¿çå¾
确认æä½ã'; |
| | | }catch(e){error.value=errorText(e);}finally{busy.value=false;} |
| | | } |
| | | onBeforeRouteLeave(()=>!busy.value&&(!binding.value||!!pending.value||window.confirm('离å¼å°æ¾å¼æ¬æ¬¡æªæäº¤çç»å®æ¸
åï¼æ¯å¦ç»§ç»ï¼'))); |
| | | </script> |
| | | <template> |
| | | <p v-if="notice" class="m-success" role="status">{{notice}}</p> |
| | | <p v-if="error" class="m-error" role="alert">{{error}}</p> |
| | | <div v-if="pending" class="m-panel"><h2 class="m-title">ç»å®ç»æå¾
确认</h2><p>æ¬æ¬¡ {{pending.ids.length}} 个对象ï¼ç®æ ç¼å· {{pending.targetId}}ã</p><button class="m-primary" :disabled="busy" @click="verifyBind">æ ¸å®ç»å®ç»æ</button></div> |
| | | <template v-else> |
| | | <template v-if="binding"> |
| | | <h2 class="m-title">{{!target?(key==='bags'?'å
æ«æç®æ æç':'å
æ«æç®æ æ¹æ¬¡'):(key==='bags'?'éè¢éé':'æ«æå¾
ç»å®æç')}}</h2> |
| | | <div v-if="target" class="m-panel"><p>ç®æ ï¼{{target.code}}</p><p class="m-muted">{{target.data.itemName||'æªå½å
¥åç§'}} · {{target.data.batchCode||'æªç»å®æ¹æ¬¡'}}</p></div> |
| | | </template> |
| | | <Scanner :disabled="busy" @code="identify"/> |
| | | <div v-if="detail&&!binding" class="m-panel"> |
| | | <h2 class="m-title">{{detail.data.itemName||'对象详æ
'}}</h2> |
| | | <p v-if="detail.type === 'batch'" class="m-status">{{detail.data.status === 30 ? '已宿' : detail.data.status === 20 ? 'ç产ä¸' : detail.data.status === 40 ? 'å·²ä½åº' : 'è稿'}} · å·²å
¥åº {{detail.data.inboundBagQuantity || 0}} è¢</p> |
| | | <p v-else class="m-status">{{detail.data.inboundTime ? 'å·²å
¥åº' : 'æªå
¥åº'}}<span v-if="detail.type === 'pallet'"> · å¾
å
¥åº {{detail.data.pendingBagQuantity ?? 'â'}} è¢</span></p> |
| | | <p v-if="writeBlockReason(detail)" class="m-warning">{{writeBlockReason(detail)}}</p> |
| | | <dl><div class="m-row"><dt>ç å¼</dt><dd>{{detail.code}}</dd></div><div class="m-row"><dt>æ¹æ¬¡</dt><dd>{{detail.data.batchCode||detail.data.tempCode||'æªç»å®'}}</dd></div><div class="m-row"><dt>æç</dt><dd>{{detail.data.palletCode||'â'}}</dd></div><div class="m-row"><dt>çäº§æ¥æ</dt><dd>{{detail.data.produceDate||'æªå½å
¥'}}</dd></div><div class="m-row"><dt>产线</dt><dd>{{detail.data.lineName||'æªå½å
¥'}}</dd></div></dl> |
| | | <dl v-if="detail.type==='bag'"><div class="m-row"><dt>追溯ç </dt><dd>{{detail.data.code}}</dd></div><div class="m-row"><dt>ä¸å¡ç </dt><dd>{{detail.data.businessCode||'æªçæ'}}</dd></div></dl> |
| | | <dl v-else><div class="m-row"><dt>ä¸å¡ç </dt><dd>{{detail.data.businessCode||'æªçæ'}}</dd></div></dl> |
| | | </div> |
| | | <template v-if="binding"> |
| | | <p v-if="key==='bags'" class="m-muted">å·²éé {{bags.length}} è¢ï¼å°æªæäº¤ç»å®</p> |
| | | <article v-for="(bag,i) in bags" :key="bag.data.id" class="m-record"><p>{{bag.code}}</p><button class="m-detail-toggle" :disabled="busy" @click="bags.splice(i,1)">ç§»é¤</button></article> |
| | | <p v-if="key==='pallets'&&detail">å¾
ç»å®æçï¼{{detail.code}}</p> |
| | | <div class="m-action-bar"><button class="m-primary" :disabled="busy||!target||(key==='bags'?!bags.length:!detail)" @click="submitBind">{{busy?'æ£å¨ç»å®â¦':'确认ç»å®'}}</button><button class="m-secondary" :disabled="busy" @click="binding=false;bags=[];target=undefined;detail=undefined">åæ¶æ¬æ¬¡ç»å®</button></div> |
| | | </template> |
| | | <template v-else> |
| | | <button v-if="canBind" class="m-primary" :disabled="busy" @click="startBind">{{key==='bags'?'å¼å§è¢ç ç»å®æç':'å¼å§æçç»å®æ¹æ¬¡'}}</button> |
| | | <h2 class="m-section-title">对象å表</h2> |
| | | <form class="m-stack" @submit.prevent="load(true)"><label class="m-field"><span>æç 弿¥è¯¢</span><input v-model="filter" placeholder="è¾å
¥ç å¼" /></label><button class="m-secondary" :disabled="busy">æ¥è¯¢å表</button></form> |
| | | <p v-if="!rows.length&&!busy" class="m-empty">ææ å¹é
对象</p> |
| | | <button v-for="row in rows" :key="row.id" class="m-feature" style="width:100%;background:transparent;border-top:0;border-left:0;border-right:0;text-align:left" :disabled="busy" @click="identify(row.code||row.tempCode||'')"><div><strong>{{row.itemName||row.code||row.tempCode}}</strong><small>{{row.code||row.tempCode}} · {{row.batchCode||'ç¹å»æ¥ç详æ
'}}</small></div></button> |
| | | <button v-if="rows.length<total" class="m-secondary" :disabled="busy" @click="load()">{{busy?'æ£å¨å è½½â¦':'å è½½æ´å¤'}}</button> |
| | | </template> |
| | | </template> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { onMounted,ref } from 'vue'; |
| | | import type { MesProScanEventApi } from '#/api/mes/pro/scanEvent'; |
| | | import { myRecords,errorText } from '#/mobile/api'; |
| | | const now=new Date(); |
| | | const day=ref(now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0')+'-'+String(now.getDate()).padStart(2,'0')); |
| | | const status=ref(''),page=ref(1),total=ref(0),busy=ref(false),error=ref(''),rows=ref<MesProScanEventApi.ScanEvent[]>([]); |
| | | let applied={day:day.value,status:status.value}; |
| | | async function load(reset=false){ |
| | | if(busy.value)return;busy.value=true;error.value='';const nextPage=reset?1:page.value+1; |
| | | const filters=reset?{day:day.value,status:status.value}:applied; |
| | | try{const r=await myRecords({pageNo:nextPage,pageSize:20,status:filters.status||undefined,receivedTime:filters.day?[filters.day+' 00:00:00',filters.day+' 23:59:59']:undefined});rows.value=reset?r.list:[...rows.value,...r.list];total.value=r.total;page.value=nextPage;applied=filters;} |
| | | catch(e){error.value=errorText(e);}finally{busy.value=false;} |
| | | } |
| | | onMounted(()=>load(true)); |
| | | </script> |
| | | <template> |
| | | <form class="m-panel" @submit.prevent="load(true)"><label class="m-field"><span>ä½ä¸æ¥æ</span><input v-model="day" type="date"/></label><label class="m-field"><span>ç»æ</span><select v-model="status"><option value="">å
¨é¨ç»æ</option><option value="SUCCESS">å·²å
¥åº</option><option value="FAILED">失败</option></select></label><button class="m-secondary" :disabled="busy">{{busy?'æ£å¨æ¥è¯¢â¦':'æ¥è¯¢æ¬äººè®°å½'}}</button></form> |
| | | <p v-if="error" class="m-error" role="alert">{{error}}</p><p v-if="!rows.length&&!busy" class="m-empty">è¯¥æ¥æææ æ«ç å
¥åºè®°å½</p> |
| | | <article v-for="r in rows" :key="r.id" class="m-panel"><span :class="['m-status',r.status?.toLowerCase()]">{{r.status==='SUCCESS'?'å·²å
¥åº':'å¤ç失败'}}</span><h2 class="m-title" style="margin-top:8px">{{r.bagCode}}</h2><p class="m-muted">{{r.receivedTime}}</p><p v-if="r.errorMessage" class="m-error">{{r.errorMessage}}</p><details><summary class="m-link">æ¥çæ¬æ¬¡ä½ä¸æç»</summary><dl><div class="m-row"><dt>æä½å·</dt><dd>{{r.eventId}}</dd></div><div class="m-row"><dt>æ¹æ¬¡</dt><dd>{{r.batchCode||'æªè§£æ'}}</dd></div><div class="m-row"><dt>æ«ç ç±»å</dt><dd>{{r.scanType}}</dd></div><div class="m-row"><dt>æ°é</dt><dd>{{r.quantity}}</dd></div><div class="m-row"><dt>宿æ¶é´</dt><dd>{{r.eventTime||'æªå®æ'}}</dd></div></dl></details></article> |
| | | <button v-if="rows.length<total" class="m-secondary" :disabled="busy" @click="load()">å è½½æ´å¤</button> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | import { onBeforeRouteLeave } from 'vue-router'; |
| | | import { useUserStore } from '@vben/stores'; |
| | | import { getProductSalesPage, getProductSales, previewProductSalesScan, scanProductSales, checkProductSalesQuantity, stockProductSales, shippingProductSales, finishProductSales, type MesWmProductSalesApi } from '#/api/mes/wm/productsales'; |
| | | import { getProductSalesLinePage, type MesWmProductSalesLineApi } from '#/api/mes/wm/productsales/line'; |
| | | import { getProductSalesDetailListByLineId, type MesWmProductSalesDetailApi } from '#/api/mes/wm/productsales/detail'; |
| | | import { can, extractCode } from '#/mobile/access'; |
| | | import { errorText } from '#/mobile/api'; |
| | | import Scanner from './scanner.vue'; |
| | | import PickingBags from '../wls/productsales/modules/picking-bags.vue'; |
| | | |
| | | type Sales = MesWmProductSalesApi.ProductSales; |
| | | type Preview = MesWmProductSalesApi.ProductSalesScanResponse; |
| | | type Line = MesWmProductSalesLineApi.ProductSalesLine; |
| | | type Detail = MesWmProductSalesDetailApi.ProductSalesDetail; |
| | | type Action = 'scan' | 'stock' | 'shipping' | 'finish'; |
| | | type Pending = { salesId: number; action: Action; before: number; preview?: Preview; carrier?: string; shippingNumber?: string }; |
| | | const statusNames: Record<number, string> = { 0: 'è稿', 1: 'å¾
æ£æµ', 2: 'å¾
æ£è´§', 10: 'å¾
å¡«åè¿å', 3: 'å¾
æ§è¡åºåº', 4: '已宿', 5: '已忶' }; |
| | | const busy = ref(false), error = ref(''), notice = ref(''), code = ref(''), status = ref(''); |
| | | const rows = ref<Sales[]>([]), page = ref(0), total = ref(0), sales = ref<Sales>(); |
| | | const lines = ref<Line[]>([]), details = ref<Detail[]>([]), preview = ref<Preview>(), pending = ref<Pending>(); |
| | | const carrier = ref(''), shippingNumber = ref(''); |
| | | const scanType = ref<'BAG' | 'PALLET' | 'BATCH'>('BAG'), selectedBagIds = ref<number[]>([]); |
| | | const storageKey = 'mom-mobile-sales-' + useUserStore().userInfo?.id; |
| | | let filters: { code?: string; status?: number } = {}; |
| | | const picking = computed(() => sales.value?.status === 2 && can('mes:wm-product-sales:stock')); |
| | | function clearPending() { pending.value = undefined; sessionStorage.removeItem(storageKey); } |
| | | async function load(reset = true) { |
| | | if (busy.value || pending.value) return; |
| | | busy.value = true; error.value = ''; |
| | | const next = reset ? 1 : page.value + 1; |
| | | const query = reset ? { code: code.value.trim() || undefined, status: status.value === '' ? undefined : Number(status.value) } : filters; |
| | | try { const data = await getProductSalesPage({ ...query, pageNo: next, pageSize: 20 }); rows.value = reset ? data.list : [...rows.value, ...data.list]; page.value = next; total.value = data.total; filters = query; } |
| | | catch (e) { error.value = errorText(e); } finally { busy.value = false; } |
| | | } |
| | | async function readOrder(id: number) { |
| | | const order = await getProductSales(id); |
| | | if (!order) throw new Error('åºåºåä¸åå¨æå·²è¢«å é¤'); |
| | | const all: Line[] = []; |
| | | for (let n = 1; ; n++) { |
| | | const data = await getProductSalesLinePage({ salesId: id, pageNo: n, pageSize: 100 }); |
| | | all.push(...data.list); |
| | | if (all.length >= data.total || !data.list.length) break; |
| | | } |
| | | const picked = (await Promise.all(all.filter(l => l.id != null).map(l => getProductSalesDetailListByLineId(l.id!)))).flat(); |
| | | sales.value = order; lines.value = all; details.value = picked; |
| | | carrier.value = order.carrier || ''; shippingNumber.value = order.shippingNumber || ''; |
| | | } |
| | | async function open(id?: number) { |
| | | if (!id || busy.value || pending.value) return; |
| | | busy.value = true; error.value = ''; notice.value = ''; preview.value = undefined; |
| | | try { await readOrder(id); } catch (e) { error.value = errorText(e); } finally { busy.value = false; } |
| | | } |
| | | function pickedQuantity(lineId?: number) { return details.value.filter(d => d.lineId === lineId).reduce((sum, d) => sum + Number(d.quantity || 0), 0); } |
| | | async function identify(raw: string) { |
| | | if (busy.value || !picking.value || pending.value) return; |
| | | busy.value = true; error.value = ''; notice.value = ''; preview.value = undefined; |
| | | try { |
| | | selectedBagIds.value = []; |
| | | const data = await previewProductSalesScan(extractCode(raw), scanType.value, sales.value?.id); |
| | | if (!data.codeId || !data.scanQuantity || data.scanQuantity <= 0) throw new Error('æ æ³ç¡®è®¤æ«ç æ°éï¼è¯·æ ¸å¯¹ç å¼'); |
| | | const line = lines.value.find(l => l.itemId === data.itemId); |
| | | if (!line) throw new Error('æ«ç åç§ä¸å¨å½ååºåºåä¸'); |
| | | data.itemName = line.itemName; preview.value = data; |
| | | if (data.scanType === 'BAG') selectedBagIds.value = data.bags?.filter(b => b.selectable).map(b => b.id) || []; |
| | | } catch (e) { error.value = errorText(e); } finally { busy.value = false; } |
| | | } |
| | | async function execute(action: Action) { |
| | | const order = sales.value; |
| | | if (!order?.id || busy.value || pending.value) return; |
| | | const permission = action === 'scan' ? 'stock' : action; |
| | | if (!can('mes:wm-product-sales:' + permission)) return; |
| | | if (action === 'scan' && (!picking.value || !preview.value)) return; |
| | | if (action === 'scan' && !selectedBagIds.value.length) { error.value = 'è¯·éæ©æ¬æ¬¡æ£è´§å¯¹åºçè¢ç '; return; } |
| | | if (action === 'stock' && order.status !== 2 || action === 'shipping' && order.status !== 10 || action === 'finish' && order.status !== 3) return; |
| | | if (action === 'shipping' && (!carrier.value.trim() || !shippingNumber.value.trim())) { error.value = 'è¯·å¡«åæ¿è¿ååè¿è¾åå·'; return; } |
| | | const message = action === 'scan' ? `ç¡®è®¤å°æé ${selectedBagIds.value.length} è¢å å
¥æ¬åæ£è´§å¹¶å
³è追溯è¢ç ï¼` : action === 'stock' ? 'ç¡®è®¤å®ææ£è´§ï¼ç³»ç»å°æ ¸å¯¹ååç§æ°éã' : action === 'shipping' ? '确认ä¿åè¿åå¹¶è¿å
¥å¾
æ§è¡åºåºï¼' : '确认æ§è¡åºåºï¼æ¬åå°æ£ååºåå¹¶æ è®°å·²å®æã'; |
| | | if (!window.confirm(message)) return; |
| | | busy.value = true; error.value = ''; notice.value = ''; |
| | | try { |
| | | if (action === 'stock' && !await checkProductSalesQuantity(order.id)) throw new Error('æ£è´§æ°éä¸åºåºæ°éä¸ä¸è´ï¼è¯·æ ¸å¯¹æ¯ä¸ªåç§'); |
| | | const operation: Pending = { salesId: order.id, action, before: order.status!, preview: action === 'scan' ? { ...preview.value, selectedBagIds: [...selectedBagIds.value], scanQuantity: selectedBagIds.value.length } : undefined, carrier: carrier.value.trim(), shippingNumber: shippingNumber.value.trim() }; |
| | | pending.value = operation; sessionStorage.setItem(storageKey, JSON.stringify(operation)); |
| | | if (action === 'scan') { |
| | | const result = await scanProductSales({ salesId: order.id, scanContent: operation.preview!.scanContent!, scanType: operation.preview!.scanType as 'BAG' | 'PALLET' | 'BATCH', bagIds: operation.preview!.selectedBagIds, quantity: operation.preview!.scanQuantity }); |
| | | notice.value = result.alreadyScanned ? '该ç 已卿£è´§æ¸
åä¸ï¼æ¬æ¬¡æªé夿·»å ã' : `å·²å å
¥æ£è´§ï¼${result.outboundQuantity || operation.preview!.scanQuantity} è¢ã`; |
| | | } else if (action === 'stock') { await stockProductSales(order.id); notice.value = 'æ£è´§å®æï¼è¯·å¡«åè¿åã'; } |
| | | else if (action === 'shipping') { await shippingProductSales({ id: order.id, carrier: operation.carrier, shippingNumber: operation.shippingNumber }); notice.value = 'è¿åå·²ä¿åï¼è¯·æ ¸å¯¹åæ§è¡åºåºã'; } |
| | | else { await finishProductSales(order.id); notice.value = 'åºåºå®æï¼åºåå·²æ´æ°ã'; } |
| | | clearPending(); preview.value = undefined; |
| | | await readOrder(order.id); |
| | | } catch (e) { error.value = (pending.value ? 'æä½ç»æå¾
确认ï¼è¯·å
æ ¸å®ï¼' : '') + errorText(e); } finally { busy.value = false; } |
| | | } |
| | | async function verify() { |
| | | if (!pending.value || busy.value) return; |
| | | busy.value = true; error.value = ''; |
| | | try { |
| | | const operation = pending.value; |
| | | await readOrder(operation.salesId); |
| | | const requestedIds = operation.preview?.selectedBagIds; |
| | | const found = operation.action === 'scan' ? requestedIds?.length ? requestedIds.every(id => details.value.some(d => d.bagCodeId === id)) : details.value.some(d => operation.preview?.scanType === 'PALLET' ? d.palletCodeId === operation.preview.codeId : d.bagCodeId === operation.preview?.codeId) : sales.value?.status !== operation.before; |
| | | if (operation.action === 'scan' && !found && requestedIds?.some(id => details.value.some(d => d.bagCodeId === id))) { error.value = 'æéè¢ç ä»
é¨ååå¨äºæ£è´§æç»ï¼è¯·èç³»è´è´£äººæ ¸å¯¹ï¼ä¸è½èªå¨éè¯ã'; return; } |
| | | if (found) { |
| | | notice.value = operation.action === 'scan' ? 'å·²æ ¸å®ï¼è¯¥ç 已卿£è´§æ¸
åä¸ã' : 'å½ååæ®ç¶æï¼' + statusNames[sales.value!.status!] + 'ãè¯·æ ¸å¯¹æç»åè¿ååç»§ç»ã'; |
| | | clearPending(); preview.value = undefined; |
| | | } else if (window.confirm('å½åå°æªåç°è¯¥æä½ç»æã确认å请æ±å·²ç»æåï¼å¯éæ°æ ¸å¯¹åæä½ãç»§ç»ï¼')) { clearPending(); preview.value = undefined; notice.value = 'è¯·æ ¸å¯¹å½å忮䏿£è´§æ¸
ååéæ°æä½ã'; } |
| | | } catch (e) { error.value = errorText(e); } finally { busy.value = false; } |
| | | } |
| | | onMounted(async () => { |
| | | try { pending.value = JSON.parse(sessionStorage.getItem(storageKey) || 'null') || undefined; } catch { sessionStorage.removeItem(storageKey); } |
| | | if (pending.value) { busy.value = true; try { await readOrder(pending.value.salesId); } catch (e) { error.value = errorText(e); } finally { busy.value = false; } } |
| | | else await load(); |
| | | }); |
| | | onBeforeRouteLeave(() => !busy.value && (!preview.value || !!pending.value || window.confirm('离å¼å°æ¾å¼å°æªç¡®è®¤çæ«ç å¯¹è±¡ï¼æ¯å¦ç»§ç»ï¼'))); |
| | | </script> |
| | | <template> |
| | | <p v-if="notice" class="m-success" role="status">{{notice}}</p> |
| | | <p v-if="error" class="m-error" role="alert">{{error}}</p> |
| | | <section v-if="pending" class="m-panel"><h2 class="m-title">åºåºæä½ç»æå¾
确认</h2><p>åæ®ç¼å· {{pending.salesId}}ï¼è¯·å
æ ¸å®åç»§ç»æä½ã</p><button class="m-primary" :disabled="busy" @click="verify">æ ¸å®æä½ç»æ</button></section> |
| | | <template v-if="sales"> |
| | | <button v-if="!pending" class="m-secondary" :disabled="busy" @click="sales = undefined; preview = undefined; load()">è¿ååºåºåå表</button> |
| | | <section class="m-panel" style="margin-top:16px"><h2 class="m-title">{{sales.code}}</h2><strong class="m-status">{{statusNames[sales.status!] || 'æªç¥ç¶æ'}}</strong><dl><div class="m-row"><dt>客æ·</dt><dd>{{sales.clientName || 'â'}}</dd></div><div class="m-row"><dt>æ¶è´§äºº</dt><dd>{{sales.contactName || 'â'}} {{sales.contactTelephone}}</dd></div><div class="m-row"><dt>æ¶è´§å°å</dt><dd>{{sales.contactAddress || 'â'}}</dd></div><div v-if="sales.shippingNumber" class="m-row"><dt>è¿å</dt><dd>{{sales.carrier}} · {{sales.shippingNumber}}</dd></div></dl><button v-if="!pending" class="m-secondary" :disabled="busy" @click="open(sales.id)">å·æ°åæ®</button></section> |
| | | <h2 class="m-section-title">åºåºåç§ä¸æ£è´§è¿åº¦</h2> |
| | | <p v-if="!lines.length" class="m-empty">è¯¥åææ åç§æç»</p> |
| | | <article v-for="line in lines" :key="line.id" class="m-panel"><h3 class="m-title">{{line.itemName || line.itemCode}}</h3><p>计å {{line.quantity || 0}} {{line.unitMeasureName || 'è¢'}} · å·²æ£ {{pickedQuantity(line.id)}} {{line.unitMeasureName || 'è¢'}}</p><details><summary>æ¥çæ£è´§æç»</summary><div v-for="detail in details.filter(d => d.lineId === line.id)" :key="detail.id" class="m-record"><p>{{detail.batchCode || 'æªå½å
¥æ¹æ¬¡'}} · {{detail.quantity}} {{line.unitMeasureName || 'è¢'}}</p><p class="m-muted">{{detail.warehouseName || 'â'}} / {{detail.locationName || 'â'}} / {{detail.areaName || 'â'}}</p><p class="m-muted">{{detail.bagCodeId ? 'è¢ç ç¼å· ' + detail.bagCodeId : 'æçç¼å· ' + detail.palletCodeId}}</p><p v-if="detail.remark" class="m-muted" style="overflow-wrap:anywhere">{{detail.remark}}</p></div></details></article> |
| | | <template v-if="!pending"> |
| | | <template v-if="picking"><h2 class="m-section-title">æ«ç æ£è´§</h2><label class="m-field"><span>æ£è´§æ«ç ç±»å</span><select v-model="scanType" :disabled="busy" @change="preview = undefined; selectedBagIds = []"><option value="BAG">è¢ç </option><option value="PALLET">æçç </option><option value="BATCH">çäº§æ¹æ¬¡ç </option></select></label><p class="m-muted">æ¯æä¸å¡ç ä¸è¿½æº¯ç ãæçãæ¹æ¬¡è¯å«åéæ©å®é
æ£è´§è¢ç ï¼æ°éææéè¢æ°è®¡å
¥ã</p><Scanner :disabled="busy" @code="identify" /><section v-if="preview" class="m-panel"><h3 class="m-title">æ ¸å¯¹æ£è´§å¯¹è±¡</h3><p>{{preview.itemName}}</p><p style="overflow-wrap:anywhere">{{preview.scanContent}}</p><p>æ¹æ¬¡ {{preview.batchCode}} · 坿£ {{preview.scanQuantity}} è¢</p><PickingBags v-if="preview.scanType !== 'BAG'" v-model="selectedBagIds" :bags="preview.bags || []" :disabled="busy" /><p v-else>è¢ç {{preview.bags?.[0]?.code}}</p><p role="status">æ¬æ¬¡æ£è´§ {{selectedBagIds.length}} è¢</p><button class="m-primary" :disabled="busy || !selectedBagIds.length" @click="execute('scan')">确认å å
¥æ£è´§</button><button class="m-detail-toggle" :disabled="busy" @click="preview = undefined">åæ¶æ¬æ¬¡æ«ç </button></section></template> |
| | | <form v-if="sales.status === 10 && can('mes:wm-product-sales:shipping')" class="m-panel" @submit.prevent="execute('shipping')"><h2 class="m-title">å¡«åè¿å</h2><label class="m-field"><span>æ¿è¿å</span><input v-model="carrier" :disabled="busy" maxlength="100" required /></label><label class="m-field"><span>è¿è¾åå·</span><input v-model="shippingNumber" :disabled="busy" maxlength="100" required /></label><button class="m-primary" :disabled="busy">ä¿åè¿å</button></form> |
| | | <div v-if="!preview" class="m-action-bar"><button v-if="picking" class="m-primary" :disabled="busy || !lines.length" @click="execute('stock')">宿æ£è´§å¹¶æ ¸å¯¹æ°é</button><button v-if="sales.status === 3 && can('mes:wm-product-sales:finish')" class="m-primary" :disabled="busy" @click="execute('finish')">确认æ§è¡åºåº</button></div> |
| | | <p v-if="sales.status === 0 || sales.status === 1" class="m-warning">{{sales.status === 0 ? '该å为è稿ï¼è¯·å¨ PC 端维æ¤åç§å¹¶æäº¤åè¿è¡ç§»å¨æ£è´§ã' : '该åå¾
æ£æµï¼æ£æµå®æåæè½æ£è´§ã'}}</p> |
| | | <p v-if="sales.status === 4" class="m-success">该å已宿åºåºã</p> |
| | | </template> |
| | | </template> |
| | | <template v-else-if="!pending"> |
| | | <form class="m-panel" @submit.prevent="load()"><label class="m-field"><span>åºåºåå·</span><input v-model="code" :disabled="busy" placeholder="è¾å
¥åºåºåå·" /></label><label class="m-field"><span>åæ®ç¶æ</span><select v-model="status" :disabled="busy"><option value="">å
¨é¨ç¶æ</option><option v-for="(name, value) in statusNames" :key="value" :value="String(value)">{{name}}</option></select></label><button class="m-secondary" :disabled="busy">{{busy ? 'æ£å¨æ¥è¯¢â¦' : 'æ¥è¯¢åºåºå'}}</button></form> |
| | | <p class="m-muted" role="status">å
± {{total}} å¼ åºåºå</p><p v-if="!rows.length && !busy && !error" class="m-empty">ææ å¹é
çåºåºå</p> |
| | | <button v-for="row in rows" :key="row.id" class="m-order-button" :disabled="busy" @click="open(row.id)"><strong>{{row.code}}</strong><span>{{statusNames[row.status!] || 'æªç¥ç¶æ'}}</span><small>{{row.clientName || 'æªå½å
¥å®¢æ·'}}</small></button> |
| | | <button v-if="rows.length < total" class="m-secondary" :disabled="busy" @click="load(false)">{{busy ? 'æ£å¨å è½½â¦' : 'å è½½æ´å¤'}}</button> |
| | | </template> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { onBeforeUnmount, onDeactivated, onMounted, ref } from 'vue'; |
| | | import Icon from '#/mobile/icon.vue'; |
| | | const props=defineProps<{disabled?:boolean}>(); |
| | | const emit=defineEmits<{code:[value:string]}>(); |
| | | const input=ref(''),error=ref(''),active=ref(false),starting=ref(false),video=ref<HTMLVideoElement>(); |
| | | let stream:MediaStream|undefined,frame=0,generation=0,last=0; |
| | | function stop(){generation++;active.value=false;starting.value=false;cancelAnimationFrame(frame);stream?.getTracks().forEach(t=>t.stop());stream=undefined;if(video.value)video.value.srcObject=null;} |
| | | function accept(value:string){if(props.disabled)return;stop();input.value=value;emit('code',value);} |
| | | async function start(){ |
| | | error.value='';stop();const ticket=generation;starting.value=true; |
| | | try{ |
| | | if(!window.isSecureContext||!navigator.mediaDevices?.getUserMedia)throw new Error('æå头éè¦ HTTPS åç¸æºææï¼è¯·ä½¿ç¨æå¨è¾å
¥'); |
| | | const decode=(await import('jsqr')).default; |
| | | const media=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:'environment'}},audio:false}); |
| | | if(ticket!==generation){media.getTracks().forEach(t=>t.stop());return;} |
| | | stream=media;active.value=true;starting.value=false; |
| | | await new Promise<void>(r=>requestAnimationFrame(()=>r())); |
| | | if(!video.value||ticket!==generation){stop();return;} |
| | | video.value.srcObject=media;await video.value.play(); |
| | | const canvas=document.createElement('canvas'),ctx=canvas.getContext('2d',{willReadFrequently:true}); |
| | | function tick(time:number){ |
| | | if(ticket!==generation||!active.value)return; |
| | | const v=video.value; |
| | | if(v&&ctx&&v.readyState>=2&&time-last>180&&!props.disabled){ |
| | | last=time;canvas.width=Math.min(v.videoWidth,640);canvas.height=Math.round(v.videoHeight*canvas.width/v.videoWidth); |
| | | ctx.drawImage(v,0,0,canvas.width,canvas.height); |
| | | const pixels=ctx.getImageData(0,0,canvas.width,canvas.height); |
| | | const result=decode(pixels.data,canvas.width,canvas.height); |
| | | if(result?.data){accept(result.data);return;} |
| | | } |
| | | frame=requestAnimationFrame(tick); |
| | | } |
| | | frame=requestAnimationFrame(tick); |
| | | }catch(e){ |
| | | if(ticket!==generation)return;stop(); |
| | | const name=e instanceof DOMException?e.name:''; |
| | | error.value=name==='NotAllowedError'?'ç¸æºæéæªå¼å¯ï¼è¯·å¨æµè§å¨è®¾ç½®ä¸å
è®¸ç¸æºï¼ææå¨è¾å
¥ç å¼': |
| | | name==='NotFoundError'?'æªæ¾å°å¯ç¨æå头ï¼è¯·æå¨è¾å
¥æä½¿ç¨æ«ç 设å¤': |
| | | name==='NotReadableError'?'æå头æ£è¢«å
¶ä»ç¨åºä½¿ç¨ï¼è¯·å
³éåéè¯ææå¨è¾å
¥': |
| | | e instanceof Error?e.message:'ç¸æºä¸å¯ç¨ï¼è¯·æ£æ¥æéææå¨è¾å
¥'; |
| | | } |
| | | } |
| | | function visibility(){if(document.hidden)stop();} |
| | | onMounted(()=>document.addEventListener('visibilitychange',visibility)); |
| | | onBeforeUnmount(()=>{stop();document.removeEventListener('visibilitychange',visibility);}); |
| | | onDeactivated(stop); |
| | | </script> |
| | | <template> |
| | | <div class="m-panel"> |
| | | <button v-if="!active" type="button" class="m-secondary" :disabled="disabled||starting" @click="start"><Icon name="scan"/>{{starting?'æ£å¨è¯·æ±ç¸æºæéâ¦':'æå¼æå头æ«ç '}}</button> |
| | | <template v-else><video ref="video" class="m-camera" muted playsinline aria-label="äºç»´ç 忝"/><button type="button" class="m-secondary" @click="stop">å
³éæå头</button></template> |
| | | <p v-if="error" class="m-error" role="alert">{{error}}</p> |
| | | <form @submit.prevent="accept(input)"> |
| | | <label class="m-field" style="margin-top:16px"><span>ç å¼ / 溯æºé¾æ¥</span><input v-model="input" autocomplete="off" :disabled="disabled" placeholder="æå¨è¾å
¥æä½¿ç¨æ«ç 设å¤" maxlength="2048" /></label> |
| | | <button class="m-secondary" :disabled="disabled||!input.trim()" type="submit">{{disabled?'æ£å¨å¤çâ¦':'è¯å«ç å¼'}}</button> |
| | | </form> |
| | | </div> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { onMounted,ref } from 'vue'; |
| | | import { requestClient } from '#/api/request'; |
| | | import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock'; |
| | | import { errorText } from '#/mobile/api'; |
| | | import { getWarehouseSimpleList, type MesWmWarehouseApi } from '#/api/mes/wm/warehouse'; |
| | | import { getWarehouseLocationSimpleList, type MesWmWarehouseLocationApi } from '#/api/mes/wm/warehouse/location'; |
| | | import { getWarehouseAreaSimpleList, type MesWmWarehouseAreaApi } from '#/api/mes/wm/warehouse/area'; |
| | | import Picker from './picker.vue'; |
| | | defineOptions({name:'MobileStock'}); |
| | | const positionLoading=ref(false); |
| | | let positionRequest=0; |
| | | const warehouses=ref<MesWmWarehouseApi.Warehouse[]>([]),locations=ref<MesWmWarehouseLocationApi.WarehouseLocation[]>([]),areas=ref<MesWmWarehouseAreaApi.WarehouseArea[]>([]); |
| | | const warehouseId=ref<number>(),locationId=ref<number>(),areaId=ref<number>(); |
| | | async function changeWarehouse(){ |
| | | const ticket=++positionRequest,id=warehouseId.value;locationId.value=undefined;areaId.value=undefined;locations.value=[];areas.value=[];positionLoading.value=!!id; |
| | | try{if(id){const list=await getWarehouseLocationSimpleList(id);if(ticket===positionRequest)locations.value=list;}} |
| | | catch(e){if(ticket===positionRequest)error.value=errorText(e);}finally{if(ticket===positionRequest)positionLoading.value=false;} |
| | | } |
| | | async function changeLocation(){ |
| | | const ticket=++positionRequest,id=locationId.value;areaId.value=undefined;areas.value=[];positionLoading.value=!!id; |
| | | try{if(id){const list=await getWarehouseAreaSimpleList(id);if(ticket===positionRequest)areas.value=list;}} |
| | | catch(e){if(ticket===positionRequest)error.value=errorText(e);}finally{if(ticket===positionRequest)positionLoading.value=false;} |
| | | } |
| | | const keyword=ref(''),batch=ref(''),page=ref(1),busy=ref(false),error=ref(''),total=ref(0),rows=ref<MesWmMaterialStockApi.MaterialStock[]>([]),expanded=ref<number>(); |
| | | type Filters={itemName?:string;batchCode?:string;warehouseId?:number;locationId?:number;areaId?:number}; |
| | | let applied:Filters={}; |
| | | async function load(reset=false){ |
| | | if(busy.value)return;busy.value=true;error.value='';const nextPage=reset?1:page.value+1; |
| | | const filters=reset?{itemName:keyword.value.trim()||undefined,batchCode:batch.value.trim()||undefined,warehouseId:warehouseId.value,locationId:locationId.value,areaId:areaId.value}:applied; |
| | | try{ |
| | | const r=await requestClient.get<{list:MesWmMaterialStockApi.MaterialStock[];total:number}>('/mes/wm/material-stock/page',{params:{pageNo:nextPage,pageSize:20,...filters}}); |
| | | rows.value=reset?r.list:[...rows.value,...r.list];total.value=r.total;page.value=nextPage; |
| | | applied=filters; |
| | | }catch(e){error.value=errorText(e);}finally{busy.value=false;} |
| | | } |
| | | onMounted(async()=>{void load(true);try{warehouses.value=await getWarehouseSimpleList();}catch(e){error.value=errorText(e);}}); |
| | | </script> |
| | | <template> |
| | | <form class="m-panel" @submit.prevent="load(true)"> |
| | | <label class="m-field"><span>åç§åç§°</span><input v-model="keyword" placeholder="æç´¢åç§"/></label> |
| | | <label class="m-field"><span>æ¹æ¬¡å·</span><input v-model="batch" placeholder="å¯éï¼è¾å
¥æ¹æ¬¡å·"/></label> |
| | | <details><summary class="m-link">æä»åº / åºä½çé</summary> |
| | | <Picker v-model="warehouseId" label="ä»åº" :options="warehouses" :disabled="busy" allow-empty placeholder="å
¨é¨ä»åº" @update:model-value="changeWarehouse"/> |
| | | <Picker v-model="locationId" label="åºåº" :options="locations" :disabled="busy||!warehouseId" :loading="positionLoading" allow-empty placeholder="å
¨é¨åºåº" @update:model-value="changeLocation"/> |
| | | <Picker v-model="areaId" label="åºä½" :options="areas" :disabled="busy||!locationId" :loading="positionLoading" allow-empty placeholder="å
¨é¨åºä½"/> |
| | | </details> |
| | | <button class="m-primary" :disabled="busy">{{busy?'æ£å¨æ¥è¯¢â¦':'æ¥è¯¢åºå'}}</button> |
| | | </form> |
| | | <p v-if="error" class="m-error" role="alert">{{error}}</p> |
| | | <p v-if="!rows.length&&!busy" class="m-empty">ææ å¹é
åºå</p> |
| | | <article v-for="row in rows" :key="row.id" class="m-panel"> |
| | | <h2 class="m-title">{{row.itemName}}</h2><p><strong>{{row.availableQuantity??row.quantity??0}}</strong> {{row.unitMeasureName}} <span class="m-muted">{{row.availableQuantity!=null?'å¯ç¨':'å¨åº'}}</span></p> |
| | | <p class="m-muted">{{row.warehouseName}} / {{row.locationName}} / {{row.areaName}}</p><p class="m-muted">æ¹æ¬¡ï¼{{row.batchCode||'æªå½å
¥'}}</p> |
| | | <button class="m-detail-toggle" :aria-expanded="expanded===row.id" @click="expanded=expanded===row.id?undefined:row.id"> {{expanded===row.id?'æ¶èµ·æç»':'æ¥çæ°éæç»'}}</button> |
| | | <dl v-if="expanded===row.id"><div class="m-row"><dt>å¨åºæ°é</dt><dd>{{row.quantity}} {{row.unitMeasureName}}</dd></div><div class="m-row"><dt>å»ç»æ°é</dt><dd>{{row.frozenQuantity??0}}</dd></div><div class="m-row"><dt>å ç¨æ°é</dt><dd>{{row.reservedQuantity??0}}</dd></div><div class="m-row"><dt>å
¥åºæ¶é´</dt><dd>{{row.receiptTime||'æªå½å
¥'}}</dd></div></dl> |
| | | </article> |
| | | <button v-if="rows.length<total" class="m-secondary" :disabled="busy" @click="load()">å è½½æ´å¤</button> |
| | | </template> |
| | |
| | | title: 'æ«ç æ¥æº', |
| | | minWidth: 110, |
| | | formatter: ({ row }) => |
| | | row.palletCodeId ? `æçç #${row.palletCodeId}` : row.bagCodeId ? `è¢ç #${row.bagCodeId}` : 'æå·¥æ£è´§', |
| | | row.bagCodeId ? `è¢ç #${row.bagCodeId}${row.palletCodeId ? ` / æç #${row.palletCodeId}` : ''}` : row.palletCodeId ? `æçç #${row.palletCodeId}` : 'æå·¥æ£è´§', |
| | | }, |
| | | { |
| | | field: 'warehouseName', |
| | |
| | | title: 'æ°é', |
| | | width: 100, |
| | | }, |
| | | { field: 'remark', title: 'è¢ç 䏿«ç æ¥æº', minWidth: 240 }, |
| | | ...(stockable |
| | | ? [ |
| | | { |
| | |
| | | label: $t('common.edit'), |
| | | type: 'link', |
| | | icon: ACTION_ICON.EDIT, |
| | | ifShow: !row.bagCodeId, |
| | | onClick: handleEdit.bind(null, row), |
| | | }, |
| | | { |
| | |
| | | createProductSales, |
| | | finishProductSales, |
| | | getProductSales, |
| | | previewProductSalesScan, |
| | | scanProductSales, |
| | | shippingProductSales, |
| | | stockProductSales, |
| | |
| | | |
| | | 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(''); // 表ååå§æ°æ®å¿«ç
§ï¼ç¨äºæäº¤æ¶è·³è¿æªåæ´çä¿åè¯·æ± |
| | |
| | | } |
| | | } |
| | | |
| | | /** æ«ç æ£è´§ï¼æ«ç æªé常以åè½¦ç»æè¾å
¥ */ |
| | | /** å
è¯å«å¯¹è±¡å¹¶éæ©è¢ç ï¼å车ä¸ä¼ç´æ¥åå
¥æ£è´§ã */ |
| | | 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; |
| | |
| | | } |
| | | }, |
| | | async onOpenChange(isOpen: boolean) { |
| | | scanPreview.value = undefined; |
| | | selectedBagIds.value = []; |
| | | scanContent.value = ''; |
| | | if (!isOpen) { |
| | | formData.value = undefined; |
| | | originalSnapshot.value = ''; |
| | |
| | | <Divider>ç©æä¿¡æ¯</Divider> |
| | | <div class="mx-4"> |
| | | <div v-if="isStock" class="mb-3 flex items-center gap-2 px-4"> |
| | | <label for="sales-picking-type" class="sr-only">æ£è´§æ«ç ç±»å</label> |
| | | <a-select id="sales-picking-type" v-model:value="scanType" :disabled="scanLoading" style="width:140px" @change="scanPreview = undefined; selectedBagIds = []"> |
| | | <a-select-option value="BAG">è¢ç </a-select-option><a-select-option value="PALLET">æçç </a-select-option><a-select-option value="BATCH">çäº§æ¹æ¬¡ç </a-select-option> |
| | | </a-select> |
| | | <a-input |
| | | v-model:value="scanContent" |
| | | :disabled="scanLoading" |
| | | autofocus |
| | | class="flex-1" |
| | | placeholder="è¯·æ«æè¢ç ææçç ï¼å车æäº¤" |
| | | placeholder="æ«ææéç±»åç ï¼å车è¯å«" |
| | | @press-enter="handleScan" |
| | | /> |
| | | <a-input-number |
| | | v-model:value="scanQuantity" |
| | | :disabled="scanLoading" |
| | | :min="0" |
| | | placeholder="æ°éï¼å¯éï¼" |
| | | style="width: 140px" |
| | | /> |
| | | <Button :loading="scanLoading" type="default" @click="handleScan"> |
| | | æ«ç |
| | | è¯å«ç å¼ |
| | | </Button> |
| | | </div> |
| | | <section v-if="isStock && scanPreview" class="mb-4 rounded-lg border border-border p-4"> |
| | | <h3 class="font-semibold">æ ¸å¯¹æ£è´§å¯¹è±¡</h3><p>æ¹æ¬¡ {{scanPreview.batchCode}} · 坿£ {{scanPreview.scanQuantity}} è¢</p> |
| | | <PickingBags v-if="scanPreview.scanType !== 'BAG'" v-model="selectedBagIds" :bags="scanPreview.bags || []" :disabled="scanLoading" /> |
| | | <p v-else>è¢ç {{scanPreview.bags?.[0]?.code}} · æ¬æ¬¡ {{selectedBagIds.length}} è¢</p> |
| | | <div class="mt-3 flex gap-2"><Button type="primary" :loading="scanLoading" :disabled="!selectedBagIds.length" @click="confirmScan">确认å å
¥æ£è´§</Button><Button :disabled="scanLoading" @click="scanPreview = undefined">åæ¶æ¬æ¬¡æ«ç </Button></div> |
| | | </section> |
| | | <LineList |
| | | :form-type="formType" |
| | | :notice-id="formData.noticeId" |
| | |
| | | > |
| | | <Button type="primary">æäº¤</Button> |
| | | </Popconfirm> |
| | | <Button v-if="isStock" type="primary" @click="handleStock"> |
| | | <Button v-if="isStock" type="primary" :disabled="scanLoading || !!scanPreview" @click="handleStock"> |
| | | æ§è¡æ£è´§ |
| | | </Button> |
| | | <Popconfirm |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, ref, watch } from 'vue'; |
| | | import type { MesWmProductSalesApi } from '#/api/mes/wm/productsales'; |
| | | |
| | | const props = defineProps<{ bags: MesWmProductSalesApi.PickingBag[]; modelValue: number[]; disabled?: boolean }>(); |
| | | const emit = defineEmits<{ 'update:modelValue': [ids: number[]] }>(); |
| | | const search = ref(''), desired = ref<number>(), limit = ref(50), selectionError = ref(''); |
| | | const eligible = computed(() => props.bags.filter(b => b.selectable)); |
| | | const filtered = computed(() => props.bags.filter(b => !search.value || b.code.includes(search.value.trim()) || b.businessCode?.includes(search.value.trim()))); |
| | | watch(search, () => { limit.value = 50; }); |
| | | watch(() => props.modelValue, ids => { desired.value = ids.length || undefined; }); |
| | | function choose(bag: MesWmProductSalesApi.PickingBag, checked: boolean) { |
| | | selectionError.value = ''; |
| | | emit('update:modelValue', checked ? [...props.modelValue, bag.id] : props.modelValue.filter(id => id !== bag.id)); |
| | | } |
| | | function chooseQuantity() { |
| | | const count = Number(desired.value); |
| | | if (!Number.isInteger(count) || count < 1 || count > eligible.value.length) { |
| | | selectionError.value = `请è¾å
¥ 1 è³ ${eligible.value.length} çæ´æ°è¢æ°`; return; |
| | | } |
| | | selectionError.value = ''; |
| | | emit('update:modelValue', eligible.value.slice(0, count).map(b => b.id)); |
| | | } |
| | | </script> |
| | | <template> |
| | | <fieldset class="pick-bags" :disabled="disabled"> |
| | | <legend>éæ©è¿½æº¯è¢ç </legend> |
| | | <p class="pick-summary" role="status">å¯é {{eligible.length}} è¢ Â· æ¬æ¬¡å·²é <strong>{{modelValue.length}}</strong> è¢</p> |
| | | <div class="pick-controls"> |
| | | <label>æ¬æ¬¡æ£è´§æ°é<input v-model="desired" type="number" min="1" :max="eligible.length" step="1" placeholder="è¾å
¥è¢æ°" /></label> |
| | | <button type="button" :disabled="!eligible.length" @click="chooseQuantity">ææ°ééæ©è¢ç </button> |
| | | <button type="button" :disabled="!modelValue.length" @click="emit('update:modelValue', [])">æ¸
ç©ºéæ©</button> |
| | | </div> |
| | | <p class="pick-help">ææ°é鿩尿¿æ¢å½åéæ©ï¼æè¢ç 顺åºéåå¯éè¢ãè¯·æ ¸å¯¹å®ç©ï¼æéè¢å¾éã</p> |
| | | <p v-if="selectionError" class="pick-error" role="alert">{{selectionError}}</p> |
| | | <label class="pick-search">æ¥æ¾è¢ç <input v-model="search" type="search" placeholder="è¾å
¥ä¸å¡ç æè¿½æº¯ç " /></label> |
| | | <div class="pick-list"> |
| | | <label v-for="bag in filtered.slice(0, limit)" :key="bag.id" class="pick-option" :class="{ 'pick-unavailable': !bag.selectable, 'pick-selected': modelValue.includes(bag.id) }"> |
| | | <input type="checkbox" :checked="modelValue.includes(bag.id)" :disabled="!bag.selectable" :aria-label="'éæ©è¢ç ' + bag.code" @change="choose(bag, ($event.target as HTMLInputElement).checked)" /> |
| | | <span><strong>{{bag.code}}</strong><small v-if="bag.businessCode">ä¸å¡ç {{bag.businessCode}}</small><small v-if="bag.unavailableReason">{{bag.unavailableReason}}</small></span> |
| | | </label> |
| | | <p v-if="!filtered.length" class="pick-help">没æå¹é
çè¢ç </p> |
| | | </div> |
| | | <button v-if="filtered.length > limit" type="button" @click="limit += 50">æ¾ç¤ºæ´å¤è¢ç ï¼{{limit}} / {{filtered.length}}ï¼</button> |
| | | <details v-if="modelValue.length"><summary>æ ¸å¯¹å
¨é¨å·²éè¢ç ï¼{{modelValue.length}} è¢ï¼</summary><p v-for="bag in bags.filter(b => modelValue.includes(b.id))" :key="bag.id" class="pick-code">{{bag.code}}<small v-if="bag.businessCode"> · {{bag.businessCode}}</small></p></details> |
| | | </fieldset> |
| | | </template> |
| | | <style scoped> |
| | | .pick-bags{border:0;padding:0;margin:16px 0;min-width:0;color:inherit}.pick-bags legend{font-weight:600}.pick-summary{margin:8px 0}.pick-controls{display:flex;align-items:flex-end;gap:8px;flex-wrap:wrap}.pick-controls label{flex:1;min-width:130px}.pick-bags label{font-weight:500}.pick-bags input[type=number],.pick-bags input[type=search]{display:block;width:100%;min-width:0;min-height:44px;border:1px solid #64748b;border-radius:6px;background:var(--background, white);color:inherit;padding:8px 10px;font:inherit}.pick-bags button{min-height:44px;padding:8px 12px;border:1px solid #64748b;border-radius:6px;color:#0369a1;background:var(--background, white);cursor:pointer}.pick-bags button:disabled{opacity:.5;cursor:default}.pick-help{font-size:14px;color:#475569;line-height:1.5;margin:10px 0}.pick-search{display:block;margin:12px 0}.pick-list{max-height:360px;overflow:auto;border-top:1px solid #e2e8f0}.pick-option{display:flex;align-items:center;gap:12px;min-height:64px;padding:10px 4px;border-bottom:1px solid #e2e8f0;cursor:pointer}.pick-option input{width:20px;height:20px;flex-shrink:0;accent-color:#0369a1}.pick-option span{min-width:0;overflow-wrap:anywhere}.pick-option strong{display:block;font-weight:500}.pick-option small{display:block;color:#475569;font-size:14px}.pick-selected{background:#f0f9ff;color:#0f172a}.pick-unavailable{cursor:default;background:#f8fafc;color:#475569}.pick-error{color:#b91c1c}.pick-code{overflow-wrap:anywhere;font-size:14px;padding:6px 0}.pick-bags summary{cursor:pointer;min-height:44px;padding:10px 0;color:#0369a1}.pick-bags :focus-visible{outline:2px solid #0369a1;outline-offset:2px} |
| | | </style> |