add:手持pda页面

This commit is contained in:
zhaoyf
2026-06-12 13:24:00 +08:00
parent d6fa944d8c
commit bd54c8057f
25 changed files with 4488 additions and 0 deletions

39
pda/pda/.gitignore vendored Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

12
pda/pda/src/App.vue Normal file
View 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
View 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);
}

View 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>

View 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
View 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
View 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')

View 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

View 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

View 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>

View 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>

View 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
View 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>

View 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
View 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>

View 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>

View 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>

View 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
View 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,
},
},
},
})