add:手持pda页面
This commit is contained in:
39
pda/pda/.gitignore
vendored
Normal file
39
pda/pda/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
38
pda/pda/README.md
Normal file
38
pda/pda/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# pda
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
bun install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
bun dev
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
bun run build
|
||||
```
|
||||
13
pda/pda/index.html
Normal file
13
pda/pda/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=750, maximum-scale=1.0, user-scalable=no">
|
||||
<title>诺力集成 WMS-PDA</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
pda/pda/jsconfig.json
Normal file
8
pda/pda/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
2499
pda/pda/package-lock.json
generated
Normal file
2499
pda/pda/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
pda/pda/package.json
Normal file
28
pda/pda/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "pda",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.17.0",
|
||||
"vant": "^4.9.24",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vant/auto-import-resolver": "^1.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-vue-devtools": "^8.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
BIN
pda/pda/public/favicon.ico
Normal file
BIN
pda/pda/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
12
pda/pda/src/App.vue
Normal file
12
pda/pda/src/App.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
import { provideI18n } from '@/i18n'
|
||||
|
||||
provideI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
122
pda/pda/src/assets/main.css
Normal file
122
pda/pda/src/assets/main.css
Normal file
@@ -0,0 +1,122 @@
|
||||
:root {
|
||||
--primary-color: #1989fa;
|
||||
--primary-gradient: linear-gradient(135deg, #1989fa 0%, #0d6efd 100%);
|
||||
--header-gradient: linear-gradient(135deg, #1989fa 0%, #2b6cb0 100%);
|
||||
--bg-color: #f5f5f5;
|
||||
--text-color: #333;
|
||||
--border-color: #e8e8e8;
|
||||
--font-size-lg: 18px;
|
||||
--font-size-md: 16px;
|
||||
--font-size-sm: 14px;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--text-color);
|
||||
background: var(--bg-color);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 750px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.van-nav-bar {
|
||||
background: var(--header-gradient) !important;
|
||||
}
|
||||
|
||||
.van-nav-bar__title {
|
||||
color: #fff !important;
|
||||
font-size: var(--font-size-lg) !important;
|
||||
}
|
||||
|
||||
.van-nav-bar__left .van-icon,
|
||||
.van-nav-bar__right .van-nav-bar__text {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.van-field__control {
|
||||
font-size: var(--font-size-md) !important;
|
||||
}
|
||||
|
||||
.van-button--primary {
|
||||
background: var(--primary-color) !important;
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-bottom {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.scan-icon {
|
||||
color: var(--primary-color);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.detail-table th {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 10px 6px;
|
||||
text-align: center;
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-table td {
|
||||
padding: 10px 6px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.detail-table tr:nth-child(even) td {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.detail-total {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
color: #999;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
23
pda/pda/src/components/BottomButton.vue
Normal file
23
pda/pda/src/components/BottomButton.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="page-bottom">
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
{{ text }}
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
text: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
defineEmits(['click'])
|
||||
</script>
|
||||
28
pda/pda/src/components/ScanField.vue
Normal file
28
pda/pda/src/components/ScanField.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<van-field
|
||||
v-model="modelValue"
|
||||
:label="label"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:rules="rules"
|
||||
:name="name"
|
||||
clearable
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon name="scan" class="scan-icon" @click="$emit('scan')" />
|
||||
</template>
|
||||
</van-field>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
required: { type: Boolean, default: false },
|
||||
rules: { type: Array, default: () => [] },
|
||||
name: { type: String, default: '' },
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue', 'scan'])
|
||||
</script>
|
||||
307
pda/pda/src/i18n/index.js
Normal file
307
pda/pda/src/i18n/index.js
Normal file
@@ -0,0 +1,307 @@
|
||||
import {reactive, provide, inject} from 'vue'
|
||||
|
||||
const zh = {
|
||||
login: {
|
||||
title: '诺力集成',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
usernamePlaceholder: '请输入用户名',
|
||||
passwordPlaceholder: '请输入密码',
|
||||
usernameRequired: '请输入用户名',
|
||||
passwordRequired: '请输入密码',
|
||||
login: '登录',
|
||||
langSwitch: 'EN',
|
||||
version: 'v22.11.07',
|
||||
loginFail: '用户名或密码错误',
|
||||
loginSuccess: '登录成功',
|
||||
},
|
||||
home: {
|
||||
greeting: '您好!',
|
||||
logout: '退出',
|
||||
menus: [
|
||||
'收货组盘',
|
||||
'AGV入库',
|
||||
'手工入库',
|
||||
'平库上架',
|
||||
'平库库存查询',
|
||||
'库位绑定/解绑',
|
||||
'拣选大厅',
|
||||
'无容器收货入库',
|
||||
'空托上架/下架/注册/呼叫',
|
||||
'AGV配送',
|
||||
'人工盘点',
|
||||
'备货组盘查询',
|
||||
],
|
||||
},
|
||||
receiveGroup: {
|
||||
title: '收货组盘',
|
||||
barcode: '条码信息',
|
||||
barcodePlaceholder: '请扫描或输入条码',
|
||||
barcodeRequired: '请输入条码',
|
||||
motherTray: '母托盘',
|
||||
motherTrayPlaceholder: '请扫描或输入母托盘',
|
||||
motherTrayRequired: '请输入母托盘',
|
||||
sectCode: '库区编码',
|
||||
total: '共{0}条',
|
||||
submit: '提交',
|
||||
submitSuccess: '提交成功',
|
||||
fetchFail: '获取入库单信息失败',
|
||||
noData: '暂无明细',
|
||||
skuCode: '物料编码',
|
||||
skuName: '物料名称',
|
||||
orderNo: '单据号',
|
||||
itemNo: '单据行号',
|
||||
qty: '数量',
|
||||
batchNo: '批次',
|
||||
categoryCode: '类别编码',
|
||||
categoryName: '类别名称',
|
||||
deleteConfirm: '确定删除该条明细?',
|
||||
},
|
||||
putaway: {
|
||||
title: '平库上架',
|
||||
barcode: '条码信息',
|
||||
barcodePlaceholder: '请扫描或输入条码信息',
|
||||
barcodeRequired: '请输入条码信息',
|
||||
location: '库位号',
|
||||
locationPlaceholder: '请扫描或输入库位号',
|
||||
locationRequired: '请输入库位号',
|
||||
sectCode: '库区编码',
|
||||
total: '共{0}条',
|
||||
putaway: '上架',
|
||||
putawaySuccess: '上架成功',
|
||||
fetchFail: '获取物料信息失败',
|
||||
noData: '暂无明细',
|
||||
skuCode: '物料编码',
|
||||
skuName: '物料名称',
|
||||
orderNo: '单据号',
|
||||
qty: '数量',
|
||||
batchNo: '批次',
|
||||
categoryName: '类别名称',
|
||||
deleteConfirm: '确定删除该条明细?',
|
||||
},
|
||||
inventory: {
|
||||
title: '平库库存查询',
|
||||
searchKey: '容器/库位号',
|
||||
searchKeyPlaceholder: '请扫描或输入容器/库位号',
|
||||
searchKeyRequired: '请输入容器/库位号',
|
||||
query: '查询',
|
||||
noData: '暂无数据',
|
||||
columns: {
|
||||
container: '容器号',
|
||||
location: '库位号',
|
||||
material: '物料编码',
|
||||
qty: '数量',
|
||||
},
|
||||
},
|
||||
developing: {
|
||||
title: '开发中',
|
||||
message: '该功能正在开发中,敬请期待!',
|
||||
},
|
||||
common: {
|
||||
back: '返回',
|
||||
scan: '扫描',
|
||||
confirm: '确定',
|
||||
cancel: '取消',
|
||||
},
|
||||
bindUnbind: {
|
||||
title: '库位绑定/解绑',
|
||||
},
|
||||
agvInbound: {
|
||||
title: 'AGV入库',
|
||||
vehicleCode: '托盘号',
|
||||
vehicleCodePlaceholder: '请扫描或输入托盘号',
|
||||
vehicleCodeRequired: '请输入托盘号',
|
||||
sectCode: '库位号',
|
||||
sectCodePlaceholder: '请扫描或输入库位号',
|
||||
sectCodeRequired: '请输入库位号',
|
||||
pointCode: '起始点位',
|
||||
pointCodePlaceholder: '请扫描或输入起始点位',
|
||||
pointCodeRequired: '请输入起始点位',
|
||||
putaway: '上架',
|
||||
putawaySuccess: '上架成功',
|
||||
},
|
||||
manualInbound: {
|
||||
title: '手工入库',
|
||||
vehicleCode: '托盘号',
|
||||
vehicleCodePlaceholder: '请扫描或输入托盘号',
|
||||
vehicleCodeRequired: '请输入托盘号',
|
||||
sectCode: '库位号',
|
||||
sectCodePlaceholder: '请扫描或输入库位号',
|
||||
sectCodeRequired: '请输入库位号',
|
||||
submit: '提交',
|
||||
submitSuccess: '提交成功',
|
||||
},
|
||||
}
|
||||
|
||||
const en = {
|
||||
login: {
|
||||
title: 'ZDINT',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
usernamePlaceholder: 'Enter username',
|
||||
passwordPlaceholder: 'Enter password',
|
||||
usernameRequired: 'Username is required',
|
||||
passwordRequired: 'Password is required',
|
||||
login: 'Login',
|
||||
langSwitch: '中文',
|
||||
version: 'v22.11.07',
|
||||
loginFail: 'Invalid username or password',
|
||||
loginSuccess: 'Login successful',
|
||||
},
|
||||
home: {
|
||||
greeting: 'Hello! ',
|
||||
logout: 'Logout',
|
||||
menus: [
|
||||
'Receive Group',
|
||||
'AGV Inbound',
|
||||
'Manual Inbound',
|
||||
'Putaway',
|
||||
'Inventory Query',
|
||||
'Bind/Unbind',
|
||||
'Picking Hall',
|
||||
'Containerless Receive',
|
||||
'Empty Pallet Ops',
|
||||
'AGV Delivery',
|
||||
'Manual Inventory',
|
||||
'Staging Query',
|
||||
'Consolidation',
|
||||
],
|
||||
},
|
||||
receiveGroup: {
|
||||
title: 'Receive Group',
|
||||
barcode: 'Barcode',
|
||||
barcodePlaceholder: 'Scan or enter barcode',
|
||||
barcodeRequired: 'Barcode is required',
|
||||
motherTray: 'Mother Tray',
|
||||
motherTrayPlaceholder: 'Scan or enter mother tray',
|
||||
motherTrayRequired: 'Mother tray is required',
|
||||
sectCode: 'Sect Code',
|
||||
total: 'Total {0} items',
|
||||
submit: 'Submit',
|
||||
submitSuccess: 'Submitted successfully',
|
||||
fetchFail: 'Failed to fetch inbound info',
|
||||
noData: 'No details',
|
||||
skuCode: 'SKU Code',
|
||||
skuName: 'SKU Name',
|
||||
orderNo: 'Order No.',
|
||||
itemNo: 'Item No.',
|
||||
qty: 'Qty',
|
||||
batchNo: 'Batch No.',
|
||||
categoryCode: 'Category Code',
|
||||
categoryName: 'Category Name',
|
||||
deleteConfirm: 'Delete this item?',
|
||||
},
|
||||
putaway: {
|
||||
title: 'Putaway',
|
||||
barcode: 'Barcode',
|
||||
barcodePlaceholder: 'Scan or enter barcode',
|
||||
barcodeRequired: 'Barcode is required',
|
||||
location: 'Location',
|
||||
locationPlaceholder: 'Scan or enter location',
|
||||
locationRequired: 'Location is required',
|
||||
sectCode: 'Sect Code',
|
||||
total: 'Total {0} items',
|
||||
putaway: 'Putaway',
|
||||
putawaySuccess: 'Putaway successful',
|
||||
fetchFail: 'Failed to fetch material info',
|
||||
noData: 'No details',
|
||||
skuCode: 'SKU Code',
|
||||
skuName: 'SKU Name',
|
||||
orderNo: 'Order No.',
|
||||
qty: 'Qty',
|
||||
batchNo: 'Batch No.',
|
||||
categoryName: 'Category',
|
||||
deleteConfirm: 'Delete this item?',
|
||||
},
|
||||
inventory: {
|
||||
title: 'Inventory Query',
|
||||
searchKey: 'Container/Location',
|
||||
searchKeyPlaceholder: 'Scan or enter container/location',
|
||||
searchKeyRequired: 'Container/Location is required',
|
||||
query: 'Query',
|
||||
noData: 'No data',
|
||||
columns: {
|
||||
container: 'Container',
|
||||
location: 'Location',
|
||||
material: 'Material',
|
||||
qty: 'Qty',
|
||||
},
|
||||
},
|
||||
developing: {
|
||||
title: 'In Development',
|
||||
message: 'This feature is under development, stay tuned!',
|
||||
},
|
||||
common: {
|
||||
back: 'Back',
|
||||
scan: 'Scan',
|
||||
confirm: 'OK',
|
||||
cancel: 'Cancel',
|
||||
},
|
||||
bindUnbind: {
|
||||
title: 'Bind/Unbind Location',
|
||||
},
|
||||
agvInbound: {
|
||||
title: 'AGV Inbound',
|
||||
vehicleCode: 'Vehicle Code',
|
||||
vehicleCodePlaceholder: 'Scan or enter vehicle code',
|
||||
vehicleCodeRequired: 'Vehicle code is required',
|
||||
sectCode: 'Location',
|
||||
sectCodePlaceholder: 'Scan or enter location',
|
||||
sectCodeRequired: 'Location is required',
|
||||
pointCode: 'Start Point',
|
||||
pointCodePlaceholder: 'Scan or enter start point',
|
||||
pointCodeRequired: 'Start point is required',
|
||||
putaway: 'Putaway',
|
||||
putawaySuccess: 'Putaway successful',
|
||||
},
|
||||
manualInbound: {
|
||||
title: 'Manual Inbound',
|
||||
vehicleCode: 'Vehicle Code',
|
||||
vehicleCodePlaceholder: 'Scan or enter vehicle code',
|
||||
vehicleCodeRequired: 'Vehicle code is required',
|
||||
sectCode: 'Location',
|
||||
sectCodePlaceholder: 'Scan or enter location',
|
||||
sectCodeRequired: 'Location is required',
|
||||
submit: 'Submit',
|
||||
submitSuccess: 'Submitted successfully',
|
||||
},
|
||||
}
|
||||
|
||||
const messages = {zh, en}
|
||||
|
||||
const i18nState = reactive({
|
||||
locale: 'zh',
|
||||
})
|
||||
|
||||
function t(path, ...args) {
|
||||
const keys = path.split('.')
|
||||
let result = messages[i18nState.locale]
|
||||
for (const key of keys) {
|
||||
if (result == null) return path
|
||||
result = result[key]
|
||||
}
|
||||
if (typeof result === 'string' && args.length) {
|
||||
return result.replace(/\{(\d+)\}/g, (_, i) => args[i] ?? '')
|
||||
}
|
||||
return result ?? path
|
||||
}
|
||||
|
||||
function setLocale(locale) {
|
||||
i18nState.locale = locale
|
||||
}
|
||||
|
||||
function toggleLocale() {
|
||||
i18nState.locale = i18nState.locale === 'zh' ? 'en' : 'zh'
|
||||
}
|
||||
|
||||
const i18nKey = Symbol('i18n')
|
||||
|
||||
function provideI18n() {
|
||||
provide(i18nKey, {t, setLocale, toggleLocale, i18nState})
|
||||
}
|
||||
|
||||
function useI18n() {
|
||||
return inject(i18nKey, {t, setLocale, toggleLocale, i18nState})
|
||||
}
|
||||
|
||||
export {t, setLocale, toggleLocale, provideI18n, useI18n, i18nState}
|
||||
15
pda/pda/src/main.js
Normal file
15
pda/pda/src/main.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import './assets/main.css'
|
||||
import 'vant/lib/index.css';
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
import { Lazyload } from 'vant'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
app.use(Lazyload)
|
||||
|
||||
app.mount('#app')
|
||||
80
pda/pda/src/router/index.js
Normal file
80
pda/pda/src/router/index.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/Home.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/receive-group',
|
||||
name: 'ReceiveGroup',
|
||||
component: () => import('@/views/ReceiveGroup.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/putaway',
|
||||
name: 'Putaway',
|
||||
component: () => import('@/views/Putaway.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/inventory',
|
||||
name: 'InventoryQuery',
|
||||
component: () => import('@/views/InventoryQuery.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/bind-unbind',
|
||||
name: 'BindUnbind',
|
||||
component: () => import('@/views/BindUnbind.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/agv-inbound',
|
||||
name: 'AgvInbound',
|
||||
component: () => import('@/views/AgvInbound.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/manual-inbound',
|
||||
name: 'ManualInbound',
|
||||
component: () => import('@/views/ManualInbound.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/developing',
|
||||
name: 'Developing',
|
||||
component: () => import('@/views/Developing.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/login',
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && token) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
40
pda/pda/src/utils/request.js
Normal file
40
pda/pda/src/utils/request.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import axios from 'axios'
|
||||
import { showToast } from 'vant'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '',
|
||||
timeout: 20000,
|
||||
})
|
||||
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
)
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
const res = response.data
|
||||
if (res.success === false) {
|
||||
showToast({ message: res.message || '请求失败', type: 'fail' })
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
}
|
||||
return res
|
||||
},
|
||||
(error) => {
|
||||
const msg = error.response?.data?.message || error.message || '网络异常'
|
||||
showToast({ message: msg, type: 'fail' })
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default request
|
||||
107
pda/pda/src/views/AgvInbound.vue
Normal file
107
pda/pda/src/views/AgvInbound.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<van-nav-bar
|
||||
:title="t('agvInbound.title')"
|
||||
left-arrow
|
||||
@click-left="router.push('/')"
|
||||
/>
|
||||
<div class="page-content">
|
||||
<van-form ref="formRef">
|
||||
<van-field
|
||||
v-model="vehicleCode"
|
||||
:label="t('agvInbound.vehicleCode')"
|
||||
:placeholder="t('agvInbound.vehicleCodePlaceholder')"
|
||||
:rules="[{ required: true, message: t('agvInbound.vehicleCodeRequired') }]"
|
||||
name="vehicleCode"
|
||||
clearable
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon name="scan" class="scan-icon" @click="onScanVehicle" />
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-model="sectCode"
|
||||
:label="t('agvInbound.sectCode')"
|
||||
:placeholder="t('agvInbound.sectCodePlaceholder')"
|
||||
:rules="[{ required: true, message: t('agvInbound.sectCodeRequired') }]"
|
||||
name="sectCode"
|
||||
clearable
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon name="scan" class="scan-icon" @click="onScanSect" />
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-model="pointCode"
|
||||
:label="t('agvInbound.pointCode')"
|
||||
:placeholder="t('agvInbound.pointCodePlaceholder')"
|
||||
:rules="[{ required: true, message: t('agvInbound.pointCodeRequired') }]"
|
||||
name="pointCode"
|
||||
clearable
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon name="scan" class="scan-icon" @click="onScanPoint" />
|
||||
</template>
|
||||
</van-field>
|
||||
</van-form>
|
||||
</div>
|
||||
<BottomButton :text="t('agvInbound.putaway')" :loading="submitting" @click="onPutaway" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import { useI18n } from '@/i18n'
|
||||
import BottomButton from '@/components/BottomButton.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const vehicleCode = ref('')
|
||||
const sectCode = ref('')
|
||||
const pointCode = ref('')
|
||||
const formRef = ref()
|
||||
const submitting = ref(false)
|
||||
|
||||
function onScanVehicle() {
|
||||
vehicleCode.value = 'VTP' + Date.now().toString().slice(-6)
|
||||
}
|
||||
|
||||
function onScanSect() {
|
||||
sectCode.value = 'BG' + String(Math.floor(Math.random() * 99) + 1).padStart(2, '0')
|
||||
}
|
||||
|
||||
function onScanPoint() {
|
||||
pointCode.value = 'P' + String(Math.floor(Math.random() * 999) + 1).padStart(3, '0')
|
||||
}
|
||||
|
||||
async function onPutaway() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await request.post('/api/pda/inbound/groupPlateInbound', {
|
||||
vehicle_code: vehicleCode.value,
|
||||
sect_code: sectCode.value,
|
||||
point_code: pointCode.value,
|
||||
})
|
||||
showToast({
|
||||
message: res.message || t('agvInbound.putawaySuccess'),
|
||||
type: 'success',
|
||||
})
|
||||
vehicleCode.value = ''
|
||||
sectCode.value = ''
|
||||
pointCode.value = ''
|
||||
} catch {
|
||||
// error already handled by interceptor
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
23
pda/pda/src/views/BindUnbind.vue
Normal file
23
pda/pda/src/views/BindUnbind.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<van-nav-bar
|
||||
:title="t('bindUnbind.title')"
|
||||
left-arrow
|
||||
@click-left="router.push('/')"
|
||||
/>
|
||||
<div class="page-content">
|
||||
<van-empty
|
||||
image="search"
|
||||
:description="t('developing.message')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
42
pda/pda/src/views/Developing.vue
Normal file
42
pda/pda/src/views/Developing.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<van-nav-bar
|
||||
:title="menuTitle"
|
||||
left-arrow
|
||||
@click-left="router.push('/')"
|
||||
/>
|
||||
<div class="developing-content">
|
||||
<van-empty
|
||||
image="search"
|
||||
:description="t('developing.message')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const menuTitle = ref(t('developing.title'))
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.title) {
|
||||
menuTitle.value = route.query.title
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.developing-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
129
pda/pda/src/views/Home.vue
Normal file
129
pda/pda/src/views/Home.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<div class="home-header">
|
||||
<div class="header-content">
|
||||
<div class="greeting">{{ t('home.greeting') }}{{ username }}</div>
|
||||
<div class="logout" @click="onLogout">{{ t('home.logout') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-menus">
|
||||
<van-grid :column-num="3" :border="false" :gutter="10">
|
||||
<van-grid-item
|
||||
v-for="(menu, index) in t('home.menus')"
|
||||
:key="index"
|
||||
:icon="menuIcons[index]"
|
||||
:text="menu"
|
||||
@click="onMenuClick(index)"
|
||||
/>
|
||||
</van-grid>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showDialog } from 'vant'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const username = ref(localStorage.getItem('username') || 'Admin')
|
||||
|
||||
const menuIcons = [
|
||||
'logistics',
|
||||
'gift-o',
|
||||
'edit',
|
||||
'shop-o',
|
||||
'search',
|
||||
'link-o',
|
||||
'gift-o',
|
||||
'bag-o',
|
||||
'apps-o',
|
||||
'records',
|
||||
'todo-list-o',
|
||||
'records',
|
||||
]
|
||||
|
||||
const menuRoutes = {
|
||||
0: '/receive-group',
|
||||
1: '/agv-inbound',
|
||||
2: '/manual-inbound',
|
||||
3: '/putaway',
|
||||
4: '/inventory',
|
||||
5: '/bind-unbind',
|
||||
}
|
||||
|
||||
function onMenuClick(index) {
|
||||
const path = menuRoutes[index]
|
||||
if (path) {
|
||||
router.push(path)
|
||||
} else {
|
||||
router.push('/developing')
|
||||
}
|
||||
}
|
||||
|
||||
function onLogout() {
|
||||
showDialog({
|
||||
title: t('home.logout'),
|
||||
message: t('common.confirm') + '?',
|
||||
showCancelButton: true,
|
||||
}).then(() => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('username')
|
||||
router.replace('/login')
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.home-header {
|
||||
background: var(--header-gradient);
|
||||
padding: 40px 20px 30px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logout {
|
||||
font-size: 14px;
|
||||
padding: 4px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.home-menus {
|
||||
padding: 16px 6px;
|
||||
}
|
||||
|
||||
.home-menus :deep(.van-grid-item__content) {
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.home-menus :deep(.van-grid-item__icon) {
|
||||
font-size: 38px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.home-menus :deep(.van-grid-item__text) {
|
||||
font-size: 22px;
|
||||
color: #333;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
96
pda/pda/src/views/InventoryQuery.vue
Normal file
96
pda/pda/src/views/InventoryQuery.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<van-nav-bar
|
||||
:title="t('inventory.title')"
|
||||
left-arrow
|
||||
@click-left="router.push('/')"
|
||||
/>
|
||||
<div class="page-content">
|
||||
<van-form ref="formRef">
|
||||
<van-field
|
||||
v-model="searchKey"
|
||||
:label="t('inventory.searchKey')"
|
||||
:placeholder="t('inventory.searchKeyPlaceholder')"
|
||||
:rules="[{ required: true, message: t('inventory.searchKeyRequired') }]"
|
||||
name="searchKey"
|
||||
clearable
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon name="scan" class="scan-icon" @click="onScan" />
|
||||
</template>
|
||||
</van-field>
|
||||
</van-form>
|
||||
<div class="detail-section">
|
||||
<table class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('inventory.columns.container') }}</th>
|
||||
<th>{{ t('inventory.columns.location') }}</th>
|
||||
<th>{{ t('inventory.columns.material') }}</th>
|
||||
<th>{{ t('inventory.columns.qty') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, idx) in resultList" :key="idx">
|
||||
<td>{{ item.container }}</td>
|
||||
<td>{{ item.location }}</td>
|
||||
<td>{{ item.material }}</td>
|
||||
<td>{{ item.qty }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!resultList.length" class="detail-total">{{ t('inventory.noData') }}</div>
|
||||
<div v-else class="detail-total">{{ t('receiveGroup.total', resultList.length) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<BottomButton :text="t('inventory.query')" @click="onQuery" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from '@/i18n'
|
||||
import BottomButton from '@/components/BottomButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const searchKey = ref('')
|
||||
const resultList = ref([])
|
||||
const formRef = ref()
|
||||
|
||||
function onScan() {
|
||||
searchKey.value = 'CT' + Date.now().toString().slice(-6)
|
||||
}
|
||||
|
||||
const mockData = [
|
||||
{ container: 'CT001234', location: 'A-01', material: 'MAT-1001', qty: 20 },
|
||||
{ container: 'CT001234', location: 'A-01', material: 'MAT-1002', qty: 15 },
|
||||
{ container: 'CT001235', location: 'B-03', material: 'MAT-2001', qty: 8 },
|
||||
{ container: 'CT001236', location: 'C-12', material: 'MAT-3001', qty: 50 },
|
||||
{ container: 'CT001237', location: 'D-05', material: 'MAT-4001', qty: 30 },
|
||||
]
|
||||
|
||||
async function onQuery() {
|
||||
try {
|
||||
await formRef.value?.validate('searchKey')
|
||||
resultList.value = mockData.filter((item) =>
|
||||
item.container.includes(searchKey.value) ||
|
||||
item.location.includes(searchKey.value)
|
||||
)
|
||||
if (!resultList.value.length) {
|
||||
resultList.value = mockData.slice(0, 2)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-section {
|
||||
margin-top: 12px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
136
pda/pda/src/views/Login.vue
Normal file
136
pda/pda/src/views/Login.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-lang" @click="toggleLocale">
|
||||
{{ t('login.langSwitch') }}
|
||||
</div>
|
||||
<div class="login-header">
|
||||
<div class="login-logo">
|
||||
<div class="logo-box">ZD</div>
|
||||
<div class="logo-text">{{ t('login.title') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-form">
|
||||
<van-form @submit="onLogin" ref="formRef">
|
||||
<van-field
|
||||
v-model="username"
|
||||
:label="t('login.username')"
|
||||
:placeholder="t('login.usernamePlaceholder')"
|
||||
:rules="[{ required: true, message: t('login.usernameRequired') }]"
|
||||
name="username"
|
||||
clearable
|
||||
/>
|
||||
<van-field
|
||||
v-model="password"
|
||||
:label="t('login.password')"
|
||||
:placeholder="t('login.passwordPlaceholder')"
|
||||
:rules="[{ required: true, message: t('login.passwordRequired') }]"
|
||||
name="password"
|
||||
type="password"
|
||||
clearable
|
||||
/>
|
||||
<div class="login-btn">
|
||||
<van-button type="primary" block round size="large" native-type="submit">
|
||||
{{ t('login.login') }}
|
||||
</van-button>
|
||||
</div>
|
||||
</van-form>
|
||||
</div>
|
||||
<div class="login-footer">{{ t('login.version') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const router = useRouter()
|
||||
const { t, toggleLocale } = useI18n()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
|
||||
function onLogin() {
|
||||
if (username.value === 'admin' && password.value === '123456') {
|
||||
localStorage.setItem('token', 'pda-token-mock')
|
||||
localStorage.setItem('username', 'admin')
|
||||
showToast({ message: t('login.loginSuccess'), type: 'success' })
|
||||
router.push('/')
|
||||
} else {
|
||||
showToast({ message: t('login.loginFail'), type: 'fail' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: linear-gradient(180deg, #1989fa 0%, #e8f4fd 40%, #f5f5f5 100%);
|
||||
padding: 0 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-lang {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
padding-top: 80px;
|
||||
padding-bottom: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-box {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 12px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #1989fa;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px 16px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: auto;
|
||||
padding: 24px 0;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
88
pda/pda/src/views/ManualInbound.vue
Normal file
88
pda/pda/src/views/ManualInbound.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<van-nav-bar
|
||||
:title="t('manualInbound.title')"
|
||||
left-arrow
|
||||
@click-left="router.push('/')"
|
||||
/>
|
||||
<div class="page-content">
|
||||
<van-form ref="formRef">
|
||||
<van-field
|
||||
v-model="vehicleCode"
|
||||
:label="t('manualInbound.vehicleCode')"
|
||||
:placeholder="t('manualInbound.vehicleCodePlaceholder')"
|
||||
:rules="[{ required: true, message: t('manualInbound.vehicleCodeRequired') }]"
|
||||
name="vehicleCode"
|
||||
clearable
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon name="scan" class="scan-icon" @click="onScanVehicle" />
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-model="sectCode"
|
||||
:label="t('manualInbound.sectCode')"
|
||||
:placeholder="t('manualInbound.sectCodePlaceholder')"
|
||||
:rules="[{ required: true, message: t('manualInbound.sectCodeRequired') }]"
|
||||
name="sectCode"
|
||||
clearable
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon name="scan" class="scan-icon" @click="onScanSect" />
|
||||
</template>
|
||||
</van-field>
|
||||
</van-form>
|
||||
</div>
|
||||
<BottomButton :text="t('manualInbound.submit')" :loading="submitting" @click="onSubmit" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import { useI18n } from '@/i18n'
|
||||
import BottomButton from '@/components/BottomButton.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const vehicleCode = ref('')
|
||||
const sectCode = ref('')
|
||||
const formRef = ref()
|
||||
const submitting = ref(false)
|
||||
|
||||
function onScanVehicle() {
|
||||
vehicleCode.value = 'VTP' + Date.now().toString().slice(-6)
|
||||
}
|
||||
|
||||
function onScanSect() {
|
||||
sectCode.value = 'BG' + String(Math.floor(Math.random() * 99) + 1).padStart(2, '0')
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await request.post('/api/pda/inbound/manualInbound', {
|
||||
vehicle_code: vehicleCode.value,
|
||||
struct_code: sectCode.value,
|
||||
})
|
||||
showToast({
|
||||
message: res.message || t('manualInbound.submitSuccess'),
|
||||
type: 'success',
|
||||
})
|
||||
vehicleCode.value = ''
|
||||
sectCode.value = ''
|
||||
} catch {
|
||||
// error already handled by interceptor
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
288
pda/pda/src/views/Putaway.vue
Normal file
288
pda/pda/src/views/Putaway.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<van-nav-bar
|
||||
:title="t('putaway.title')"
|
||||
left-arrow
|
||||
@click-left="router.push('/')"
|
||||
/>
|
||||
<div class="page-content">
|
||||
<van-form ref="formRef">
|
||||
<van-field
|
||||
v-model="barcode"
|
||||
:label="t('putaway.barcode')"
|
||||
:placeholder="t('putaway.barcodePlaceholder')"
|
||||
clearable
|
||||
@blur="onBarcodeBlur"
|
||||
@keyup.enter="onBarcodeBlur"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon name="scan" class="scan-icon" @click="onScanBarcode" />
|
||||
</template>
|
||||
</van-field>
|
||||
<!-- <van-field-->
|
||||
<!-- v-model="location"-->
|
||||
<!-- :label="t('putaway.location')"-->
|
||||
<!-- :placeholder="t('putaway.locationPlaceholder')"-->
|
||||
<!-- :rules="[{ required: true, message: t('putaway.locationRequired') }]"-->
|
||||
<!-- name="location"-->
|
||||
<!-- clearable-->
|
||||
<!-- >-->
|
||||
<!-- <template #right-icon>-->
|
||||
<!-- <van-icon name="scan" class="scan-icon" @click="onScanLocation" />-->
|
||||
<!-- </template>-->
|
||||
<!-- </van-field>-->
|
||||
<van-field
|
||||
v-model="sectCode"
|
||||
:label="t('putaway.sectCode')"
|
||||
readonly
|
||||
is-link
|
||||
/>
|
||||
</van-form>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-header">
|
||||
<span class="detail-header-text">{{ t('putaway.total', detailList.length) }}</span>
|
||||
</div>
|
||||
<div v-if="!detailList.length" class="detail-empty">
|
||||
<van-empty :description="t('putaway.noData')" image="search" />
|
||||
</div>
|
||||
<div v-else class="card-list">
|
||||
<div
|
||||
v-for="(item, idx) in detailList"
|
||||
:key="idx"
|
||||
class="detail-card"
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="card-index">#{{ idx + 1 }}</span>
|
||||
<van-icon
|
||||
name="delete-o"
|
||||
class="card-delete"
|
||||
@click="onDeleteItem(idx)"
|
||||
/>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ t('putaway.skuCode') }}</span>
|
||||
<span class="card-value">{{ item.sku_code }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ t('putaway.skuName') }}</span>
|
||||
<span class="card-value">{{ item.sku_name }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ t('putaway.orderNo') }}</span>
|
||||
<span class="card-value">{{ item.order_no }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ t('putaway.qty') }}</span>
|
||||
<span class="card-value card-value--num">{{ item.qty }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ t('putaway.batchNo') }}</span>
|
||||
<span class="card-value">{{ item.batch_no }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ t('putaway.categoryName') }}</span>
|
||||
<span class="card-value">{{ item.category_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BottomButton :text="t('putaway.putaway')" :loading="submitting" @click="onPutaway" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showDialog } from 'vant'
|
||||
import { useI18n } from '@/i18n'
|
||||
import BottomButton from '@/components/BottomButton.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const barcode = ref('')
|
||||
const location = ref('')
|
||||
const sectCode = ref('')
|
||||
const detailList = ref([])
|
||||
const formRef = ref()
|
||||
const submitting = ref(false)
|
||||
const billId = ref('')
|
||||
|
||||
async function fetchMaterialInfo(code) {
|
||||
if (!code) return
|
||||
try {
|
||||
const res = await request.get('/api/pda/inbound/getInfoByCode', {
|
||||
params: { code },
|
||||
})
|
||||
const data = res.data || []
|
||||
if (!data.length) {
|
||||
showToast({ message: t('putaway.fetchFail'), type: 'fail' })
|
||||
return
|
||||
}
|
||||
if (!billId.value && data[0].bill_id) {
|
||||
billId.value = data[0].bill_id
|
||||
}
|
||||
if (!sectCode.value && data[0].sect_code) {
|
||||
sectCode.value = data[0].sect_code
|
||||
}
|
||||
data.forEach((item) => {
|
||||
detailList.value.push({ ...item })
|
||||
})
|
||||
} catch {
|
||||
showToast({ message: t('putaway.fetchFail'), type: 'fail' })
|
||||
}
|
||||
}
|
||||
|
||||
function onBarcodeBlur() {
|
||||
const code = barcode.value.trim()
|
||||
if (!code) return
|
||||
fetchMaterialInfo(code)
|
||||
}
|
||||
|
||||
function onScanBarcode() {
|
||||
onBarcodeBlur()
|
||||
}
|
||||
|
||||
function onScanLocation() {
|
||||
location.value = 'LOC-' + String.fromCharCode(65 + Math.floor(Math.random() * 26)) + '-' + (Math.floor(Math.random() * 99) + 1)
|
||||
}
|
||||
|
||||
function onDeleteItem(idx) {
|
||||
showDialog({
|
||||
title: '',
|
||||
message: t('putaway.deleteConfirm'),
|
||||
showCancelButton: true,
|
||||
}).then(() => {
|
||||
detailList.value.splice(idx, 1)
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function onPutaway() {
|
||||
// try {
|
||||
// await formRef.value?.validate('location')
|
||||
// } catch {
|
||||
// return
|
||||
// }
|
||||
if (!detailList.value.length) {
|
||||
showToast({ message: t('putaway.noData'), type: 'fail' })
|
||||
return
|
||||
}
|
||||
const detailData = detailList.value.map((item) => ({
|
||||
order_no: item.order_no,
|
||||
sku_code: item.sku_code,
|
||||
batch_no: item.batch_no,
|
||||
qty: item.qty,
|
||||
}))
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await request.post('/api/pda/inbound/usualInbound', {
|
||||
bill_id: billId.value,
|
||||
sect_code: sectCode.value,
|
||||
detailList: detailData,
|
||||
})
|
||||
showToast({
|
||||
message: res.message || t('putaway.putawaySuccess'),
|
||||
type: 'success',
|
||||
})
|
||||
detailList.value = []
|
||||
billId.value = ''
|
||||
sectCode.value = ''
|
||||
barcode.value = ''
|
||||
location.value = ''
|
||||
} catch {
|
||||
// error already handled by interceptor
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-section {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.detail-header-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-empty {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--primary-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.card-index {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-delete {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.card-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.card-value--num {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
297
pda/pda/src/views/ReceiveGroup.vue
Normal file
297
pda/pda/src/views/ReceiveGroup.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<van-nav-bar
|
||||
:title="t('receiveGroup.title')"
|
||||
left-arrow
|
||||
@click-left="router.push('/')"
|
||||
/>
|
||||
<div class="page-content">
|
||||
<van-form ref="formRef">
|
||||
<van-field
|
||||
v-model="barcode"
|
||||
:label="t('receiveGroup.barcode')"
|
||||
:placeholder="t('receiveGroup.barcodePlaceholder')"
|
||||
clearable
|
||||
@blur="onBarcodeBlur"
|
||||
@keyup.enter="onBarcodeBlur"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon name="scan" class="scan-icon" @click="onScanBarcode" />
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-model="motherTray"
|
||||
:label="t('receiveGroup.motherTray')"
|
||||
:placeholder="t('receiveGroup.motherTrayPlaceholder')"
|
||||
:rules="[{ required: true, message: t('receiveGroup.motherTrayRequired') }]"
|
||||
name="motherTray"
|
||||
clearable
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon name="scan" class="scan-icon" @click="onScanMotherTray" />
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-model="sectCode"
|
||||
:label="t('receiveGroup.sectCode')"
|
||||
readonly
|
||||
is-link
|
||||
/>
|
||||
</van-form>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-header">
|
||||
<span class="detail-header-text">{{ t('receiveGroup.total', detailList.length) }}</span>
|
||||
</div>
|
||||
<div v-if="!detailList.length" class="detail-empty">
|
||||
<van-empty :description="t('receiveGroup.noData')" image="search" />
|
||||
</div>
|
||||
<div v-else class="card-list">
|
||||
<div
|
||||
v-for="(item, idx) in detailList"
|
||||
:key="idx"
|
||||
class="detail-card"
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="card-index">#{{ idx + 1 }}</span>
|
||||
<van-icon
|
||||
name="delete-o"
|
||||
class="card-delete"
|
||||
@click="onDeleteItem(idx)"
|
||||
/>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ t('receiveGroup.skuCode') }}</span>
|
||||
<span class="card-value">{{ item.sku_code }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ t('receiveGroup.skuName') }}</span>
|
||||
<span class="card-value">{{ item.sku_name }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ t('receiveGroup.orderNo') }}</span>
|
||||
<span class="card-value">{{ item.order_no }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ t('receiveGroup.itemNo') }}</span>
|
||||
<span class="card-value">{{ item.item_no }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ t('receiveGroup.qty') }}</span>
|
||||
<span class="card-value card-value--num">{{ item.qty }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ t('receiveGroup.batchNo') }}</span>
|
||||
<span class="card-value">{{ item.batch_no }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ t('receiveGroup.categoryCode') }}</span>
|
||||
<span class="card-value">{{ item.category_code }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ t('receiveGroup.categoryName') }}</span>
|
||||
<span class="card-value">{{ item.category_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BottomButton :text="t('receiveGroup.submit')" :loading="submitting" @click="onSubmit" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showDialog } from 'vant'
|
||||
import { useI18n } from '@/i18n'
|
||||
import BottomButton from '@/components/BottomButton.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const barcode = ref('')
|
||||
const motherTray = ref('')
|
||||
const sectCode = ref('')
|
||||
const detailList = ref([])
|
||||
const formRef = ref()
|
||||
const submitting = ref(false)
|
||||
const billId = ref('')
|
||||
|
||||
async function fetchInboundInfo(code) {
|
||||
if (!code) return
|
||||
try {
|
||||
const res = await request.get('/api/pda/inbound/getInfoByCode', {
|
||||
params: { code },
|
||||
})
|
||||
const data = res.data || []
|
||||
if (!data.length) {
|
||||
showToast({ message: t('receiveGroup.fetchFail'), type: 'fail' })
|
||||
return
|
||||
}
|
||||
if (!billId.value && data[0].bill_id) {
|
||||
billId.value = data[0].bill_id
|
||||
}
|
||||
if (!sectCode.value && data[0].sect_code) {
|
||||
sectCode.value = data[0].sect_code
|
||||
}
|
||||
data.forEach((item) => {
|
||||
detailList.value.push({ ...item })
|
||||
})
|
||||
} catch {
|
||||
showToast({ message: t('receiveGroup.fetchFail'), type: 'fail' })
|
||||
}
|
||||
}
|
||||
|
||||
function onBarcodeBlur() {
|
||||
const code = barcode.value.trim()
|
||||
if (!code) return
|
||||
fetchInboundInfo(code)
|
||||
}
|
||||
|
||||
function onScanBarcode() {
|
||||
onBarcodeBlur()
|
||||
}
|
||||
|
||||
function onScanMotherTray() {
|
||||
motherTray.value = 'TP' + Date.now().toString().slice(-6)
|
||||
}
|
||||
|
||||
function onDeleteItem(idx) {
|
||||
showDialog({
|
||||
title: '',
|
||||
message: t('receiveGroup.deleteConfirm'),
|
||||
showCancelButton: true,
|
||||
}).then(() => {
|
||||
detailList.value.splice(idx, 1)
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
try {
|
||||
await formRef.value?.validate('motherTray')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!detailList.value.length) {
|
||||
showToast({ message: t('receiveGroup.noData'), type: 'fail' })
|
||||
return
|
||||
}
|
||||
const detailData = detailList.value.map((item) => ({
|
||||
order_no: item.order_no,
|
||||
sku_code: item.sku_code,
|
||||
batch_no: item.batch_no,
|
||||
qty: item.qty,
|
||||
}))
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await request.post('/api/pda/inbound/groupPlate', {
|
||||
bill_id: billId.value,
|
||||
vehicle_code: motherTray.value,
|
||||
sect_code: sectCode.value,
|
||||
detailList: detailData,
|
||||
})
|
||||
showToast({
|
||||
message: res.message || t('receiveGroup.submitSuccess'),
|
||||
type: 'success',
|
||||
})
|
||||
detailList.value = []
|
||||
billId.value = ''
|
||||
sectCode.value = ''
|
||||
barcode.value = ''
|
||||
motherTray.value = ''
|
||||
} catch {
|
||||
// error already handled by interceptor
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-section {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.detail-header-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-empty {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--primary-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.card-index {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-delete {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.card-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.card-value--num {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
30
pda/pda/vite.config.js
Normal file
30
pda/pda/vite.config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { VantResolver } from '@vant/auto-import-resolver'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
Components({
|
||||
resolvers: [VantResolver()],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8011',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user