袁伟杰: V2.0.002 其他入库其他出库扫码

This commit is contained in:
yuan
2026-05-13 19:48:21 +08:00
parent 2c203ff4cf
commit 7f84bbca79
13 changed files with 1686 additions and 6 deletions

View File

@@ -88,6 +88,7 @@
"build:quickapp-webview": "uni build -p quickapp-webview",
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
"test": "vitest run --config vitest.config.ts",
"type-check": "vue-tsc --noEmit",
"init-husky": "git init && husky",
"init-baseFiles": "node ./scripts/create-base-files.js",
@@ -173,6 +174,7 @@
"unplugin-auto-import": "^20.0.0",
"vite": "5.2.8",
"vite-plugin-restart": "^1.0.0",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.6"
},
"pnpm": {

279
pnpm-lock.yaml generated
View File

@@ -135,7 +135,7 @@ importers:
version: 20.19.11
'@uni-helper/eslint-config':
specifier: 0.5.0
version: 0.5.0(@antfu/eslint-config@5.2.1(@unocss/eslint-plugin@66.5.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.22)(eslint-plugin-format@1.0.1(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.34.0(jiti@2.6.1))
version: 0.5.0(@antfu/eslint-config@5.2.1(@unocss/eslint-plugin@66.5.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.22)(eslint-plugin-format@1.0.1(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.11)(jsdom@16.7.0)(sass@1.77.8)(terser@5.43.1)))(eslint@9.34.0(jiti@2.6.1))
'@uni-helper/plugin-uni':
specifier: 0.1.0
version: 0.1.0(@dcloudio/vite-plugin-uni@3.0.0-4070620250821001(postcss@8.5.6)(rollup@4.50.0)(vite@5.2.8(@types/node@20.19.11)(sass@1.77.8)(terser@5.43.1))(vue@3.4.21(typescript@5.8.3)))
@@ -235,6 +235,9 @@ importers:
vite-plugin-restart:
specifier: ^1.0.0
version: 1.0.0(vite@5.2.8(@types/node@20.19.11)(sass@1.77.8)(terser@5.43.1))
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.11)(jsdom@16.7.0)(sass@1.77.8)(terser@5.43.1)
vue-tsc:
specifier: ^3.0.6
version: 3.0.6(typescript@5.8.3)
@@ -1937,12 +1940,18 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/conventional-commits-parser@5.0.1':
resolution: {integrity: sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==}
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -2345,6 +2354,35 @@ packages:
vitest:
optional: true
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/mocker@3.2.4':
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/runner@3.2.4':
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
'@vitest/snapshot@3.2.4':
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
'@volar/language-core@2.4.23':
resolution: {integrity: sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==}
@@ -2565,6 +2603,10 @@ packages:
array-timsort@1.0.3:
resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-kit@0.11.3:
resolution: {integrity: sha512-qdwwKEhckRk0XE22/xDdmU3v/60E8Edu4qFhgTLIhGGDs/PAJwLw9pQn8Rj99PitlbBZbYpx0k/lbir4kg0SuA==}
engines: {node: '>=16.14.0'}
@@ -2758,6 +2800,10 @@ packages:
centra@2.7.0:
resolution: {integrity: sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -2776,6 +2822,10 @@ packages:
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@@ -3045,6 +3095,10 @@ packages:
dedent@0.7.0:
resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -3478,6 +3532,10 @@ packages:
resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==}
engines: {node: '>= 0.8.0'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
expect@27.5.1:
resolution: {integrity: sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -4299,6 +4357,9 @@ packages:
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -4782,6 +4843,10 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
@@ -5210,6 +5275,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -5271,6 +5339,9 @@ packages:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@@ -5399,6 +5470,9 @@ packages:
timm@1.7.1:
resolution: {integrity: sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinycolor2@1.6.0:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
@@ -5416,6 +5490,18 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyspy@4.0.4:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
tmpl@1.0.5:
resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==}
@@ -5652,6 +5738,11 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite-plugin-restart@1.0.0:
resolution: {integrity: sha512-t2ktkTOa+DQX05TEZm/3FE0DyrYEyFXdhG5gLcta5p71zOpg9yG3DeRcHWJVLJgWNoaVtOr4fUlr1kKu+WfXyQ==}
peerDependencies:
@@ -5685,6 +5776,34 @@ packages:
terser:
optional: true
vitest@3.2.4:
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.4
'@vitest/ui': 3.2.4
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
@@ -5772,6 +5891,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@@ -5916,7 +6040,7 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.30
'@antfu/eslint-config@5.2.1(@unocss/eslint-plugin@66.5.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.22)(eslint-plugin-format@1.0.1(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3)':
'@antfu/eslint-config@5.2.1(@unocss/eslint-plugin@66.5.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.22)(eslint-plugin-format@1.0.1(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.11)(jsdom@16.7.0)(sass@1.77.8)(terser@5.43.1))':
dependencies:
'@antfu/install-pkg': 1.1.0
'@clack/prompts': 0.11.0
@@ -5925,7 +6049,7 @@ snapshots:
'@stylistic/eslint-plugin': 5.4.0(eslint@9.34.0(jiti@2.6.1))
'@typescript-eslint/eslint-plugin': 8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3)
'@typescript-eslint/parser': 8.46.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3)
'@vitest/eslint-plugin': 1.3.16(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3)
'@vitest/eslint-plugin': 1.3.16(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.11)(jsdom@16.7.0)(sass@1.77.8)(terser@5.43.1))
ansis: 4.2.0
cac: 6.7.14
eslint: 9.34.0(jiti@2.6.1)
@@ -8508,6 +8632,11 @@ snapshots:
dependencies:
'@babel/types': 7.28.4
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/conventional-commits-parser@5.0.1':
dependencies:
'@types/node': 20.19.11
@@ -8516,6 +8645,8 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
'@types/graceful-fs@4.1.9':
@@ -8711,9 +8842,9 @@ snapshots:
'@typescript-eslint/types': 8.46.0
eslint-visitor-keys: 4.2.1
'@uni-helper/eslint-config@0.5.0(@antfu/eslint-config@5.2.1(@unocss/eslint-plugin@66.5.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.22)(eslint-plugin-format@1.0.1(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.34.0(jiti@2.6.1))':
'@uni-helper/eslint-config@0.5.0(@antfu/eslint-config@5.2.1(@unocss/eslint-plugin@66.5.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.22)(eslint-plugin-format@1.0.1(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.11)(jsdom@16.7.0)(sass@1.77.8)(terser@5.43.1)))(eslint@9.34.0(jiti@2.6.1))':
dependencies:
'@antfu/eslint-config': 5.2.1(@unocss/eslint-plugin@66.5.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.22)(eslint-plugin-format@1.0.1(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3)
'@antfu/eslint-config': 5.2.1(@unocss/eslint-plugin@66.5.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.22)(eslint-plugin-format@1.0.1(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.11)(jsdom@16.7.0)(sass@1.77.8)(terser@5.43.1))
'@eslint/eslintrc': 3.3.1
eslint: 9.34.0(jiti@2.6.1)
eslint-flat-config-utils: 2.1.1
@@ -9108,16 +9239,59 @@ snapshots:
vite: 5.2.8(@types/node@20.19.11)(sass@1.77.8)(terser@5.43.1)
vue: 3.4.21(typescript@5.8.3)
'@vitest/eslint-plugin@1.3.16(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3)':
'@vitest/eslint-plugin@1.3.16(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.11)(jsdom@16.7.0)(sass@1.77.8)(terser@5.43.1))':
dependencies:
'@typescript-eslint/scope-manager': 8.46.0
'@typescript-eslint/utils': 8.46.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.8.3)
eslint: 9.34.0(jiti@2.6.1)
optionalDependencies:
typescript: 5.8.3
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.11)(jsdom@16.7.0)(sass@1.77.8)(terser@5.43.1)
transitivePeerDependencies:
- supports-color
'@vitest/expect@3.2.4':
dependencies:
'@types/chai': 5.2.3
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@5.2.8(@types/node@20.19.11)(sass@1.77.8)(terser@5.43.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.19
optionalDependencies:
vite: 5.2.8(@types/node@20.19.11)(sass@1.77.8)(terser@5.43.1)
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.4':
dependencies:
'@vitest/utils': 3.2.4
pathe: 2.0.3
strip-literal: 3.0.0
'@vitest/snapshot@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
magic-string: 0.30.19
pathe: 2.0.3
'@vitest/spy@3.2.4':
dependencies:
tinyspy: 4.0.4
'@vitest/utils@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
loupe: 3.2.1
tinyrainbow: 2.0.0
'@volar/language-core@2.4.23':
dependencies:
'@volar/source-map': 2.4.23
@@ -9383,6 +9557,8 @@ snapshots:
array-timsort@1.0.3: {}
assertion-error@2.0.1: {}
ast-kit@0.11.3(rollup@4.50.0):
dependencies:
'@babel/parser': 7.28.3
@@ -9633,6 +9809,14 @@ snapshots:
transitivePeerDependencies:
- debug
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -9646,6 +9830,8 @@ snapshots:
character-entities@2.0.2: {}
check-error@2.1.3: {}
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
@@ -9877,6 +10063,8 @@ snapshots:
dedent@0.7.0: {}
deep-eql@5.0.2: {}
deep-is@0.1.4: {}
deepmerge@4.3.1: {}
@@ -10381,6 +10569,8 @@ snapshots:
exit@0.1.2: {}
expect-type@1.3.0: {}
expect@27.5.1:
dependencies:
'@jest/types': 27.5.1
@@ -11427,6 +11617,8 @@ snapshots:
longest-streak@3.1.0: {}
loupe@3.2.1: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -12045,6 +12237,8 @@ snapshots:
pathe@2.0.3: {}
pathval@2.0.1: {}
perfect-debounce@1.0.0: {}
phin@2.9.3: {}
@@ -12475,6 +12669,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
@@ -12527,6 +12723,8 @@ snapshots:
dependencies:
escape-string-regexp: 2.0.0
stackback@0.0.2: {}
statuses@2.0.1: {}
std-env@3.9.0: {}
@@ -12645,6 +12843,8 @@ snapshots:
timm@1.7.1: {}
tinybench@2.9.0: {}
tinycolor2@1.6.0: {}
tinyexec@0.3.2: {}
@@ -12661,6 +12861,12 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tinypool@1.1.1: {}
tinyrainbow@2.0.0: {}
tinyspy@4.0.4: {}
tmpl@1.0.5: {}
to-regex-range@5.0.1:
@@ -12946,6 +13152,23 @@ snapshots:
vary@1.1.2: {}
vite-node@3.2.4(@types/node@20.19.11)(sass@1.77.8)(terser@5.43.1):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 5.2.8(@types/node@20.19.11)(sass@1.77.8)(terser@5.43.1)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- stylus
- sugarss
- supports-color
- terser
vite-plugin-restart@1.0.0(vite@5.2.8(@types/node@20.19.11)(sass@1.77.8)(terser@5.43.1)):
dependencies:
micromatch: 4.0.8
@@ -12962,6 +13185,45 @@ snapshots:
sass: 1.77.8
terser: 5.43.1
vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.11)(jsdom@16.7.0)(sass@1.77.8)(terser@5.43.1):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@5.2.8(@types/node@20.19.11)(sass@1.77.8)(terser@5.43.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.19
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.9.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 5.2.8(@types/node@20.19.11)(sass@1.77.8)(terser@5.43.1)
vite-node: 3.2.4(@types/node@20.19.11)(sass@1.77.8)(terser@5.43.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 20.19.11
jsdom: 16.7.0
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- stylus
- sugarss
- supports-color
- terser
vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.4.21(typescript@5.8.3)):
@@ -13047,6 +13309,11 @@ snapshots:
dependencies:
isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
word-wrap@1.2.5: {}
wot-design-uni@1.13.0(vue@3.4.21(typescript@5.8.3)):

View File

@@ -0,0 +1,12 @@
import { http } from '@/http/http'
/** 客户精简信息 */
export interface CustomerSimple {
id: number
name: string
}
/** 获取客户精简列表 */
export function getCustomerSimpleList() {
return http.get<CustomerSimple[]>('/erp/customer/simple-list')
}

View File

@@ -16,7 +16,23 @@ export interface ProductSimple {
weight?: number
}
/** 按条码查询的产品信息 */
export interface ProductByBarCode {
id: number
name: string
barCode?: string
unitId?: number
unitName?: string
standard?: string
minPrice?: number
}
/** 获取产品精简列表 */
export function getProductSimpleList() {
return http.get<ProductSimple[]>('/erp/product/simple-list')
}
/** 按条码查询启用中的产品 */
export function getProductByBarCode(barCode: string) {
return http.get<ProductByBarCode>('/erp/product/get-by-barcode', { barCode })
}

View File

@@ -0,0 +1,157 @@
<template>
<view class="item-list">
<view v-if="items.length === 0" class="item-list__empty">
暂无扫码明细
</view>
<view v-for="(item, index) in items" :key="`${item.warehouseId}-${item.productId}-${index}`" class="item-card">
<view class="item-card__header">
<text class="item-card__name">{{ item.productName }}</text>
<text class="item-card__remove" @tap="emit('remove', index)">删除</text>
</view>
<view class="item-card__meta">
<text>仓库{{ item.warehouseName || '-' }}</text>
<text>条码{{ item.productBarCode || '-' }}</text>
</view>
<view class="item-card__meta">
<text>规格{{ item.productSpec || '-' }}</text>
<text>单位{{ item.productUnitName || '-' }}</text>
</view>
<view class="item-card__meta">
<text>单价{{ formatNumber(item.productPrice) }}</text>
<text v-if="showStock">库存{{ formatNumber(item.stockCount) }}</text>
</view>
<view class="item-card__footer">
<view class="counter">
<text class="counter__button" @tap="emit('decrease', index)">-</text>
<text class="counter__value">{{ formatNumber(item.count) }}</text>
<text class="counter__button" @tap="emit('increase', index)">+</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
export interface ItemCardValue {
warehouseId: number
warehouseName?: string
productId: number
productName: string
productBarCode?: string
productSpec?: string
productUnitName?: string
productPrice?: number
stockCount?: number
count: number
}
withDefaults(
defineProps<{
items: ItemCardValue[]
showStock?: boolean
}>(),
{
showStock: false,
},
)
const emit = defineEmits<{
(event: 'increase', index: number): void
(event: 'decrease', index: number): void
(event: 'remove', index: number): void
}>()
/**
* 功能说明:格式化数值展示,避免 `undefined` 直接透出到页面。
* 适用场景:数量、库存、单价展示。
* @param value 原始数值
* @return 格式化后的字符串
* 注意事项:扫码页优先保证可读性,这里只保留最多 4 位小数,不做复杂金额格式化。
*/
function formatNumber(value?: number) {
if (value === undefined || value === null) {
return '-'
}
return Number(value).toFixed(Number.isInteger(Number(value)) ? 0 : 4)
}
</script>
<style lang="scss" scoped>
.item-list__empty {
padding: 28rpx;
border-radius: 16rpx;
background: #fff;
color: #86909c;
text-align: center;
}
.item-card {
margin-bottom: 20rpx;
padding: 24rpx;
border-radius: 16rpx;
background: #fff;
&__header,
&__meta,
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
}
&__header {
margin-bottom: 12rpx;
}
&__name {
font-size: 30rpx;
font-weight: 600;
}
&__remove {
color: #f53f3f;
font-size: 24rpx;
}
&__meta {
margin-top: 8rpx;
color: #4e5969;
font-size: 24rpx;
gap: 16rpx;
flex-wrap: wrap;
}
&__footer {
margin-top: 20rpx;
}
}
.counter {
display: flex;
align-items: center;
gap: 24rpx;
&__button,
&__value {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
border-radius: 12rpx;
background: #f2f3f5;
}
&__button {
font-size: 36rpx;
color: #1d2129;
}
&__value {
min-width: 88rpx;
background: #fff7e8;
color: #ad6800;
}
}
</style>

View File

@@ -0,0 +1,117 @@
<template>
<view class="scan-input">
<input
class="scan-input__field"
:value="modelValue"
:placeholder="placeholder"
:focus="autoFocus"
:disabled="disabled"
confirm-type="done"
@input="handleInput"
@confirm="handleSubmit"
>
<button class="scan-input__button scan-input__button--primary" size="mini" :disabled="disabled || loading" @tap="handleSubmit">
{{ loading ? '处理中' : '确认' }}
</button>
<button class="scan-input__button scan-input__button--camera" size="mini" :disabled="disabled || loading" @tap="handleCameraScan">
相机
</button>
</view>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
modelValue: string
placeholder?: string
disabled?: boolean
loading?: boolean
autoFocus?: boolean
}>(),
{
placeholder: '请扫描条码',
disabled: false,
loading: false,
autoFocus: true,
},
)
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'submit'): void
(event: 'camera-scan'): void
}>()
/**
* 功能说明:同步输入框内容到父组件。
* 适用场景:扫码枪键盘模拟输入、手工补录条码。
* @param event 输入事件
* @return 无
* 注意事项:扫码枪的原始值最终仍由父组件统一清洗,这里只做输入透传。
*/
function handleInput(event: Record<string, any>) {
emit('update:modelValue', event.detail.value || '')
}
/**
* 功能说明:把确认动作抛给父组件处理。
* 适用场景:扫码枪回车提交、用户手工点击确认按钮。
* @return 无
* 注意事项:父组件会负责防重入和业务校验,这里不重复做业务判断。
*/
function handleSubmit() {
if (props.disabled || props.loading) {
return
}
emit('submit')
}
/**
* 功能说明:触发父组件进入相机扫码流程。
* 适用场景:普通手机没有外接扫码枪时,通过摄像头扫描条码。
* @return 无
* 注意事项:组件层不直接执行业务逻辑,只负责抛出统一事件,保证扫码入口一致。
*/
function handleCameraScan() {
if (props.disabled || props.loading) {
return
}
emit('camera-scan')
}
</script>
<style lang="scss" scoped>
.scan-input {
display: flex;
align-items: center;
gap: 16rpx;
&__field {
flex: 1;
height: 84rpx;
padding: 0 24rpx;
border: 2rpx solid #d0d7de;
border-radius: 12rpx;
background: #fff;
}
&__button {
width: 128rpx;
height: 84rpx;
line-height: 84rpx;
border-radius: 12rpx;
&--primary {
background: #1677ff;
color: #fff;
}
&--camera {
background: #e8f3ff;
color: #1677ff;
}
}
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<view class="submit-bar">
<button class="submit-bar__ghost" @tap="emit('secondary')">
{{ secondaryText }}
</button>
<button class="submit-bar__primary" :loading="loading" @tap="emit('submit')">
{{ submitText }}
</button>
</view>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
loading?: boolean
submitText?: string
secondaryText?: string
}>(),
{
loading: false,
submitText: '提交单据',
secondaryText: '清空明细',
},
)
const emit = defineEmits<{
(event: 'submit'): void
(event: 'secondary'): void
}>()
</script>
<style lang="scss" scoped>
.submit-bar {
position: sticky;
bottom: 0;
display: flex;
gap: 16rpx;
padding: 20rpx 0 calc(20rpx + env(safe-area-inset-bottom));
background: #f4f6f8;
&__ghost,
&__primary {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 16rpx;
}
}
</style>

View File

@@ -0,0 +1,403 @@
<template>
<view class="yd-page-container scan-page">
<wd-navbar
title="扫码其它入库"
left-arrow
placeholder
safe-area-inset-top
fixed
@click-left="handleBack"
/>
<view class="scan-page__content">
<view class="scan-section">
<view class="scan-section__title">
单据信息
</view>
<picker :range="warehouses" range-key="name" :value="selectedWarehouseIndex" @change="handleWarehouseChange">
<view class="scan-field">
{{ currentWarehouse?.name || '请选择入库仓库' }}
</view>
</picker>
<picker :range="suppliers" range-key="name" :value="selectedSupplierIndex" @change="handleSupplierChange">
<view class="scan-field">
{{ currentSupplierName }}
</view>
</picker>
<input v-model="remark" class="scan-field" placeholder="请输入备注">
</view>
<view class="scan-section">
<view class="scan-section__title">
扫码录入
</view>
<ScanInput
v-model="scanCode"
placeholder="请先选择仓库,再扫描产品条码"
:loading="scanLoading"
@submit="handleScanSubmit"
@camera-scan="handleCameraScan"
/>
</view>
<view class="scan-section">
<view class="scan-section__title">
入库明细
</view>
<ItemList :items="items" @increase="increaseCount" @decrease="decreaseCount" @remove="removeItem" />
</view>
<SubmitBar :loading="submitting" submit-text="提交其它入库" @submit="handleSubmit" @secondary="clearItems" />
</view>
</view>
</template>
<script setup lang="ts">
import type { ProductByBarCode } from '@/api/erp/product'
import type { StockIn, StockInItem } from '@/api/erp/stock-in'
import type { SupplierSimple } from '@/api/erp/supplier'
import type { Warehouse } from '@/api/erp/warehouse'
import type { ItemCardValue } from '@/components/erp-scan/item-list.vue'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getProductByBarCode } from '@/api/erp/product'
import { createStockIn } from '@/api/erp/stock-in'
import { getSupplierSimpleList } from '@/api/erp/supplier'
import { getWarehouseSimpleList } from '@/api/erp/warehouse'
import ItemList from '@/components/erp-scan/item-list.vue'
import ScanInput from '@/components/erp-scan/scan-input.vue'
import SubmitBar from '@/components/erp-scan/submit-bar.vue'
import { navigateBackPlus } from '@/utils'
import { buildScanMergeKey, normalizeCameraScanResult, normalizeScanCode } from '@/utils/scan'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
interface StockInScanItem extends ItemCardValue {
remark?: string
}
const toast = useToast()
const scanCode = ref('')
const scanLoading = ref(false)
const submitting = ref(false)
const remark = ref('')
const warehouses = ref<Warehouse[]>([])
const suppliers = ref<SupplierSimple[]>([])
const selectedWarehouseId = ref<number>()
const selectedSupplierId = ref<number>()
const items = ref<StockInScanItem[]>([])
const currentWarehouse = computed(() =>
warehouses.value.find(item => item.id === selectedWarehouseId.value),
)
const currentSupplierName = computed(() => suppliers.value.find(item => item.id === selectedSupplierId.value)?.name || '请选择供应商(可选)')
const selectedWarehouseIndex = computed(() => Math.max(warehouses.value.findIndex(item => item.id === selectedWarehouseId.value), 0))
const selectedSupplierIndex = computed(() => {
const index = suppliers.value.findIndex(item => item.id === selectedSupplierId.value)
return index < 0 ? 0 : index
})
/**
* 功能说明:返回上一页,若没有上一级页面则回到其它入库列表页。
* 适用场景:用户从快捷页返回库存业务列表。
* @return 无
* 注意事项:使用增强返回逻辑,避免页面栈为空时返回失败。
*/
function handleBack() {
navigateBackPlus('/pages-erp/stock-in/index')
}
/**
* 功能说明:加载扫码其它入库页依赖的基础下拉数据。
* 适用场景:页面首次进入时准备仓库和供应商数据。
* @return 无
* 注意事项:默认仓库优先取系统默认仓库,减少仓库人员开始作业前的额外点击。
*/
async function loadInitialData() {
const [warehouseList, supplierList] = await Promise.all([
getWarehouseSimpleList(),
getSupplierSimpleList(),
])
warehouses.value = warehouseList
suppliers.value = supplierList
selectedWarehouseId.value = warehouseList.find(item => item.defaultStatus)?.id || warehouseList[0]?.id
}
/**
* 功能说明:切换当前入库仓库。
* 适用场景:操作员选择本次扫码作业仓库。
* @param event picker 事件
* @return 无
* 注意事项:已录入明细不会被自动改仓,避免把历史扫码明细误迁到新仓库。
*/
function handleWarehouseChange(event: Record<string, any>) {
const warehouse = warehouses.value[Number(event.detail.value)]
selectedWarehouseId.value = warehouse?.id
}
/**
* 功能说明:切换当前供应商。
* 适用场景:其它入库单头维护可选供应商。
* @param event picker 事件
* @return 无
* 注意事项:供应商当前不是强校验项,因此仅在存在时传给后端。
*/
function handleSupplierChange(event: Record<string, any>) {
const supplier = suppliers.value[Number(event.detail.value)]
selectedSupplierId.value = supplier?.id
}
/**
* 功能说明:把扫码命中的产品合并到当前入库明细。
* 适用场景:扫码枪连续扫到同一产品时自动累计数量。
* @param product 条码命中的产品
* @return 无
* 注意事项:重复扫码按“仓库 + 产品”合并,避免同一产品刷出多行影响 PDA 作业效率。
*/
function appendScannedProduct(product: ProductByBarCode) {
const warehouseId = selectedWarehouseId.value as number
const targetKey = buildScanMergeKey({ warehouseId, productId: product.id, count: 1 })
const existedIndex = items.value.findIndex(item => buildScanMergeKey({
warehouseId: item.warehouseId,
productId: item.productId,
count: item.count,
}) === targetKey)
if (existedIndex >= 0) {
items.value[existedIndex].count += 1
return
}
items.value.unshift({
warehouseId,
warehouseName: currentWarehouse.value?.name,
productId: product.id,
productName: product.name,
productBarCode: product.barCode,
productSpec: product.standard,
productUnitName: product.unitName,
productPrice: Number(product.minPrice || 0),
count: 1,
})
}
/**
* 功能说明:处理扫码提交。
* 适用场景:扫码枪回车或用户点击确认按钮。
* @param sourceCode 可选的外部条码输入值
* @return 无
* 注意事项:未选择仓库时直接拦截,避免条码产品被录入到错误仓库上下文。
*/
async function handleScanSubmit(sourceCode?: string) {
if (scanLoading.value) {
return
}
if (!selectedWarehouseId.value) {
toast.error('请先选择仓库')
return
}
const barCode = normalizeScanCode(sourceCode ?? scanCode.value)
if (!barCode) {
toast.error('请先扫描条码')
return
}
scanLoading.value = true
try {
const product = await getProductByBarCode(barCode)
if (!product?.id) {
toast.error('未匹配到启用中的产品')
return
}
appendScannedProduct(product)
scanCode.value = ''
} finally {
scanLoading.value = false
}
}
/**
* 功能说明:触发相机扫码,并将结果接入现有扫码录入流程。
* 适用场景:普通手机没有外接扫码枪时,通过摄像头录入条码。
* @return 无
* 注意事项:扫码取消应静默返回,不应污染当前输入框内容或产生误报。
*/
function handleCameraScan() {
if (scanLoading.value) {
return
}
uni.scanCode({
onlyFromCamera: true,
success: async (result) => {
const barCode = normalizeCameraScanResult(result)
if (!barCode) {
return
}
scanCode.value = barCode
await handleScanSubmit(barCode)
},
fail: (error) => {
// 用户主动取消扫码不属于异常流程,这里静默返回,避免产生干扰提示。
if (String(error?.errMsg || '').includes('cancel')) {
return
}
toast.error('相机扫码失败,请重试')
},
})
}
/**
* 功能说明:增加指定明细数量。
* 适用场景:扫码后人工补正入库数量。
* @param index 明细下标
* @return 无
* 注意事项:其它入库不需要前端库存拦截,只做数量累加。
*/
function increaseCount(index: number) {
items.value[index].count += 1
}
/**
* 功能说明:减少指定明细数量。
* 适用场景:误扫后人工回退数量。
* @param index 明细下标
* @return 无
* 注意事项:数量最低保留 1避免留下 0 数量脏明细。
*/
function decreaseCount(index: number) {
if (items.value[index].count <= 1) {
return
}
items.value[index].count -= 1
}
/**
* 功能说明:删除指定明细。
* 适用场景:误扫错误产品后移除整行。
* @param index 明细下标
* @return 无
* 注意事项:这里只删除当前行,不联动其他已录入产品。
*/
function removeItem(index: number) {
items.value.splice(index, 1)
}
/**
* 功能说明:清空当前录入明细。
* 适用场景:整单作废后重新扫码。
* @return 无
* 注意事项:清空后保留当前仓库和供应商上下文,避免重新开始时重复选择。
*/
function clearItems() {
items.value = []
}
/**
* 功能说明:将页面明细转换成其它入库接口需要的明细结构。
* 适用场景:提交前组装请求体。
* @return 其它入库明细数组
* 注意事项:这里只保留后端真正需要的字段,避免把展示字段一并带入接口契约。
*/
function buildSubmitItems(): StockInItem[] {
return items.value.map(item => ({
warehouseId: item.warehouseId,
productId: item.productId,
productPrice: item.productPrice,
count: item.count,
remark: item.remark,
}))
}
/**
* 功能说明:提交扫码其它入库单。
* 适用场景:扫码录入完成后生成正式其它入库单。
* @return 无
* 注意事项:提交失败时保留当前录入数据,方便用户修正后重试。
*/
async function handleSubmit() {
if (submitting.value) {
return
}
if (items.value.length === 0) {
toast.error('请先扫描入库明细')
return
}
submitting.value = true
try {
const payload: StockIn = {
supplierId: selectedSupplierId.value,
inTime: new Date().toISOString(),
remark: remark.value,
items: buildSubmitItems(),
}
await createStockIn(payload)
toast.success('入库成功')
clearItems()
remark.value = ''
} finally {
submitting.value = false
}
}
/**
* 功能说明:初始化扫码其它入库页。
* 适用场景:页面首次挂载时准备基础数据。
* @return 无
* 注意事项:初始化失败时由请求封装统一提示,这里不额外重复 toast。
*/
onMounted(async () => {
await loadInitialData()
})
</script>
<style lang="scss" scoped>
.scan-page {
background: #f4f6f8;
&__content {
padding: 24rpx;
}
}
.scan-section {
margin-bottom: 24rpx;
padding: 24rpx;
border-radius: 20rpx;
background: #fff;
&__title {
margin-bottom: 20rpx;
font-size: 30rpx;
font-weight: 600;
color: #1f2329;
}
}
.scan-field {
height: 84rpx;
margin-bottom: 16rpx;
padding: 0 24rpx;
line-height: 84rpx;
border: 2rpx solid #e5e6eb;
border-radius: 12rpx;
background: #f7f8fa;
color: #1f2329;
}
</style>

View File

@@ -0,0 +1,455 @@
<template>
<view class="yd-page-container scan-page">
<wd-navbar
title="扫码其它出库"
left-arrow
placeholder
safe-area-inset-top
fixed
@click-left="handleBack"
/>
<view class="scan-page__content">
<view class="scan-section">
<view class="scan-section__title">
单据信息
</view>
<picker :range="warehouses" range-key="name" :value="selectedWarehouseIndex" @change="handleWarehouseChange">
<view class="scan-field">
{{ currentWarehouse?.name || '请选择出库仓库' }}
</view>
</picker>
<picker :range="customers" range-key="name" :value="selectedCustomerIndex" @change="handleCustomerChange">
<view class="scan-field">
{{ currentCustomerName }}
</view>
</picker>
<input v-model="remark" class="scan-field" placeholder="请输入备注">
</view>
<view class="scan-section">
<view class="scan-section__title">
扫码录入
</view>
<ScanInput
v-model="scanCode"
placeholder="请先选择仓库,再扫描产品条码"
:loading="scanLoading"
@submit="handleScanSubmit"
@camera-scan="handleCameraScan"
/>
</view>
<view class="scan-section">
<view class="scan-section__title">
出库明细
</view>
<ItemList :items="items" :show-stock="true" @increase="increaseCount" @decrease="decreaseCount" @remove="removeItem" />
</view>
<SubmitBar :loading="submitting" submit-text="提交其它出库" @submit="handleSubmit" @secondary="clearItems" />
</view>
</view>
</template>
<script setup lang="ts">
import type { CustomerSimple } from '@/api/erp/customer'
import type { ProductByBarCode } from '@/api/erp/product'
import type { Stock } from '@/api/erp/stock'
import type { StockOut, StockOutItem } from '@/api/erp/stock-out'
import type { Warehouse } from '@/api/erp/warehouse'
import type { ItemCardValue } from '@/components/erp-scan/item-list.vue'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { getCustomerSimpleList } from '@/api/erp/customer'
import { getProductByBarCode } from '@/api/erp/product'
import { getStock2 } from '@/api/erp/stock'
import { createStockOut } from '@/api/erp/stock-out'
import { getWarehouseSimpleList } from '@/api/erp/warehouse'
import ItemList from '@/components/erp-scan/item-list.vue'
import ScanInput from '@/components/erp-scan/scan-input.vue'
import SubmitBar from '@/components/erp-scan/submit-bar.vue'
import { navigateBackPlus } from '@/utils'
import { buildScanMergeKey, normalizeCameraScanResult, normalizeScanCode } from '@/utils/scan'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
interface StockOutScanItem extends ItemCardValue {
remark?: string
}
const toast = useToast()
const scanCode = ref('')
const scanLoading = ref(false)
const submitting = ref(false)
const remark = ref('')
const warehouses = ref<Warehouse[]>([])
const customers = ref<CustomerSimple[]>([])
const selectedWarehouseId = ref<number>()
const selectedCustomerId = ref<number>()
const items = ref<StockOutScanItem[]>([])
const currentWarehouse = computed(() =>
warehouses.value.find(item => item.id === selectedWarehouseId.value),
)
const currentCustomerName = computed(() => customers.value.find(item => item.id === selectedCustomerId.value)?.name || '请选择客户(可选)')
const selectedWarehouseIndex = computed(() => Math.max(warehouses.value.findIndex(item => item.id === selectedWarehouseId.value), 0))
const selectedCustomerIndex = computed(() => {
const index = customers.value.findIndex(item => item.id === selectedCustomerId.value)
return index < 0 ? 0 : index
})
/**
* 功能说明:返回上一页,若没有上一级页面则回到其它出库列表页。
* 适用场景:用户从快捷页返回库存业务列表。
* @return 无
* 注意事项:使用增强返回逻辑,避免页面栈为空时返回失败。
*/
function handleBack() {
navigateBackPlus('/pages-erp/stock-out/index')
}
/**
* 功能说明:加载扫码其它出库页依赖的基础下拉数据。
* 适用场景:页面首次进入时准备仓库和客户数据。
* @return 无
* 注意事项:默认仓库优先取系统默认仓库,减少开始作业前的额外点击。
*/
async function loadInitialData() {
const [warehouseList, customerList] = await Promise.all([
getWarehouseSimpleList(),
getCustomerSimpleList(),
])
warehouses.value = warehouseList
customers.value = customerList
selectedWarehouseId.value = warehouseList.find(item => item.defaultStatus)?.id || warehouseList[0]?.id
}
/**
* 功能说明:切换当前出库仓库。
* 适用场景:操作员选择本次扫码作业仓库。
* @param event picker 事件
* @return 无
* 注意事项:已录入明细不会被自动改仓,避免中途切仓导致历史扫码上下文错乱。
*/
function handleWarehouseChange(event: Record<string, any>) {
const warehouse = warehouses.value[Number(event.detail.value)]
selectedWarehouseId.value = warehouse?.id
}
/**
* 功能说明:切换当前客户。
* 适用场景:其它出库单头维护可选客户信息。
* @param event picker 事件
* @return 无
* 注意事项:客户当前不是必填项,但保留该字段便于后续业务追溯。
*/
function handleCustomerChange(event: Record<string, any>) {
const customer = customers.value[Number(event.detail.value)]
selectedCustomerId.value = customer?.id
}
/**
* 功能说明:按产品和仓库查询即时库存。
* 适用场景:扫码新增明细前或手工增加数量前进行库存校验。
* @param productId 产品编号
* @return 当前仓库下的库存信息
* 注意事项:出库页需要每次实时查询,避免沿用旧库存误放行。
*/
async function loadStockInfo(productId: number): Promise<Stock> {
return await getStock2(productId, selectedWarehouseId.value as number)
}
/**
* 功能说明:校验目标数量是否超过当前库存。
* 适用场景:扫码新增、重复扫码累计、手工增加数量。
* @param stockCount 当前库存数量
* @param nextCount 目标数量
* @param productName 产品名称
* @return 是否允许继续
* 注意事项:不在前端提前拦截的话,仓库人员可能连续扫码后才在提交时整体失败,体验会很差。
*/
function validateStock(stockCount: number, nextCount: number, productName: string): boolean {
if (Number(stockCount || 0) < nextCount) {
toast.error(`${productName} 库存不足`)
return false
}
return true
}
/**
* 功能说明:把扫码命中的产品合并到当前出库明细。
* 适用场景:扫码枪连续扫到同一产品时自动累计数量。
* @param product 条码命中的产品
* @return 无
* 注意事项:重复扫码前会先刷新库存并验证累计后数量,避免页面沿用旧库存误放行。
*/
async function appendScannedProduct(product: ProductByBarCode) {
const warehouseId = selectedWarehouseId.value as number
const targetKey = buildScanMergeKey({ warehouseId, productId: product.id, count: 1 })
const existedIndex = items.value.findIndex(item => buildScanMergeKey({
warehouseId: item.warehouseId,
productId: item.productId,
count: item.count,
}) === targetKey)
const stockInfo = await loadStockInfo(product.id)
const stockCount = Number(stockInfo?.count || 0)
if (existedIndex >= 0) {
const nextCount = items.value[existedIndex].count + 1
if (!validateStock(stockCount, nextCount, product.name)) {
return
}
items.value[existedIndex].count = nextCount
items.value[existedIndex].stockCount = stockCount
return
}
if (!validateStock(stockCount, 1, product.name)) {
return
}
items.value.unshift({
warehouseId,
warehouseName: currentWarehouse.value?.name,
productId: product.id,
productName: product.name,
productBarCode: product.barCode,
productSpec: product.standard,
productUnitName: product.unitName,
productPrice: Number(stockInfo?.unitPrice ?? product.minPrice ?? 0),
stockCount,
count: 1,
})
}
/**
* 功能说明:处理扫码提交。
* 适用场景:扫码枪回车或用户点击确认按钮。
* @param sourceCode 可选的外部条码输入值
* @return 无
* 注意事项:未选择仓库时直接拦截,避免库存查询与出库校验失去仓库上下文。
*/
async function handleScanSubmit(sourceCode?: string) {
if (scanLoading.value) {
return
}
if (!selectedWarehouseId.value) {
toast.error('请先选择仓库')
return
}
const barCode = normalizeScanCode(sourceCode ?? scanCode.value)
if (!barCode) {
toast.error('请先扫描条码')
return
}
scanLoading.value = true
try {
const product = await getProductByBarCode(barCode)
if (!product?.id) {
toast.error('未匹配到启用中的产品')
return
}
await appendScannedProduct(product)
scanCode.value = ''
} finally {
scanLoading.value = false
}
}
/**
* 功能说明:触发相机扫码,并将结果接入现有扫码录入流程。
* 适用场景:普通手机没有外接扫码枪时,通过摄像头录入条码。
* @return 无
* 注意事项:扫码取消应静默返回,避免把用户主动取消误判成系统错误。
*/
function handleCameraScan() {
if (scanLoading.value) {
return
}
uni.scanCode({
onlyFromCamera: true,
success: async (result) => {
const barCode = normalizeCameraScanResult(result)
if (!barCode) {
return
}
scanCode.value = barCode
await handleScanSubmit(barCode)
},
fail: (error) => {
// 用户主动取消扫码不属于异常流程,这里静默返回,避免产生干扰提示。
if (String(error?.errMsg || '').includes('cancel')) {
return
}
toast.error('相机扫码失败,请重试')
},
})
}
/**
* 功能说明:增加指定明细数量。
* 适用场景:扫码后人工补正出库数量。
* @param index 明细下标
* @return 无
* 注意事项:每次加数量前都重新校验库存,避免页面显示的库存已被其他单据消耗后继续放行。
*/
async function increaseCount(index: number) {
const item = items.value[index]
const stockInfo = await loadStockInfo(item.productId)
const nextCount = item.count + 1
if (!validateStock(Number(stockInfo?.count || 0), nextCount, item.productName)) {
return
}
item.stockCount = Number(stockInfo?.count || 0)
item.count = nextCount
}
/**
* 功能说明:减少指定明细数量。
* 适用场景:误扫后人工回退数量。
* @param index 明细下标
* @return 无
* 注意事项:数量最低保留 1避免出现 0 数量明细继续提交。
*/
function decreaseCount(index: number) {
if (items.value[index].count <= 1) {
return
}
items.value[index].count -= 1
}
/**
* 功能说明:删除指定明细。
* 适用场景:误扫错误产品后移除整行。
* @param index 明细下标
* @return 无
* 注意事项:这里只删除当前行,不联动其他产品数量和库存展示。
*/
function removeItem(index: number) {
items.value.splice(index, 1)
}
/**
* 功能说明:清空当前录入明细。
* 适用场景:整单作废后重新扫码。
* @return 无
* 注意事项:清空后保留当前仓库和客户上下文,便于同一作业场景继续录单。
*/
function clearItems() {
items.value = []
}
/**
* 功能说明:将页面明细转换成其它出库接口需要的明细结构。
* 适用场景:提交前组装请求体。
* @return 其它出库明细数组
* 注意事项:这里只保留后端真正需要的字段,避免把展示字段一并带入接口契约。
*/
function buildSubmitItems(): StockOutItem[] {
return items.value.map(item => ({
warehouseId: item.warehouseId,
productId: item.productId,
productPrice: item.productPrice,
count: item.count,
remark: item.remark,
}))
}
/**
* 功能说明:提交扫码其它出库单。
* 适用场景:扫码录入完成后生成正式其它出库单。
* @return 无
* 注意事项:前端库存校验只做即时提醒,最终正确性以后端校验结果为准。
*/
async function handleSubmit() {
if (submitting.value) {
return
}
if (items.value.length === 0) {
toast.error('请先扫描出库明细')
return
}
submitting.value = true
try {
const payload: StockOut = {
customerId: selectedCustomerId.value,
outTime: new Date().toISOString(),
remark: remark.value,
items: buildSubmitItems(),
}
await createStockOut(payload)
toast.success('出库成功')
clearItems()
remark.value = ''
} finally {
submitting.value = false
}
}
/**
* 功能说明:初始化扫码其它出库页。
* 适用场景:页面首次挂载时准备基础数据。
* @return 无
* 注意事项:初始化失败时由请求封装统一提示,这里不额外重复 toast。
*/
onMounted(async () => {
await loadInitialData()
})
</script>
<style lang="scss" scoped>
.scan-page {
background: #f4f6f8;
&__content {
padding: 24rpx;
}
}
.scan-section {
margin-bottom: 24rpx;
padding: 24rpx;
border-radius: 20rpx;
background: #fff;
&__title {
margin-bottom: 20rpx;
font-size: 30rpx;
font-weight: 600;
color: #1f2329;
}
}
.scan-field {
height: 84rpx;
margin-bottom: 16rpx;
padding: 0 24rpx;
line-height: 84rpx;
border: 2rpx solid #e5e6eb;
border-radius: 12rpx;
background: #f7f8fa;
color: #1f2329;
}
</style>

View File

@@ -146,6 +146,14 @@ const menuGroupsData: MenuGroup[] = [
iconColor: '#13c2c2',
permission: 'erp:stock-in:query',
},
{
key: 'stockInScan',
name: '扫码其它入库',
icon: 'scan',
url: '/pages-erp/stock-in/scan/index',
iconColor: '#36cfc9',
permission: 'erp:stock-in:query',
},
{
key: 'stockOut',
name: '其他出库',
@@ -154,6 +162,14 @@ const menuGroupsData: MenuGroup[] = [
iconColor: '#fa8c16',
permission: 'erp:stock-out:query',
},
{
key: 'stockOutScan',
name: '扫码其它出库',
icon: 'scan',
url: '/pages-erp/stock-out/scan/index',
iconColor: '#ff7a45',
permission: 'erp:stock-out:query',
},
{
key: 'stockGain',
name: '库存报溢',

View File

@@ -0,0 +1,81 @@
import type { ScanItem } from '../scan'
import { describe, expect, it } from 'vitest'
import {
buildScanMergeKey,
mergeScannedItem,
normalizeCameraScanResult,
normalizeScanCode,
} from '../scan'
/**
* 功能说明:构造测试明细数据,避免每个用例重复拼装字段。
* 适用场景:扫码合并工具函数单元测试。
* @param overrides 需要覆盖的字段
* @return 测试用明细对象
* 注意事项:测试数据默认模拟“自由扫码”场景,订单约束字段按需覆盖。
*/
function createItem(overrides: Partial<ScanItem> = {}): ScanItem {
return {
warehouseId: 1,
productId: 1001,
count: 1,
orderItemId: undefined,
...overrides,
}
}
describe('normalizeScanCode', () => {
it('应去掉回车换行和前后空格', () => {
expect(normalizeScanCode(' TEST-001\r\n')).toBe('TEST-001')
})
it('空值应返回空字符串', () => {
expect(normalizeScanCode('')).toBe('')
})
})
describe('normalizeCameraScanResult', () => {
it('应从手机扫码结果对象中提取并清洗条码', () => {
expect(normalizeCameraScanResult({ result: ' MOBILE-001\r\n' })).toBe('MOBILE-001')
})
it('扫码取消或结果为空时应返回空字符串', () => {
expect(normalizeCameraScanResult({ result: '' })).toBe('')
})
})
describe('buildScanMergeKey', () => {
it('自由扫码模式应按仓库和产品生成合并键', () => {
expect(buildScanMergeKey(createItem())).toBe('1:1001')
})
it('订单约束模式应额外带上订单明细编号', () => {
expect(buildScanMergeKey(createItem({ orderItemId: 5001 }), true)).toBe('1:1001:5001')
})
})
describe('mergeScannedItem', () => {
it('同仓库同产品重复扫码应累计数量', () => {
const merged = mergeScannedItem([createItem({ count: 2 })], createItem(), false)
expect(merged).toHaveLength(1)
expect(merged[0].count).toBe(3)
})
it('跨仓库扫码不应错误合并', () => {
const merged = mergeScannedItem([createItem({ warehouseId: 2 })], createItem(), false)
expect(merged).toHaveLength(2)
})
it('订单约束模式下不同订单行不应合并', () => {
const merged = mergeScannedItem(
[createItem({ orderItemId: 9001 })],
createItem({ orderItemId: 9002 }),
true,
)
expect(merged).toHaveLength(2)
})
})

85
src/utils/scan.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* 功能说明:定义扫码明细的最小结构,统一扫码页里的重复合并计算输入。
* 适用场景:扫码其它入库、扫码其它出库的条码累计逻辑。
*/
export interface ScanItem {
warehouseId: number
productId: number
count: number
orderItemId?: number
}
/**
* 功能说明:清洗扫码枪输入的条码文本,去掉回车、换行和前后空白。
* 适用场景:扫码枪通过键盘模拟输入后,页面提交前统一清洗条码值。
* @param raw 原始扫码内容
* @return 清洗后的条码字符串
* 注意事项:扫码枪通常会追加回车,不先清洗会导致条码查产品接口无法命中。
*/
export function normalizeScanCode(raw: string): string {
return (raw || '').replace(/[\r\n]/g, '').trim()
}
/**
* 功能说明:把相机扫码结果统一转换成业务可用条码。
* 适用场景:`uni.scanCode` 返回对象后,与扫码枪输入共用同一套处理流程。
* @param result uni-app 扫码返回结果
* @return 清洗后的条码字符串
* 注意事项:扫码取消、空返回或结构变化时,需要安全降级为空字符串,避免页面直接抛错。
*/
export function normalizeCameraScanResult(result?: { result?: string } | null): string {
return normalizeScanCode(result?.result || '')
}
/**
* 功能说明:生成扫码明细的合并键,决定重复扫码时应该累加到哪一行。
* 适用场景:自由扫码模式按“仓库 + 产品”合并,订单约束模式再附加订单行编号。
* @param item 当前明细
* @param withOrderConstraint 是否启用订单约束模式
* @return 合并键字符串
* 注意事项:订单约束场景若不带订单行编号,会把不同订单行的同一产品错误合并。
*/
export function buildScanMergeKey(item: ScanItem, withOrderConstraint = false): string {
const segments = [item.warehouseId, item.productId]
// 订单约束扫码时需要把订单明细编号带入合并键,避免不同订单行误合并。
if (withOrderConstraint) {
segments.push(item.orderItemId || 0)
}
return segments.join(':')
}
/**
* 功能说明:将一次新的扫码结果合并到现有明细列表中。
* 适用场景:连续扫到同一产品时自动累计数量,减少仓库人员手工修改次数。
* @param items 当前明细列表
* @param scannedItem 新扫码得到的明细
* @param withOrderConstraint 是否启用订单约束模式
* @return 合并后的新明细列表
* 注意事项:这里返回新数组而不是原地修改,避免页面状态更新不稳定。
*/
export function mergeScannedItem(
items: ScanItem[],
scannedItem: ScanItem,
withOrderConstraint = false,
): ScanItem[] {
const targetKey = buildScanMergeKey(scannedItem, withOrderConstraint)
const existedIndex = items.findIndex(item => buildScanMergeKey(item, withOrderConstraint) === targetKey)
if (existedIndex === -1) {
return [...items, scannedItem]
}
return items.map((item, index) => {
if (index !== existedIndex) {
return item
}
// 命中同一合并键时只累计数量,避免把已录入明细拆成多行,影响 PDA 连续扫码效率。
return {
...item,
count: Number(item.count || 0) + Number(scannedItem.count || 0),
}
})
}

20
vitest.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import path from 'node:path'
import { defineConfig } from 'vitest/config'
/**
* 功能说明:为纯工具函数测试提供独立的 Vitest 配置,避免 uni-app 构建插件影响测试执行。
* 适用场景:运行扫码工具、数据清洗、明细合并等不依赖页面渲染的单元测试。
* @return Vitest 配置对象
* 注意事项:当前仅解析 `@/` 别名并执行 `src/utils/__tests__` 下的测试,避免把页面编译链路引入测试环境。
*/
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
test: {
environment: 'node',
include: ['src/utils/__tests__/**/*.spec.ts'],
},
})