This commit is contained in:
2026-08-26 16:50:25 +08:00
parent 5868e774d4
commit 083299602d
31 changed files with 816 additions and 239 deletions
+2
View File
@@ -6,6 +6,8 @@ VITE_H5_OVERSEAS_API_BASE_URL=/overseas-api
# H5 开发代理上游;正式 App 构建请在本机环境文件中改为可访问的绝对 API 根地址。
API_PROXY_TARGET=http://127.0.0.1:9000
OVERSEAS_API_PROXY_TARGET=http://127.0.0.1:9300
# 海外 PDA 独立更新清单地址;不得与国内 PDA 发布记录混用。
VITE_PDA_UPDATE_URL=https://updates.example.com/pda/overseas/latest
# 仅开发服务器自动预填;请在不提交的 .env.local 中填写真实开发账号。
VITE_PDA_DEV_USERNAME=
VITE_PDA_DEV_PASSWORD=
+4 -3
View File
@@ -27,7 +27,7 @@
- 工作台定义:`src/data/overseas-workbench.js`。
- 登录与会话:`src/api/auth.js`、`src/stores/session.js`。
- 扫码设备层:`src/utils/scan-broadcast.js`;广播 Action=`android.intent.action.SCANRESULT`,Extra=`value`。
- 当前通用作业页中的入仓作业已接柜号级真实海外 API:账号锁柜、三类照片签收、FBA 按 SKU 与箱数收货、OVERSEAS 逐箱收货并逐入仓码核平;库内、换标与查询入口在海外 PDA 专用 Controller、权限和 Token 冻结仓库范围契约齐备前必须禁用,不得调用平台/客户/内部接口或模拟成功。
- 当前通用作业页中的入仓作业已接柜号级真实海外 API:账号锁柜、三类照片签收、统一箱码定位入仓码、FBA 按箱数收货、OVERSEAS 逐箱收货并逐入仓码核平;收货页不预列柜内全部入仓码,也不要求作业员扫描 SKU;库内、换标与查询入口在海外 PDA 专用 Controller、权限和 Token 冻结仓库范围契约齐备前必须禁用,不得调用平台/客户/内部接口或模拟成功。
## 页面与能力快速索引
@@ -36,7 +36,8 @@
| 页面注册与启动 | `src/pages.json`、`src/main.js`、`src/App.vue` | `src/manifest.json`、全局样式与应用生命周期 |
| AppID、平台图标与打包资源 | `src/manifest.json`、`static/app-icons/` | Android/iOS 尺寸映射由 `tests/app-icons.test.mjs` 锁定;独立 AppID、签名和发布通道不得复用国内 PDA |
| 登录、刷新与会话 | `src/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` | 国内 Auth 签发 Token;API 根见 `src/config/runtime.js` |
| 海外仓工作台与模块入口 | `src/pages/home/home.vue`、`src/pages/module/module.vue` | `src/data/overseas-workbench.js` 是模块、作业可用性和原型数据入口 |
| 海外仓工作台与模块入口 | `src/pages/home/home.vue`、`src/pages/module/module.vue` | `src/data/overseas-workbench.js` 是模块与作业可用性入口;首页通过 `/api/pda/v1/last-leg/receiving-containers/warehouse-profile` 读取 Token 绑定仓的中文主档名称 |
| 中英西多语言 | `src/i18n/index.js`、`src/i18n/locales/`、`src/pages/profile/profile.vue` | 语言仅允许 `zh-CN`、`en`、`es`,首次跟随设备且本地持久化;业务配置使用稳定 key,不以翻译后的名称驱动流程 |
| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 三个固定照片角色各一张,支持拍照/相册、鉴权预览及更换失败保留原图;Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` |
| 库内、托盘、换标与现场查询待接前端 | `src/data/overseas-workbench.js` 中对应 `available: false` 入口 | Backend 已有 `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/` 与 `overseas-pda-operations-v1.yaml`;本应用新增专用 API/状态恢复和测试前不得启用 |
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
@@ -45,7 +46,7 @@
## 海外业务接口边界
- `src/api/inbound.js` 已接入待卸柜候选、柜详情/prepare、current 恢复、三类照片签收、入仓码下 SKU/箱唛解析、实物托盘解析、FBA bulk/OVERSEAS carton 收货及整柜完成;响应与 404/409 判定集中在 `src/api/receiving-adapter.js`,双 Token 刷新与海外 401 单次重放由 `src/api/overseas.js` 统一处理。
- `src/api/inbound.js` 已接入待卸柜候选、柜详情/prepare、current 恢复、三类照片签收、统一箱码定位入仓码、实物托盘解析、FBA bulk/OVERSEAS carton 收货及整柜完成;FBA bulk 不提交 `skuCode`,库存分配由服务端按计划明细完成;响应与 404/409 判定集中在 `src/api/receiving-adapter.js`,双 Token 刷新与海外 401 单次重放由 `src/api/overseas.js` 统一处理。
- 请求身份和资源范围只取 Token 与 current 会话;前端不得提交 `customerId`、`planNo`、`allocationId`、`warehouseId` 或 `locationId`。409 必须引导账号恢复当前柜,完成前不得切柜。
- 签收照片固定为三个角色且每个角色只有一个当前文件;上传成功后使用服务端 `downloadPath` 在当前会话内鉴权读取。更换失败时保留原 `fileId` 和可提交状态,无原图的失败槽不得计入 `photosReady`;本地临时路径仅用于本次设备预览,不作为跨会话文件事实。
- 库内、换标与现场查询只有视觉原型依据,当前工作台入口保持禁用;新增真实作业必须建立 `/api/pda/**` 业务 API/DTO、`lastleg:pda:operate` 权限、Token 冻结仓库范围、错误态和契约测试,不得把平台、客户或内部接口直接暴露给 PDA。
+24 -1
View File
@@ -39,8 +39,31 @@ npm run build:app
- 已复用并内置:登录壳、PDA 样式、扫码广播、声音反馈、导航、网络检测、图标及 `uni_modules`。
- 已隔离:国内集装箱拣货、装柜证据、国内 Firstleg API、国内任务状态和国内本地草稿。
- 入仓页面已接待卸柜候选、柜详情与账号锁柜、三类角色照片签收、current 恢复、SKU/箱唛与既有物理托盘解析、FBA 按 SKU 和箱数收货、OVERSEAS 逐箱收货、逐入仓码核平及整柜完成的真实 API;409 会引导返回当前柜,错误会保留业务码、requestId 与 traceId。
- 入仓页面已接待卸柜候选、柜详情与账号锁柜、三类角色照片签收、current 恢复、统一箱码与既有物理托盘解析、FBA 按箱数和 OVERSEAS 逐箱收货、逐入仓码核平及整柜完成的真实 API;收货页不预列柜内入仓码,箱码解析成功后只显示本次命中的入仓码;409 会引导返回当前柜,错误会保留业务码、requestId 与 traceId。
- 入仓请求不手填客户、计划、分配、仓库或库位标识;身份与当前柜范围由 Token、Gateway 和服务端会话确定。页面同时支持 PDA 广播扫码与 H5 人工输入。
- 登录、恢复和刷新会 fail-closed 校验国内/海外双 Token 的同一平台操作主体、`WAREHOUSE_PICKER`、`lastleg:pda:operate` 及同一冻结海外仓;并发海外 401 共享一次国内 refresh,校验完成后原子替换双 Token并将原请求或照片上传重放一次。
- 库存盘点、商品移库、托盘管理、换标收货/执行/复核以及库存/库位/托盘查询当前缺少海外 PDA 专用 Controller、权限和 Token 冻结仓库范围契约,入口已禁用;不会调用平台、客户或内部接口,也不会模拟成功。
- 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。
## App 在线更新
Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独立更新通道;H5 不执行该逻辑。更新服务接收 `appid`、`platform`、`appVersion`、`appVersionCode`、`wgtVersion` 查询参数,无更新返回 `{ "update": false }`,有更新返回:
```json
{
"update": true,
"packageType": "wgt",
"versionName": "0.0.2",
"versionCode": 2,
"downloadUrl": "https://updates.example.com/pda/overseas/0.0.2.wgt",
"mandatory": true,
"releaseNotes": "本次更新内容"
}
```
`packageType` 只允许 `apk` 或 `wgt`。`mandatory=true` 时员工不能跳过,并在下载或安装失败后提供重试;wgt 安装完成会立即重启,APK 交由 Android 安装器完成覆盖安装。正式发布要求:
1. 海外 PDA 每次整包发布同时递增 `src/manifest.json` 的 `versionName` 与整数 `versionCode`,使用海外独立 AppID、包名和签名打包 APK。
2. wgt 发布至少递增 `versionName`,并使用与已安装原生包兼容的 uni-app 编译器生成;新增原生模块、权限或不兼容运行时能力时必须发布 APK,不能只发 wgt。
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
4. 海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
+9 -1
View File
@@ -1,5 +1,13 @@
<script>
export default {}
import { checkForAppUpdate } from '@/utils/app-update.js'
export default {
onLaunch() {
// #ifdef APP-PLUS
setTimeout(() => checkForAppUpdate(), 800)
// #endif
}
}
</script>
<style lang="scss">
+1 -1
View File
@@ -5,6 +5,7 @@ const RECEIVING_BASE = '/pda/v1/last-leg/receiving-containers'
const current = (suffix = '') => `${RECEIVING_BASE}/current${suffix}`
export const listReceivingCandidates = async () => (await overseasRequest('GET', `${RECEIVING_BASE}/candidates`)).map(normalizeReceivingCandidate)
export const getCurrentWarehouseProfile = () => overseasRequest('GET', `${RECEIVING_BASE}/warehouse-profile`)
export const getReceivingContainer = async (containerNo) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}`))
export const prepareReceivingContainer = async (containerNo, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare`, request))
export const uploadCurrentUnloadPhoto = (filePath) => overseasUpload(current('/unload-photos/files'), filePath)
@@ -12,7 +13,6 @@ export const downloadCurrentUnloadPhoto = (downloadPath) => overseasDownload(dow
export const signCurrentReceiving = async (request) => normalizeReceivingSession(await overseasRequest('POST', current('/sign'), request))
export const getCurrentReceiving = async () => normalizeReceivingSession(await overseasRequest('GET', current()))
export const resolveCurrentCarton = (cartonMark) => overseasRequest('POST', current('/cartons/resolve'), { cartonMark })
export const resolveCurrentSku = (skuCode) => overseasRequest('POST', current('/skus/resolve'), { skuCode })
export const resolveCurrentPallet = (palletCode, inboundCode) => overseasRequest('POST', current('/pallets/resolve'), { palletCode, inboundCode })
export const receiveCurrentBulk = (request) => overseasRequest('POST', current('/receipts/bulk'), request)
export const receiveCurrentCarton = (request) => overseasRequest('POST', current('/receipts/carton'), request)
+1 -1
View File
@@ -2,7 +2,7 @@
<view class="popup-header">
<view class="popup-header-spacer" aria-hidden="true"></view>
<text class="popup-header-title" :class="{ 'popup-header-title-danger': tone === 'danger' }">{{ title }}</text>
<button class="popup-header-close" :aria-label="`关闭${title}`" @tap="$emit('close')">
<button class="popup-header-close" :aria-label="`${$t('common.close')} ${title}`" @tap="$emit('close')">
<uni-icons type="closeempty" color="#52657c" size="24" />
</button>
</view>
+7 -7
View File
@@ -2,20 +2,20 @@
<view class="portal-header-shell">
<view class="portal-header">
<view class="portal-header-row">
<button v-if="menu" class="portal-header-button" aria-label="打开功能菜单" @tap="openMenu"><uni-icons type="bars" color="#ffffff" size="26" /></button>
<button v-else class="portal-header-button" aria-label="返回上一页" @tap="goBack"><uni-icons type="back" color="#ffffff" size="26" /></button>
<button v-if="menu" class="portal-header-button" :aria-label="$t('nav.openMenu')" @tap="openMenu"><uni-icons type="bars" color="#ffffff" size="26" /></button>
<button v-else class="portal-header-button" :aria-label="$t('nav.back')" @tap="goBack"><uni-icons type="back" color="#ffffff" size="26" /></button>
<text class="portal-title">{{ title }}</text>
<button v-if="showProfile" class="avatar-button" aria-label="打开个人中心" @tap="goProfile"><uni-icons type="person" color="#ffffff" size="27" /></button>
<button v-if="showProfile" class="avatar-button" :aria-label="$t('nav.profile')" @tap="goProfile"><uni-icons type="person" color="#ffffff" size="27" /></button>
<view v-else class="portal-header-spacer" aria-hidden="true"></view>
</view>
</view>
<uni-popup ref="menuPopup" type="bottom" background-color="#ffffff" border-radius="12px 12px 0 0" :is-mask-click="true" @change="handleMenuChange">
<view class="header-menu-sheet">
<view class="header-menu-content">
<PopupHeader title="功能菜单" @close="closeMenu" />
<button class="header-menu-item" @tap="goHistory"><uni-icons type="list" color="#334155" size="22" /><text>作业记录</text><uni-icons type="right" color="#94a3b8" size="18" /></button>
<button class="header-menu-item" @tap="goProfile"><uni-icons type="person" color="#334155" size="22" /><text>个人中心</text><uni-icons type="right" color="#94a3b8" size="18" /></button>
<button class="header-menu-close" @tap="closeMenu">关闭</button>
<PopupHeader :title="$t('nav.menu')" @close="closeMenu" />
<button class="header-menu-item" @tap="goHistory"><uni-icons type="list" color="#334155" size="22" /><text>{{ $t('nav.history') }}</text><uni-icons type="right" color="#94a3b8" size="18" /></button>
<button class="header-menu-item" @tap="goProfile"><uni-icons type="person" color="#334155" size="22" /><text>{{ $t('nav.profile') }}</text><uni-icons type="right" color="#94a3b8" size="18" /></button>
<button class="header-menu-close" @tap="closeMenu">{{ $t('common.close') }}</button>
</view>
</view>
</uni-popup>
@@ -0,0 +1,46 @@
<template>
<view class="receiving-summary">
<view class="receiving-summary-head">
<view><text class="receiving-summary-label">{{ $t('receivingSummary.container') }}</text><text class="receiving-summary-container">{{ session?.containerNo || '—' }}</text></view>
<text class="receiving-summary-status">{{ statusLabel }}</text>
</view>
<view class="receiving-summary-context">
<text>{{ $t('receivingSummary.warehouse') }}:{{ session?.overseasWarehouseName || '—' }}</text>
<text>{{ $t('receivingSummary.destinations') }}:{{ destinationSummary }}</text>
</view>
<view class="receiving-summary-metrics">
<view><text class="metric-value">{{ inboundCodeCount }}</text><text class="metric-label">{{ $t('receivingSummary.inboundCodes') }}</text></view>
<view><text class="metric-value">{{ plannedPalletCount }}</text><text class="metric-label">{{ $t('receivingSummary.plannedPallets') }}</text></view>
<view><text class="metric-value">{{ totalPackages }}</text><text class="metric-label">{{ $t('receivingSummary.plannedBoxes') }}</text></view>
<view><text class="metric-value metric-pending">{{ pendingPackages }}</text><text class="metric-label">{{ $t('receivingSummary.pendingBoxes') }}</text></view>
</view>
<view class="receiving-summary-progress"><view><text>{{ $t('operation.receivingProgress') }}</text><text>{{ receivedPackages }} / {{ totalPackages }} {{ $t('common.boxes') }}</text></view><view class="progress-track"><view class="progress-fill" :style="{ width: percent + '%' }"></view></view></view>
</view>
</template>
<script>
export default {
props: { session: { type: Object, default: null } },
computed: {
inboundCodeCount() { return Array.isArray(this.session?.inbounds) ? this.session.inbounds.length : 0 },
plannedPalletCount() { return (this.session?.inbounds || []).reduce((sum, item) => sum + Math.max(0, Number(item?.plannedPalletCount || 0)), 0) },
destinationSummary() { const values = (this.session?.inbounds || []).map((item) => item?.fbaWarehouseCode || item?.destinationWarehouseName || item?.destinationWarehouseCode).filter(Boolean); return [...new Set(values)].join(' / ') || '—' },
totalPackages() { return Math.max(0, Number(this.session?.totalPackages || 0)) },
receivedPackages() { return Math.max(0, Number(this.session?.receivedPackages || 0)) },
pendingPackages() { return Math.max(0, Number(this.session?.pendingPackages ?? this.totalPackages - this.receivedPackages)) },
percent() { return this.totalPackages ? Math.min(100, Math.round(this.receivedPackages / this.totalPackages * 100)) : 0 },
statusLabel() { return this.$t(({ WAIT_SIGN: 'operation.statusWait', SIGNED: 'operation.statusSigned', RECEIVING: 'operation.statusReceiving', COMPLETED: 'operation.statusCompleted' })[this.session?.status] || 'operation.statusWait') }
}
}
</script>
<style scoped>
.receiving-summary{box-sizing:border-box;margin-bottom:10px;padding:10px 12px;border:1px solid #b8d2f3;border-radius:13px;background:linear-gradient(145deg,#fff,#f2f7ff);box-shadow:0 3px 12px rgba(31,74,125,.08)}
.receiving-summary-head,.receiving-summary-progress>view:first-child{display:flex;align-items:center;justify-content:space-between;gap:10px}
.receiving-summary-label,.receiving-summary-container,.metric-value,.metric-label{display:block}.receiving-summary-label{color:#718095;font-size:9px;line-height:13px}.receiving-summary-container{margin-top:1px;color:#17324d;font-size:17px;font-weight:850;line-height:22px}
.receiving-summary-status{flex-shrink:0;padding:4px 8px;border-radius:99px;color:#1668dc;font-size:9px;font-weight:700;background:#e3efff}
.receiving-summary-context{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.35fr);gap:8px;margin-top:7px;color:#617389;font-size:9px;line-height:14px}.receiving-summary-context text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.receiving-summary-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:7px;padding:7px 2px;border-radius:9px;background:rgba(255,255,255,.8)}.receiving-summary-metrics>view{display:flex;align-items:center;flex-direction:column;border-right:1px solid #e3eaf2}.receiving-summary-metrics>view:last-child{border-right:0}
.metric-value{color:#17324d;font-size:17px;font-weight:850;line-height:21px}.metric-pending{color:#b66a08}.metric-label{margin-top:1px;color:#718095;font-size:8px;line-height:12px;text-align:center}
.receiving-summary-progress{margin-top:7px;color:#607287;font-size:9px}.progress-track{height:5px;margin-top:5px;overflow:hidden;border-radius:99px;background:#dfe8f2}.progress-fill{height:100%;border-radius:inherit;background:#1677e8}
</style>
+4 -4
View File
@@ -15,13 +15,13 @@
class="scan-target-value"
:value="modelValue"
:placeholder="placeholder"
:aria-label="`${label},支持扫码或人工输入`"
:aria-label="label"
confirm-type="done"
@focus="$emit('activate', name)"
@input="handleInput"
@confirm="handleConfirm"
/>
<text v-if="active" class="scan-target-badge">当前扫码</text>
<text v-if="active" class="scan-target-badge">{{ $t('scanInput.active') }}</text>
</view>
</template>
@@ -31,8 +31,8 @@
props: {
name: { type: String, required: true },
modelValue: { type: String, default: '' },
label: { type: String, default: '扫码输入框' },
placeholder: { type: String, default: '扫描或人工输入' },
label: { type: String, default: '' },
placeholder: { type: String, default: '' },
active: Boolean
},
methods: {
+14 -14
View File
@@ -1,21 +1,21 @@
export const MODULES = [
{ key: 'inbound', name: '入仓作业', icon: '入', description: '单账号锁定一柜,完成柜号级入库闭环', operations: [
{ name: '拆柜收货', icon: '卸', iconType: 'download', description: '三类照片签收,FBA 按 SKU 箱数、海外仓逐箱收货', available: true }
{ key: 'inbound', name: '入仓作业', nameKey: 'modules.inbound.name', icon: '入', description: '单账号锁定一柜,完成柜号级入库闭环', descriptionKey: 'modules.inbound.description', operations: [
{ key: 'receiving', name: '拆柜收货', nameKey: 'modules.receiving.name', icon: '卸', iconType: 'download', description: '三类照片签收,箱码定位入仓码后按目的类型收货', descriptionKey: 'modules.receiving.description', available: true }
] },
{ key: 'inventory', name: '库内作业', icon: '库', description: '盘点、移库与托盘管理', operations: [
{ name: '库存盘点', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', available: false },
{ name: '商品移库', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', available: false },
{ name: '托盘管理', icon: '托', iconType: 'grid', description: '组托、拆托及托盘状态核对', available: false }
{ key: 'inventory', name: '库内作业', nameKey: 'modules.inventory.name', icon: '库', description: '盘点、移库与托盘管理', descriptionKey: 'modules.inventory.description', operations: [
{ key: 'stocktake', name: '库存盘点', nameKey: 'modules.stocktake.name', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', descriptionKey: 'modules.stocktake.description', available: false },
{ key: 'move', name: '商品移库', nameKey: 'modules.move.name', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', descriptionKey: 'modules.move.description', available: false },
{ key: 'pallet', name: '托盘管理', nameKey: 'modules.pallet.name', icon: '托', iconType: 'map', description: '组托、拆托及托盘状态核对', descriptionKey: 'modules.pallet.description', available: false }
] },
{ key: 'relabel', name: '换标作业', icon: '换', description: '换标收货、执行与复核', operations: [
{ name: '换标收货', icon: '收', iconType: 'download', description: '扫描换标单并确认待处理库存', available: false },
{ name: '换标执行', icon: '换', iconType: 'compose', description: '扫描旧标和新标记录换标结果', available: false },
{ name: '换标复核', icon: '核', iconType: 'checkbox', description: '复核标签和商品关联关系', available: false }
{ key: 'relabel', name: '换标作业', nameKey: 'modules.relabel.name', icon: '换', description: '换标收货、执行与复核', descriptionKey: 'modules.relabel.description', operations: [
{ key: 'relabelReceive', name: '换标收货', nameKey: 'modules.relabelReceive.name', icon: '收', iconType: 'download', description: '扫描换标单并确认待处理库存', descriptionKey: 'modules.relabelReceive.description', available: false },
{ key: 'relabelExecute', name: '换标执行', nameKey: 'modules.relabelExecute.name', icon: '换', iconType: 'compose', description: '扫描旧标和新标记录换标结果', descriptionKey: 'modules.relabelExecute.description', available: false },
{ key: 'relabelReview', name: '换标复核', nameKey: 'modules.relabelReview.name', icon: '核', iconType: 'checkbox', description: '复核标签和商品关联关系', descriptionKey: 'modules.relabelReview.description', available: false }
] },
{ key: 'query', name: '现场查询', icon: '查', description: '库存、库位与托盘查询', operations: [
{ name: '库存查询', icon: '存', iconType: 'search', description: '扫描商品或箱码查询可用库存', available: false },
{ name: '库位查询', icon: '位', iconType: 'location', description: '扫描库位查看容量和在库明细', available: false },
{ name: '托盘查询', icon: '托', iconType: 'flag', description: '扫描托盘码查看绑定库存', available: false }
{ key: 'query', name: '现场查询', nameKey: 'modules.query.name', icon: '查', description: '库存、库位与托盘查询', descriptionKey: 'modules.query.description', operations: [
{ key: 'inventoryQuery', name: '库存查询', nameKey: 'modules.inventoryQuery.name', icon: '存', iconType: 'search', description: '扫描商品或箱码查询可用库存', descriptionKey: 'modules.inventoryQuery.description', available: false },
{ key: 'locationQuery', name: '库位查询', nameKey: 'modules.locationQuery.name', icon: '位', iconType: 'location', description: '扫描库位查看容量和在库明细', descriptionKey: 'modules.locationQuery.description', available: false },
{ key: 'palletQuery', name: '托盘查询', nameKey: 'modules.palletQuery.name', icon: '托', iconType: 'flag', description: '扫描托盘码查看绑定库存', descriptionKey: 'modules.palletQuery.description', available: false }
] }
]
+47
View File
@@ -0,0 +1,47 @@
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN.js'
import en from './locales/en.js'
import es from './locales/es.js'
const LOCALE_KEY = 'overseas-pda-locale'
export const SUPPORTED_LOCALES = ['zh-CN', 'en', 'es']
export const LOCALE_OPTIONS = [
{ value: 'zh-CN', labelKey: 'language.zh' },
{ value: 'en', labelKey: 'language.en' },
{ value: 'es', labelKey: 'language.es' }
]
function normalizeLocale(value) {
const locale = String(value || '').toLowerCase()
if (locale.startsWith('zh')) return 'zh-CN'
if (locale.startsWith('es')) return 'es'
if (locale.startsWith('en')) return 'en'
return 'en'
}
function initialLocale() {
if (typeof uni === 'undefined') return 'zh-CN'
const saved = typeof uni.getStorageSync === 'function' ? uni.getStorageSync(LOCALE_KEY) : ''
if (SUPPORTED_LOCALES.includes(saved)) return saved
return normalizeLocale(typeof uni.getLocale === 'function' ? uni.getLocale() : '')
}
export const i18n = createI18n({
legacy: false,
globalInjection: true,
locale: initialLocale(),
fallbackLocale: 'zh-CN',
messages: { 'zh-CN': zhCN, en, es }
})
export function currentLocale() { return i18n.global.locale.value }
export function t(key, params) { return i18n.global.t(key, params) }
export function setLocale(locale) {
const value = SUPPORTED_LOCALES.includes(locale) ? locale : normalizeLocale(locale)
i18n.global.locale.value = value
if (typeof uni !== 'undefined') {
if (typeof uni.setStorageSync === 'function') uni.setStorageSync(LOCALE_KEY, value)
if (typeof uni.setLocale === 'function') uni.setLocale(value)
}
return value
}
+18
View File
@@ -0,0 +1,18 @@
export default {
receivingSummary: { container: 'Current container', warehouse: 'Warehouse', destinations: 'Destinations', inboundCodes: 'Inbound codes', plannedPallets: 'Planned pallets', plannedBoxes: 'Planned cartons', pendingBoxes: 'Pending cartons' },
scanInput: { active: 'Scan target', label: 'Scan input', placeholder: 'Scan or enter manually' },
operationPhase: { candidates: { title: 'Containers awaiting unload', subtitle: 'Select a container and take 3 photos to sign it in' }, detail: { title: 'Container details', subtitle: 'Verify the container and total planned cartons' }, sign: { title: 'Photo sign-off', subtitle: 'Capture on-site proof before receiving' }, receive: { title: 'Current container', subtitle: 'Scan a carton to identify its inbound code' }, completed: { title: 'Inbound completed', subtitle: 'Inventory and container totals are reconciled by the server' } },
common: { close: 'Close', cancel: 'Cancel', confirm: 'Confirm', refresh: 'Refresh', back: 'Back', loading: 'Loading…', boxes: 'ctns', pallets: 'pallets', plans: 'plans', inboundCodes: 'inbound codes', available: 'Ready', unavailable: 'Unavailable', required: 'Required', uploaded: 'Uploaded', retry: 'Try again', currentWarehouse: 'Current warehouse', warehouseFallback: 'Assigned automatically to this account', warehouseOperator: 'Warehouse operator' },
app: { title: 'Baiyue Logistics Overseas', workbench: 'Overseas Warehouse', subtitle: 'Warehouse Operations System' },
login: { username: 'Employee ID / Username', usernamePlaceholder: 'Enter your employee ID', password: 'Password', passwordPlaceholder: 'Enter your password', submit: 'Sign in', submitting: 'Signing in...', usernameRequired: 'Enter your employee ID', passwordRequired: 'Enter your password', offline: 'No network connection. Connect and try again.', failed: 'Sign-in failed' },
nav: { menu: 'Menu', history: 'Work history', profile: 'Profile', openMenu: 'Open menu', back: 'Go back' },
home: { eyebrow: 'OVERSEAS WAREHOUSE', actionable: 'ready', night: 'Stay safe on the night shift', morning: 'Good morning', afternoon: 'Good afternoon', evening: 'Good evening' },
module: { title: 'Operations', functions: 'Functions', hint: 'Tap an available function to begin', availability: '{available}/{total} ready', onlyReceiving: 'Only container receiving is available. Other operations are not yet open.' },
modules: { inbound: { name: 'Inbound', description: 'Lock one container per account and complete receiving' }, inventory: { name: 'Warehouse', description: 'Stocktake, moves, and pallet operations' }, relabel: { name: 'Relabeling', description: 'Receive, execute, and review relabel work' }, query: { name: 'Lookup', description: 'Inventory, location, and pallet lookup' }, receiving: { name: 'Container receiving', description: 'Capture three photos; receive FBA by count and overseas cartons individually' }, stocktake: { name: 'Stocktake', description: 'Scan locations and stock by task' }, move: { name: 'Stock move', description: 'Scan source and destination locations' }, pallet: { name: 'Pallet management', description: 'Build, split, and verify pallets' }, relabelReceive: { name: 'Relabel receiving', description: 'Scan a relabel order and confirm stock' }, relabelExecute: { name: 'Relabel execution', description: 'Scan old and new labels' }, relabelReview: { name: 'Relabel review', description: 'Verify labels and product links' }, inventoryQuery: { name: 'Inventory lookup', description: 'Scan an item or carton to view stock' }, locationQuery: { name: 'Location lookup', description: 'Scan a location to view capacity and stock' }, palletQuery: { name: 'Pallet lookup', description: 'Scan a pallet to view linked stock' } },
profile: { title: 'Profile', warehouse: 'Work warehouse', warehousePending: 'Loading warehouse profile', shift: 'Current shift', shiftValue: 'Based on the warehouse roster', version: 'App version', language: 'Language', settings: 'Settings', settingsLater: 'Settings will be available in a future version', logout: 'Sign out', logoutTitle: 'Sign out?', logoutDescription: 'Signing out clears the local overseas PDA session. Unsubmitted work cannot be retained across accounts.' },
language: { zh: '中文', en: 'English', es: 'Español' },
receivingSummary: { container: 'Current container', inboundCodes: 'Inbound codes', plannedBoxes: 'Planned cartons', pendingBoxes: 'Pending cartons' },
history: { title: 'Work history', completedToday: 'Completed today', inProgress: 'In progress', recent: 'Recent work', emptyTitle: 'No overseas warehouse records', emptyDescription: 'Completed work will appear here when live task history is connected.' },
scanner: { title: 'Scanner broadcast test', waitingTitle: 'Receiver started', waitingDetail: 'Waiting for a hardware scan', receivedTitle: 'Scan broadcast received', receivedDetail: 'Scan again to verify continuous reception', errorTitle: 'Receiver failed to start', errorDetail: 'Check the PDA scanner settings and reopen this page', unsupportedTitle: 'Broadcast not supported here', unsupportedDetail: 'Test on an Android PDA app', latest: 'Latest value', waiting: 'Waiting for scan', hardwareHint: 'Press the PDA hardware scan key', count: 'Scan {count} on this page', extra: 'Extra key', records: 'Received scans', clear: 'Clear', empty: 'No broadcast data' },
operation: { title: 'Container receiving', restoring: 'Restoring the current container…', scanContainer: 'Scan container number', container: 'Container number', containerPlaceholder: 'Scan or enter a container number', queryContainer: 'Find container', pendingContainers: '{count} containers awaiting unload', emptyContainers: 'No containers awaiting unload', destination: 'Destination: {value}', receivingProgress: 'Receiving progress', continueCurrent: 'Continue current container', viewDetail: 'View details', lockedNotice: 'After selection, this account is locked to the container until completion.', backList: 'Back to list', selectContainer: 'Select container and take photos', unloadPhotos: 'Unload proof photos', photoHint: 'Take one photo for each of the three required slots', notAdded: 'Not added', uploading: 'Uploading…', retakeFailedKept: 'Retake failed; original kept', uploadFailed: 'Upload failed. Try again.', previewPhoto: 'Tap the photo to preview', takePhotoHint: 'Take an on-site photo', retake: 'Retake', takePhoto: 'Take photo', signAndReceive: 'Confirm arrival and start receiving', scanCartonStep: '1. Scan a carton to identify the inbound code', carton: 'Carton mark', parseCarton: 'Resolve carton', fbaMode: 'FBA receiving by carton count', overseasMode: 'Overseas receiving by carton', totalBoxes: '{count} cartons total', receivedBoxes: '{count} received', pendingBoxes: '{count} pending', scanPalletStep: '2. Scan the physical pallet', pallet: 'Pallet code', palletPlaceholder: 'Scan or enter a pallet code', verifyPallet: 'Verify pallet', palletMatched: 'Destination matches; ready to receive', palletMismatch: 'Pallet does not match', inputCountStep: '3. Enter carton count', maxCount: 'Up to {count} cartons', confirmBulk: 'Confirm carton-count receipt', confirmCarton: 'Confirm this carton', receiptSuccess: '{accepted} received; {inbound} has {pending} remaining', completeReady: 'All inbound codes reconciled — complete container', incomplete: '{count} cartons still pending', completedTitle: 'Container receiving completed', actualReceipt: '{received} / {total} cartons received', returnPending: 'Return to pending containers', unavailableSubtitle: 'This function is not available yet', unavailableTitle: 'Function unavailable', unavailableDescription: 'Warehouse operators cannot call platform, customer, or internal APIs. Scans are not submitted and no simulated success is produced.', lockedContainer: 'Container locked to this account', inbounds: 'Inbound codes in container', plannedPallets: '{count} planned pallets', photoLicense: 'License plate', photoSeal: 'Container seal', photoDoor: 'Open-door view', statusWait: 'Awaiting sign-off', statusSigned: 'Signed', statusReceiving: 'Receiving', statusCompleted: 'Completed', actionSuccess: '{action} succeeded', actionFailed: '{action} failed', photoLoadFailed: 'Photo failed to load', scanUnavailable: 'This function has no dedicated overseas PDA API; scan not submitted', currentConflict: 'Account already has unfinished container {container}; returned to current work', actions: { refresh: 'Refresh pending containers', query: 'Find container', select: 'Select container', sign: 'Sign container', carton: 'Resolve carton', sku: 'Resolve SKU', pallet: 'Verify pallet', receipt: 'Submit receipt', complete: 'Complete receiving' } }
}
+18
View File
@@ -0,0 +1,18 @@
export default {
receivingSummary: { container: 'Contenedor actual', inboundCodes: 'Códigos de entrada', plannedBoxes: 'Cajas planificadas', pendingBoxes: 'Cajas pendientes' },
scanInput: { active: 'Destino del escáner', label: 'Entrada de escaneo', placeholder: 'Escanee o ingrese manualmente' },
operationPhase: { candidates: { title: 'Contenedores por descargar', subtitle: 'Seleccione un contenedor y tome 3 fotos para firmar' }, detail: { title: 'Detalles del contenedor', subtitle: 'Verifique el contenedor y el total de cajas planificadas' }, sign: { title: 'Firma con fotos', subtitle: 'Capture la evidencia antes de recibir' }, receive: { title: 'Contenedor actual', subtitle: 'Escanee una caja para identificar su entrada' }, completed: { title: 'Entrada completada', subtitle: 'El servidor ha conciliado el inventario y el contenedor' } },
common: { close: 'Cerrar', cancel: 'Cancelar', confirm: 'Confirmar', refresh: 'Actualizar', back: 'Volver', loading: 'Cargando…', boxes: 'cajas', pallets: 'palés', plans: 'planes', inboundCodes: 'códigos de entrada', available: 'Disponible', unavailable: 'No disponible', required: 'Obligatoria', uploaded: 'Cargada', retry: 'Reintentar', currentWarehouse: 'Almacén actual', warehouseFallback: 'Asignado automáticamente a esta cuenta', warehouseOperator: 'Operario de almacén' },
app: { title: 'Baiyue Logistics Internacional', workbench: 'Almacén internacional', subtitle: 'Sistema de operaciones de almacén' },
login: { username: 'ID de empleado / Usuario', usernamePlaceholder: 'Ingrese su ID de empleado', password: 'Contraseña', passwordPlaceholder: 'Ingrese su contraseña', submit: 'Iniciar sesión', submitting: 'Iniciando sesión...', usernameRequired: 'Ingrese su ID de empleado', passwordRequired: 'Ingrese su contraseña', offline: 'Sin conexión. Conéctese e inténtelo de nuevo.', failed: 'Error al iniciar sesión' },
nav: { menu: 'Menú', history: 'Historial de trabajo', profile: 'Perfil', openMenu: 'Abrir menú', back: 'Volver' },
home: { eyebrow: 'ALMACÉN INTERNACIONAL', actionable: 'disponible', night: 'Trabaje con seguridad en el turno nocturno', morning: 'Buenos días', afternoon: 'Buenas tardes', evening: 'Buenas noches' },
module: { title: 'Operaciones', functions: 'Funciones', hint: 'Toque una función disponible para comenzar', availability: '{available}/{total} disponibles', onlyReceiving: 'Solo está disponible la recepción de contenedores. Las demás operaciones aún no están abiertas.' },
modules: { inbound: { name: 'Entrada', description: 'Bloquee un contenedor por cuenta y complete la recepción' }, inventory: { name: 'Operaciones internas', description: 'Inventario, movimientos y operaciones de palés' }, relabel: { name: 'Reetiquetado', description: 'Recepción, ejecución y revisión de reetiquetado' }, query: { name: 'Consultas', description: 'Consulta de inventario, ubicaciones y palés' }, receiving: { name: 'Recepción de contenedor', description: 'Tres fotos; FBA por cantidad y cajas internacionales una a una' }, stocktake: { name: 'Conteo de inventario', description: 'Escanee ubicaciones e inventario por tarea' }, move: { name: 'Mover mercancía', description: 'Escanee ubicaciones de origen y destino' }, pallet: { name: 'Gestión de palés', description: 'Agrupe, divida y verifique palés' }, relabelReceive: { name: 'Recepción para reetiquetar', description: 'Escanee la orden y confirme el inventario' }, relabelExecute: { name: 'Ejecutar reetiquetado', description: 'Escanee etiquetas antiguas y nuevas' }, relabelReview: { name: 'Revisar reetiquetado', description: 'Verifique etiquetas y productos' }, inventoryQuery: { name: 'Consultar inventario', description: 'Escanee un artículo o una caja' }, locationQuery: { name: 'Consultar ubicación', description: 'Escanee una ubicación para ver capacidad e inventario' }, palletQuery: { name: 'Consultar palé', description: 'Escanee un palé para ver el inventario vinculado' } },
profile: { title: 'Perfil', warehouse: 'Almacén de trabajo', warehousePending: 'Cargando datos del almacén', shift: 'Turno actual', shiftValue: 'Según el horario del almacén', version: 'Versión de la aplicación', language: 'Idioma', settings: 'Configuración', settingsLater: 'La configuración estará disponible en una versión futura', logout: 'Cerrar sesión', logoutTitle: '¿Cerrar sesión?', logoutDescription: 'Al salir se borra la sesión local del PDA internacional. El trabajo sin enviar no puede conservarse entre cuentas.' },
language: { zh: '中文', en: 'English', es: 'Español' },
receivingSummary: { container: 'Contenedor actual', warehouse: 'Almacén', destinations: 'Destinos', inboundCodes: 'Códigos de entrada', plannedPallets: 'Palés previstos', plannedBoxes: 'Cajas previstas', pendingBoxes: 'Cajas pendientes' },
history: { title: 'Historial de trabajo', completedToday: 'Completado hoy', inProgress: 'En curso', recent: 'Trabajo reciente', emptyTitle: 'Sin registros de almacén', emptyDescription: 'El trabajo completado aparecerá aquí cuando se conecte el historial real.' },
scanner: { title: 'Prueba de difusión del escáner', waitingTitle: 'Receptor iniciado', waitingDetail: 'Esperando un escaneo físico', receivedTitle: 'Escaneo recibido', receivedDetail: 'Escanee de nuevo para verificar la recepción continua', errorTitle: 'No se pudo iniciar el receptor', errorDetail: 'Revise la configuración del escáner PDA y vuelva a abrir la página', unsupportedTitle: 'Difusión no compatible aquí', unsupportedDetail: 'Pruebe en la aplicación Android del PDA', latest: 'Último valor', waiting: 'Esperando escaneo', hardwareHint: 'Pulse la tecla física de escaneo', count: 'Escaneo {count} en esta página', extra: 'Clave Extra', records: 'Escaneos recibidos', clear: 'Borrar', empty: 'Sin datos de difusión' },
operation: { title: 'Recepción de contenedor', restoring: 'Restaurando el contenedor actual…', scanContainer: 'Escanear número de contenedor', container: 'Número de contenedor', containerPlaceholder: 'Escanee o ingrese el número', queryContainer: 'Buscar contenedor', pendingContainers: '{count} contenedores por descargar', emptyContainers: 'No hay contenedores por descargar', destination: 'Destino: {value}', receivingProgress: 'Progreso de recepción', continueCurrent: 'Continuar contenedor actual', viewDetail: 'Ver detalles', lockedNotice: 'Al seleccionarlo, la cuenta queda bloqueada al contenedor hasta finalizar.', backList: 'Volver a la lista', selectContainer: 'Seleccionar y tomar fotos', unloadPhotos: 'Fotos de recepción', photoHint: 'Tome una foto en cada una de las tres posiciones obligatorias', notAdded: 'Sin foto', uploading: 'Cargando…', retakeFailedKept: 'Falló la nueva foto; se conserva la anterior', uploadFailed: 'Error de carga. Reintente.', previewPhoto: 'Toque la foto para ampliarla', takePhotoHint: 'Tome una foto en el lugar', retake: 'Repetir', takePhoto: 'Tomar foto', signAndReceive: 'Confirmar llegada e iniciar recepción', scanCartonStep: '1. Escanee una caja para identificar la entrada', carton: 'Marca de caja', parseCarton: 'Resolver caja', fbaMode: 'Recepción FBA por cantidad', overseasMode: 'Recepción internacional por caja', totalBoxes: '{count} cajas en total', receivedBoxes: '{count} recibidas', pendingBoxes: '{count} pendientes', scanPalletStep: '2. Escanee el palé físico', pallet: 'Código de palé', palletPlaceholder: 'Escanee o ingrese el código', verifyPallet: 'Verificar palé', palletMatched: 'El destino coincide; listo para recibir', palletMismatch: 'El palé no coincide', inputCountStep: '3. Ingrese la cantidad de cajas', maxCount: 'Máximo {count} cajas', confirmBulk: 'Confirmar recepción por cantidad', confirmCarton: 'Confirmar esta caja', receiptSuccess: '{accepted} recibidas; quedan {pending} en {inbound}', completeReady: 'Todas las entradas conciliadas — completar contenedor', incomplete: 'Quedan {count} cajas', completedTitle: 'Recepción del contenedor completada', actualReceipt: '{received} / {total} cajas recibidas', returnPending: 'Volver a contenedores pendientes', unavailableSubtitle: 'Esta función aún no está disponible', unavailableTitle: 'Función no disponible', unavailableDescription: 'Los operarios no pueden llamar API de plataforma, cliente o internas. Los escaneos no se envían ni generan resultados simulados.', lockedContainer: 'Contenedor bloqueado para esta cuenta', inbounds: 'Códigos de entrada del contenedor', plannedPallets: '{count} palés planificados', photoLicense: 'Matrícula del vehículo', photoSeal: 'Sello del contenedor', photoDoor: 'Puertas abiertas', statusWait: 'Pendiente de firma', statusSigned: 'Firmado', statusReceiving: 'Recibiendo', statusCompleted: 'Completado', actionSuccess: '{action} completado', actionFailed: 'Error: {action}', photoLoadFailed: 'No se pudo cargar la foto', scanUnavailable: 'Esta función no tiene API PDA internacional; escaneo no enviado', currentConflict: 'La cuenta ya tiene el contenedor {container} sin terminar; se volvió al trabajo actual', actions: { refresh: 'Actualizar pendientes', query: 'Buscar contenedor', select: 'Seleccionar contenedor', sign: 'Firmar contenedor', carton: 'Resolver caja', sku: 'Resolver SKU', pallet: 'Verificar palé', receipt: 'Enviar recepción', complete: 'Completar recepción' } }
}
+23
View File
@@ -0,0 +1,23 @@
export default {
receivingSummary: { container: '当前柜号', warehouse: '作业仓', destinations: '目的仓', inboundCodes: '入仓码', plannedPallets: '计划托盘', plannedBoxes: '计划箱数', pendingBoxes: '待收箱数' },
scanInput: { active: '当前扫码', label: '扫码输入框', placeholder: '扫描或人工输入' },
operationPhase: { candidates: { title: '待卸货集装箱', subtitle: '选择集装箱后拍摄 3 张照片完成签收' }, detail: { title: '柜号详情', subtitle: '核对柜号与整柜计划箱数' }, sign: { title: '拆柜拍照签收', subtitle: '完成现场取证后进入收货' }, receive: { title: '当前柜收货', subtitle: '扫描箱码定位入仓码后开始收货' }, completed: { title: '入库完成', subtitle: '库存与柜级进度已由服务端核平' } },
common: { close: '关闭', cancel: '取消', confirm: '确认', refresh: '刷新', back: '返回', loading: '加载中…', boxes: '箱', pallets: '托', plans: '个计划', inboundCodes: '个入仓码', available: '可处理', unavailable: '暂未开放', required: '必传', uploaded: '已上传', retry: '请重试', currentWarehouse: '当前作业仓', warehouseFallback: '仓库由当前账号自动绑定', warehouseOperator: '仓库作业员' },
app: { title: '柏悦物流海外版', workbench: '海外仓工作台', subtitle: '海外仓现场作业系统' },
login: { username: '工号 / 用户名', usernamePlaceholder: '请输入工号', password: '密码', passwordPlaceholder: '请输入密码', submit: '登录', submitting: '登录中...', usernameRequired: '请输入工号', passwordRequired: '请输入密码', offline: '当前网络不可用,请联网后重试', failed: '登录失败' },
nav: { menu: '功能菜单', history: '作业记录', profile: '个人中心', openMenu: '打开功能菜单', back: '返回上一页' },
home: { eyebrow: 'OVERSEAS WAREHOUSE', actionable: '项可处理', night: '夜间作业请注意安全', morning: '早上好', afternoon: '下午好', evening: '晚上好' },
module: { title: '业务模块', functions: '业务功能', hint: '点击可用功能开始作业', availability: '{available}/{total} 可用', onlyReceiving: '当前仅开放拆柜收货,其他作业暂未开放。' },
modules: {
inbound: { name: '入仓作业', description: '单账号锁定一柜,完成柜号级入库闭环' }, inventory: { name: '库内作业', description: '盘点、移库与托盘管理' }, relabel: { name: '换标作业', description: '换标收货、执行与复核' }, query: { name: '现场查询', description: '库存、库位与托盘查询' },
receiving: { name: '拆柜收货', description: '三类照片签收,FBA 按 SKU 箱数、海外仓逐箱收货' }, stocktake: { name: '库存盘点', description: '按任务扫描库位和库存' }, move: { name: '商品移库', description: '扫描来源与目标库位完成移库' }, pallet: { name: '托盘管理', description: '组托、拆托及托盘状态核对' }, relabelReceive: { name: '换标收货', description: '扫描换标单并确认待处理库存' }, relabelExecute: { name: '换标执行', description: '扫描旧标和新标记录换标结果' }, relabelReview: { name: '换标复核', description: '复核标签和商品关联关系' }, inventoryQuery: { name: '库存查询', description: '扫描商品或箱码查询可用库存' }, locationQuery: { name: '库位查询', description: '扫描库位查看容量和在库明细' }, palletQuery: { name: '托盘查询', description: '扫描托盘码查看绑定库存' }
},
profile: { title: '个人中心', warehouse: '作业仓库', warehousePending: '正在读取仓库主档', shift: '当前班次', shiftValue: '以海外仓排班为准', version: '应用版本', language: '语言', settings: '系统设置', settingsLater: '设置功能将在后续版本开放', logout: '退出登录', logoutTitle: '确认退出登录?', logoutDescription: '退出后会清空海外 PDA 的本地登录会话;未提交作业数据不得跨账号保留。' },
language: { zh: '中文', en: 'English', es: 'Español' },
receivingSummary: { container: '当前柜号', inboundCodes: '入仓码', plannedBoxes: '计划箱数', pendingBoxes: '待收箱数' },
history: { title: '作业记录', completedToday: '今日完成', inProgress: '进行中', recent: '最近作业', emptyTitle: '暂无海外仓作业记录', emptyDescription: '接入海外仓真实任务后,完成记录会显示在这里' },
scanner: { title: '广播扫码测试', waitingTitle: '接收器已启动', waitingDetail: '正在等待硬件扫描结果', receivedTitle: '已收到扫描广播', receivedDetail: '继续扫描可验证连续接收', errorTitle: '接收器启动失败', errorDetail: '请检查 PDA 扫描服务设置后重新进入页面', unsupportedTitle: '当前环境不支持广播', unsupportedDetail: '请在 Android App 真机中测试', latest: '最近收到', waiting: '等待扫描', hardwareHint: '按下 PDA 硬件扫描键', count: '本页第 {count} 次', extra: 'Extra 标签', records: '接收记录', clear: '清空', empty: '暂无广播数据' },
operation: {
title: '拆柜收货', restoring: '正在恢复当前柜作业…', scanContainer: '扫描柜号', container: '柜号', containerPlaceholder: '扫描或人工输入柜号', queryContainer: '查询柜号', pendingContainers: '{count} 个待卸货集装箱', emptyContainers: '暂无待卸货集装箱', destination: '目的仓:{value}', receivingProgress: '收货进度', continueCurrent: '继续当前柜', viewDetail: '查看详情', lockedNotice: '选择后账号将锁定当前柜,完成前不能切换其他柜号。', backList: '返回列表', selectContainer: '选择此柜并开始拍照', unloadPhotos: '卸货签收照片', photoHint: '三个固定槽位各拍摄 1 张现场照片', notAdded: '未添加', uploading: '正在上传…', retakeFailedKept: '重拍失败,已保留原图', uploadFailed: '上传失败,请重试', previewPhoto: '点击左侧照片可查看大图', takePhotoHint: '请拍摄现场照片', retake: '重拍', takePhoto: '拍照', signAndReceive: '确认签收并开始收货', scanCartonStep: '1. 扫描箱码定位入仓码', carton: '箱码', parseCarton: '解析箱码', fbaMode: 'FBA 按箱数收货', overseasMode: '海外仓逐箱收货', totalBoxes: '总计 {count} 箱', receivedBoxes: '已收 {count} 箱', pendingBoxes: '待收 {count} 箱', scanPalletStep: '2. 扫描实物托盘码', pallet: '托盘码', palletPlaceholder: '扫描或人工输入托盘码', verifyPallet: '校验托盘', palletMatched: '目的仓匹配,可收货', palletMismatch: '托盘不匹配', inputCountStep: '3. 输入本次箱数', maxCount: '最多可收 {count} 箱', confirmBulk: '确认按箱数收货', confirmCarton: '确认本箱收货', receiptSuccess: '本次已收 {accepted} 箱,{inbound} 剩余 {pending} 箱', completeReady: '每个入仓码已核平,完成整柜入库', incomplete: '尚有 {count} 箱未收', completedTitle: '当前柜已完成入库', actualReceipt: '实收 {received} / {total} 箱', returnPending: '返回待卸货列表', unavailableSubtitle: '当前功能暂未开放', unavailableTitle: '功能暂未开放', unavailableDescription: '现有平台、客户或内部接口不能由仓库作业员直接调用。扫码不会提交,也不会产生模拟成功结果。', lockedContainer: '当前账号锁定柜号', inbounds: '柜内入仓码', plannedPallets: '计划 {count} 托', photoLicense: '车牌照片', photoSeal: '集装箱封签', photoDoor: '开柜现场', statusWait: '待签收', statusSigned: '已签收', statusReceiving: '收货中', statusCompleted: '已完成', actionSuccess: '{action}成功', actionFailed: '{action}失败', photoLoadFailed: '照片加载失败', scanUnavailable: '该功能缺少海外 PDA 专用接口,扫码未提交', currentConflict: '账号已有未完成柜 {container},已返回当前柜', actions: { refresh: '刷新待卸柜', query: '查询柜号', select: '选择当前柜', sign: '签收当前柜', carton: '解析箱唛', sku: '解析 SKU', pallet: '校验托盘', receipt: '提交实收', complete: '完成入库' }
}
}
+2
View File
@@ -1,8 +1,10 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
import { i18n } from './i18n/index.js'
export function createApp() {
const app = createSSRApp(App)
app.use(i18n)
return {
app
+1 -3
View File
@@ -1,6 +1,4 @@
<template>
<view v-if="user" class="page-shell portal-page"><PortalHeader title="作业记录" :user="user" /><view class="portal-content"><view class="summary-card"><view><text class="summary-number">0</text><text class="summary-label">今日完成</text></view><view><text class="summary-number pending-color">0</text><text class="summary-label">进行中</text></view></view><text class="group-title">最近作业</text><view class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">暂无海外仓作业记录</text><text class="history-empty-desc">接入海外仓真实任务后,完成记录会显示在这里</text></view></view></view>
</template>
<template><view v-if="user" class="page-shell portal-page"><PortalHeader :title="$t('history.title')" :user="user" /><view class="portal-content"><view class="summary-card"><view><text class="summary-number">0</text><text class="summary-label">{{ $t('history.completedToday') }}</text></view><view><text class="summary-number pending-color">0</text><text class="summary-label">{{ $t('history.inProgress') }}</text></view></view><text class="group-title">{{ $t('history.recent') }}</text><view class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">{{ $t('history.emptyTitle') }}</text><text class="history-empty-desc">{{ $t('history.emptyDescription') }}</text></view></view></view></template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'; import { session, requireSession } from '@/stores/session.js'
export default { components: { PortalHeader }, data() { return { user: session.currentUser } }, onShow() { if (!requireSession()) return; this.user = session.currentUser } }
+40 -8
View File
@@ -1,15 +1,23 @@
<template>
<view v-if="user" class="page-shell portal-page home-page">
<PortalHeader ref="portalHeader" title="首页" :user="user" menu @menu-change="menuOpen = $event" />
<PortalHeader ref="portalHeader" :title="$t('app.workbench')" :user="user" menu @menu-change="menuOpen = $event" />
<view class="home-menu">
<view class="home-overview">
<view class="home-overview-copy">
<text class="home-overview-eyebrow">{{ $t('home.eyebrow') }}</text>
<text class="home-overview-title">{{ greeting }},{{ displayName }}</text>
<text class="home-overview-warehouse">{{ warehouseLabel }}</text>
</view>
<view class="home-overview-status"><text class="home-overview-status-value">{{ availableCount }}</text><text class="home-overview-status-label">{{ $t('home.actionable') }}</text></view>
</view>
<view v-for="group in groups" :key="group.key" class="home-group">
<view class="home-group-title"><view class="home-group-mark" :class="`home-group-mark-${group.key}`"></view><text>{{ group.name }}</text></view>
<view class="home-group-title"><view class="home-group-mark" :class="`home-group-mark-${group.key}`"></view><text>{{ $t(group.nameKey) }}</text><text class="home-group-count">{{ group.operations.length }}</text></view>
<view class="home-operation-grid">
<button v-for="operation in group.operations" :key="operation.name" class="home-operation" :class="{ 'home-operation-primary': operation.available, 'home-operation-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)">
<view class="home-operation-icon" aria-hidden="true"><uni-icons :type="operation.iconType" :color="operation.available ? '#135ee8' : '#9aa6b2'" size="28" /></view>
<text class="home-operation-name">{{ operation.name }}</text>
<text v-if="operation.available" class="home-operation-pending">可处理</text>
<text v-else class="home-operation-unavailable">暂未开放</text>
<text class="home-operation-name">{{ $t(operation.nameKey) }}</text>
<text v-if="operation.available" class="home-operation-pending">{{ $t('common.available') }}</text>
<text v-else class="home-operation-unavailable">{{ $t('common.unavailable') }}</text>
</button>
</view>
</view>
@@ -19,8 +27,9 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import { getCurrentWarehouseProfile } from '@/api/inbound.js'
import { MODULES } from '@/data/overseas-workbench.js'
import { session, requireSession } from '@/stores/session.js'
import { applyWarehouseProfile, session, requireSession } from '@/stores/session.js'
const HOME_GROUPS = MODULES.map((module) => ({
...module,
@@ -31,17 +40,40 @@
export default {
components: { PortalHeader },
data() { return { user: session.currentUser, groups: HOME_GROUPS, menuOpen: false } },
onShow() { if (!requireSession()) return; this.user = session.currentUser },
computed: {
availableCount() { return this.groups.reduce((total, group) => total + group.operations.filter((operation) => operation.available).length, 0) },
displayName() {
const name = String(this.user?.name || '').trim()
return name && !/^[a-z0-9._-]+$/i.test(name) ? name : this.$t('common.warehouseOperator')
},
warehouseLabel() {
if (this.user?.warehouseName) return this.user.warehouseCode ? `${this.user.warehouseName} · ${this.user.warehouseCode}` : this.user.warehouseName
const warehouseId = String(this.user?.warehouseId || '')
if (warehouseId) return `${this.$t('common.currentWarehouse')} · ${warehouseId.slice(-6)}`
return this.user?.warehouse || this.$t('common.warehouseFallback')
},
greeting() {
const hour = new Date().getHours()
if (hour < 6) return this.$t('home.night')
if (hour < 12) return this.$t('home.morning')
if (hour < 18) return this.$t('home.afternoon')
return this.$t('home.evening')
}
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadWarehouseProfile() },
onBackPress() {
if (!this.menuOpen) return false
this.$refs.portalHeader.closeMenu()
return true
},
methods: {
async loadWarehouseProfile() {
try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser }
},
openOperation(operation) {
if (!operation.available) return
const module = this.groups.find((group) => group.operations.some((item) => item.name === operation.name))
uni.navigateTo({ url: `/pages/operation/operation?module=${module?.key || 'query'}&name=${encodeURIComponent(operation.name)}` })
uni.navigateTo({ url: `/pages/operation/operation?module=${module?.key || 'query'}&op=${operation.key}` })
}
}
}
+8 -8
View File
@@ -2,13 +2,13 @@
<view class="page-shell login-page">
<view class="login-brand">
<image class="login-logo-icon" src="/static/brand-mark-white.png" mode="aspectFit" aria-hidden="true" />
<text class="login-logo-title">柏悦物流海外版</text><text class="login-logo-sub">海外仓现场作业系统</text>
<text class="login-logo-title">{{ $t('app.title') }}</text><text class="login-logo-sub">{{ $t('app.subtitle') }}</text>
</view>
<view class="login-card">
<view class="login-field"><text class="login-field-label">工号 / 用户名</text><input v-model="form.userId" class="login-field-input" aria-label="工号 / 用户名" placeholder="请输入工号" confirm-type="next" @focus="error = ''" /></view>
<view class="login-field"><text class="login-field-label">密码</text><input v-model="form.password" class="login-field-input" aria-label="密码" type="password" placeholder="请输入密码" confirm-type="done" @focus="error = ''" @confirm="login" /></view>
<view class="login-field"><text class="login-field-label">{{ $t('login.username') }}</text><input v-model="form.userId" class="login-field-input" :aria-label="$t('login.username')" :placeholder="$t('login.usernamePlaceholder')" confirm-type="next" @focus="error = ''" /></view>
<view class="login-field"><text class="login-field-label">{{ $t('login.password') }}</text><input v-model="form.password" class="login-field-input" :aria-label="$t('login.password')" type="password" :placeholder="$t('login.passwordPlaceholder')" confirm-type="done" @focus="error = ''" @confirm="login" /></view>
<text v-if="error" class="login-error" role="alert">{{ error }}</text>
<button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? '登录中...' : '登录' }}</button>
<button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? $t('login.submitting') : $t('login.submit') }}</button>
</view>
</view>
</template>
@@ -24,19 +24,19 @@
methods: {
async login() {
const userId = this.form.userId.trim()
if (!userId) return void (this.error = '请输入工号')
if (!this.form.password) return void (this.error = '请输入密码')
if (!userId) return void (this.error = this.$t('login.usernameRequired'))
if (!this.form.password) return void (this.error = this.$t('login.passwordRequired'))
this.submitting = true
this.error = ''
try {
requireOnline('当前网络不可用,无法登录')
requireOnline(this.$t('login.offline'))
const tokens = await loginRequest(userId, this.form.password)
signInWithTokens(tokens)
uni.reLaunch({ url: '/pages/home/home' })
} catch (error) {
console.error('[Overseas PDA login] failed', { message: error.message, network: error.network === true,
code: error.code, requestId: error.requestId, traceId: error.traceId })
this.error = error.network ? '当前网络不可用,请联网后重试' : (error.message || '登录失败')
this.error = error.network ? this.$t('login.offline') : (error.message || this.$t('login.failed'))
} finally {
this.submitting = false
}
+10 -7
View File
@@ -1,11 +1,11 @@
<template>
<view v-if="user && currentModule" class="page-shell portal-page">
<PortalHeader title="业务模块" :user="user" />
<PortalHeader :title="$t('module.title')" :user="user" />
<view class="portal-content">
<view class="module-hero" :class="`module-hero-${currentModule.key}`"><view class="module-hero-icon">{{ currentModule.icon }}</view><view><text class="module-hero-title">{{ currentModule.name }}</text><text class="module-hero-desc">{{ currentModule.description }}</text></view></view>
<text class="group-title">业务功能</text>
<view class="operation-list"><button v-for="operation in currentModule.operations" :key="operation.name" class="operation-item" :class="{ 'operation-item-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)"><view class="operation-icon">{{ operation.icon }}</view><view class="operation-info"><text class="operation-name">{{ operation.name }}</text><text class="operation-desc">{{ operation.description }}</text></view><text v-if="operation.available" class="operation-arrow">›</text></button></view>
<view class="module-empty-note">当前仅开放拆柜收货,其他作业暂未开放。</view>
<view class="module-hero" :class="`module-hero-${currentModule.key}`"><view class="module-hero-icon">{{ currentModule.icon }}</view><view class="module-hero-copy"><text class="module-hero-title">{{ $t(currentModule.nameKey) }}</text><text class="module-hero-desc">{{ $t(currentModule.descriptionKey) }}</text></view><text class="module-hero-count">{{ $t('module.availability', { available: availableCount, total: currentModule.operations.length }) }}</text></view>
<view class="section-heading"><text class="group-title">{{ $t('module.functions') }}</text><text class="section-heading-hint">{{ $t('module.hint') }}</text></view>
<view class="operation-list"><button v-for="operation in currentModule.operations" :key="operation.name" class="operation-item" :class="{ 'operation-item-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)"><view class="operation-icon">{{ operation.icon }}</view><view class="operation-info"><text class="operation-name">{{ $t(operation.nameKey) }}</text><text class="operation-desc">{{ $t(operation.descriptionKey) }}</text></view><text v-if="operation.available" class="operation-ready">{{ $t('common.available') }}</text><text v-else class="operation-locked">{{ $t('common.unavailable') }}</text><text v-if="operation.available" class="operation-arrow">›</text></button></view>
<view class="module-empty-note">{{ $t('module.onlyReceiving') }}</view>
</view>
</view>
</template>
@@ -17,11 +17,14 @@
export default {
components: { PortalHeader },
data() { return { user: session.currentUser, moduleKey: 'inbound' } },
computed: { currentModule() { return MODULES.find((module) => module.key === this.moduleKey) || MODULES[0] } },
computed: {
currentModule() { return MODULES.find((module) => module.key === this.moduleKey) || MODULES[0] },
availableCount() { return this.currentModule.operations.filter((operation) => operation.available).length }
},
onLoad(options) { this.moduleKey = options.key || 'inbound' },
onShow() { if (!requireSession()) return; this.user = session.currentUser },
methods: {
openOperation(operation) { if (operation.available) uni.navigateTo({ url: `/pages/operation/operation?module=${this.moduleKey}&name=${encodeURIComponent(operation.name)}` }) }
openOperation(operation) { if (operation.available) uni.navigateTo({ url: `/pages/operation/operation?module=${this.moduleKey}&op=${operation.key}` }) }
}
}
</script>
File diff suppressed because one or more lines are too long
+42 -3
View File
@@ -1,7 +1,46 @@
<template>
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">{{ user.role }} · {{ user.id }}</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse || '登录后由海外仓服务分配' }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">以海外仓排班为准</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">0.1.0</text></view></view><button class="profile-action" @tap="showSettings">系统设置 <text>›</text></button><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空海外 PDA 的本地登录会话;未提交作业数据不得跨账号保留。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
<view v-if="user" class="page-shell portal-page">
<PortalHeader :title="$t('profile.title')" :user="user" :show-profile="false" />
<view class="portal-content profile-content">
<view class="profile-card"><view class="profile-avatar">{{ avatar }}</view><view><text class="profile-name">{{ displayName }}</text><text class="profile-role">{{ user.role }} · {{ user.id }}</text></view></view>
<view class="profile-list">
<view class="profile-row"><text>{{ $t('profile.warehouse') }}</text><text class="profile-value">{{ warehouseDisplay }}</text></view>
<view class="profile-row"><text>{{ $t('profile.shift') }}</text><text class="profile-value">{{ $t('profile.shiftValue') }}</text></view>
<view class="profile-row"><text>{{ $t('profile.version') }}</text><text class="profile-value">0.1.0</text></view>
<picker mode="selector" :range="localeLabels" :value="localeIndex" @change="changeLocale"><view class="profile-row language-row"><text>{{ $t('profile.language') }}</text><text class="profile-value">{{ localeLabels[localeIndex] }} ›</text></view></picker>
</view>
<button class="profile-action" @tap="showSettings">{{ $t('profile.settings') }} <text>›</text></button>
<button class="logout-action" @tap="openLogout">{{ $t('profile.logout') }}</button>
</view>
<uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" :aria-label="$t('profile.logoutTitle')"><PopupHeader :title="$t('profile.logoutTitle')" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">{{ $t('profile.logoutDescription') }}</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">{{ $t('common.cancel') }}</button><button class="logout-dialog-confirm" @tap="logout">{{ $t('common.confirm') }}</button></view></view></uni-popup>
<view v-if="toast" class="app-toast warning" role="status">{{ $t('profile.settingsLater') }}</view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'; import PopupHeader from '@/components/PopupHeader.vue'; import { session, requireSession, signOut } from '@/stores/session.js'
export default { components: { PopupHeader, PortalHeader }, data() { return { user: session.currentUser, logoutOpen: false, toast: false } }, onShow() { if (!requireSession()) return; this.user = session.currentUser }, beforeUnmount() { clearTimeout(this._toastTimer) }, onBackPress() { if (!this.logoutOpen) return false; this.closeLogout(); return true }, methods: { openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) }, showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) }, logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) } } }
import PortalHeader from '@/components/PortalHeader.vue'
import PopupHeader from '@/components/PopupHeader.vue'
import { getCurrentWarehouseProfile } from '@/api/inbound.js'
import { currentLocale, LOCALE_OPTIONS, setLocale } from '@/i18n/index.js'
import { applyWarehouseProfile, session, requireSession, signOut } from '@/stores/session.js'
export default {
components: { PopupHeader, PortalHeader },
data() { return { user: session.currentUser, logoutOpen: false, toast: false, locale: currentLocale() } },
computed: {
localeLabels() { return LOCALE_OPTIONS.map((item) => this.$t(item.labelKey)) },
localeIndex() { const index = LOCALE_OPTIONS.findIndex((item) => item.value === this.locale); return Math.max(0, index) },
displayName() { const name = String(this.user?.name || ''); return name && !/^[a-z0-9._-]+$/i.test(name) ? name : this.$t('common.warehouseOperator') },
avatar() { return this.displayName.slice(0, 1).toUpperCase() },
warehouseDisplay() { return this.user?.warehouseName ? (this.user.warehouseCode ? `${this.user.warehouseName} · ${this.user.warehouseCode}` : this.user.warehouseName) : (this.user?.warehouse || this.$t('profile.warehousePending')) }
},
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.locale = currentLocale(); this.loadWarehouseProfile() },
beforeUnmount() { clearTimeout(this._toastTimer) },
onBackPress() { if (!this.logoutOpen) return false; this.closeLogout(); return true },
methods: {
async loadWarehouseProfile() { try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser } },
changeLocale(event) { this.locale = setLocale(LOCALE_OPTIONS[Number(event.detail.value)]?.value || 'en') },
openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) }
}
}
</script>
+14 -14
View File
@@ -1,6 +1,6 @@
<template>
<view v-if="user" class="page-shell portal-page scanner-test-page">
<PortalHeader title="广播扫码测试" :user="user" />
<PortalHeader :title="$t('scanner.title')" :user="user" />
<view class="scanner-test-content">
<view class="scanner-status" :class="`scanner-status-${status}`" role="status">
<view class="scanner-status-dot"></view>
@@ -11,19 +11,19 @@
</view>
<view class="scanner-result" :class="{ 'scanner-result-received': lastValue }">
<text class="scanner-result-label">最近收到</text>
<text class="scanner-result-value">{{ lastValue || '等待扫描' }}</text>
<text class="scanner-result-meta">{{ lastValue ? `${lastTime} · 本页第 ${scanCount} 次` : '按下 PDA 硬件扫描键' }}</text>
<text class="scanner-result-label">{{ $t('scanner.latest') }}</text>
<text class="scanner-result-value">{{ lastValue || $t('scanner.waiting') }}</text>
<text class="scanner-result-meta">{{ lastValue ? `${lastTime} · ${$t('scanner.count', { count: scanCount })}` : $t('scanner.hardwareHint') }}</text>
</view>
<view class="scanner-config">
<view class="scanner-config-row"><text class="scanner-config-label">Action</text><text class="scanner-config-value">{{ action }}</text></view>
<view class="scanner-config-row"><text class="scanner-config-label">Extra 标签</text><text class="scanner-config-value">{{ extra }}</text></view>
<view class="scanner-config-row"><text class="scanner-config-label">{{ $t('scanner.extra') }}</text><text class="scanner-config-value">{{ extra }}</text></view>
</view>
<view class="scanner-history-head">
<text class="scanner-history-title">接收记录</text>
<button class="scanner-clear" :disabled="!history.length" @tap="clearHistory">清空</button>
<text class="scanner-history-title">{{ $t('scanner.records') }}</text>
<button class="scanner-clear" :disabled="!history.length" @tap="clearHistory">{{ $t('scanner.clear') }}</button>
</view>
<view v-if="history.length" class="scanner-history">
<view v-for="item in history" :key="item.id" class="scanner-history-item">
@@ -31,7 +31,7 @@
<view class="scanner-history-main"><text class="scanner-history-value">{{ item.value }}</text><text class="scanner-history-time">{{ item.time }}</text></view>
</view>
</view>
<view v-else class="scanner-empty"><text>暂无广播数据</text></view>
<view v-else class="scanner-empty"><text>{{ $t('scanner.empty') }}</text></view>
</view>
</view>
</template>
@@ -57,14 +57,14 @@
},
computed: {
statusTitle() {
if (this.status === 'error') return '接收器启动失败'
if (this.status === 'unsupported') return '当前环境不支持广播'
return this.lastValue ? '已收到扫描广播' : '接收器已启动'
if (this.status === 'error') return this.$t('scanner.errorTitle')
if (this.status === 'unsupported') return this.$t('scanner.unsupportedTitle')
return this.$t(this.lastValue ? 'scanner.receivedTitle' : 'scanner.waitingTitle')
},
statusDetail() {
if (this.status === 'error') return '请检查 PDA 扫描服务设置后重新进入页面'
if (this.status === 'unsupported') return '请在 Android App 真机中测试'
return this.lastValue ? '继续扫描可验证连续接收' : '正在等待硬件扫描结果'
if (this.status === 'error') return this.$t('scanner.errorDetail')
if (this.status === 'unsupported') return this.$t('scanner.unsupportedDetail')
return this.$t(this.lastValue ? 'scanner.receivedDetail' : 'scanner.waitingDetail')
}
},
onShow() {
+2 -12
View File
@@ -3,7 +3,7 @@ import { RECEIVING_PHOTO_ROLES } from '../api/receiving-adapter.js'
export const receiving = reactive({
candidates: [], selected: null, current: null, photoSlots: [null, null, null],
selectedInboundCode: '', resolvedCarton: null, resolvedSku: null, resolvedPallet: null,
resolvedCarton: null, resolvedPallet: null,
activeScanTarget: 'container', lastReceipt: null
})
@@ -23,17 +23,9 @@ export function receivingProgress(session = receiving.current) {
return { total, received, pending: Math.max(0, Number(session?.pendingPackages ?? total - received)), percent: total ? Math.min(100, Math.round(received / total * 100)) : 0 }
}
export function selectedInbound(session = receiving.current) {
return (session?.inbounds || []).find((item) => item.inboundCode === receiving.selectedInboundCode) || null
}
export function setCurrentReceiving(value) {
receiving.current = value || null
receiving.selected = value || null
const chosen = selectedInbound(value)
if (!chosen || Number(chosen.pendingPackages || 0) <= 0) {
receiving.selectedInboundCode = (value?.inbounds || []).find((item) => Number(item.pendingPackages || 0) > 0)?.inboundCode || value?.inbounds?.[0]?.inboundCode || ''
}
receiving.photoSlots = RECEIVING_PHOTO_ROLES.map((role) => {
const photo = (value?.photos || []).find((item) => item.role === role)
return photo ? { ...photo, fileId: String(photo.fileId), role, uploaded: true, status: 'success' } : null
@@ -44,11 +36,9 @@ export function resetReceivingDraft({ keepCurrent = false } = {}) {
receiving.selected = keepCurrent ? receiving.current : null
if (!keepCurrent) receiving.current = null
receiving.photoSlots = [null, null, null]
receiving.selectedInboundCode = ''
receiving.resolvedCarton = null
receiving.resolvedSku = null
receiving.resolvedPallet = null
receiving.activeScanTarget = keepCurrent ? 'pallet' : 'container'
receiving.activeScanTarget = keepCurrent ? 'carton' : 'container'
receiving.lastReceipt = null
idempotencyKeys.clear()
}
+25 -6
View File
@@ -66,14 +66,18 @@ export function validatePdaTokenPair(accessToken, overseasAccessToken) {
return { domestic, overseas, warehouseId }
}
function userFromClaims(pair) {
function userFromClaims(pair, saved = {}) {
const savedWarehouseId = String(saved?.warehouseId || '')
const sameWarehouse = savedWarehouseId === pair.warehouseId
return {
id: claimId(pair.domestic, 'user_id'),
name: pair.domestic.username || '仓库作业员',
role: PDA_ROLE,
roles: [...pair.overseas.roles],
warehouse: `海外仓 #${pair.warehouseId}`,
warehouseId: pair.warehouseId
warehouse: sameWarehouse && saved.warehouseName ? saved.warehouseName : `海外仓 #${pair.warehouseId}`,
warehouseId: pair.warehouseId,
warehouseCode: sameWarehouse ? (saved.warehouseCode || '') : '',
warehouseName: sameWarehouse ? (saved.warehouseName || '') : ''
}
}
@@ -94,7 +98,8 @@ function persistSession() {
}
export function signIn(account, warehouse, options = {}) {
session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse, warehouseId: account.warehouseId || null }
session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse,
warehouseId: account.warehouseId || null, warehouseCode: account.warehouseCode || '', warehouseName: account.warehouseName || '' }
session.token = options.token || account.token || ''
session.domesticToken = options.domesticToken || account.domesticToken || ''
session.refreshToken = options.refreshToken || account.refreshToken || ''
@@ -120,7 +125,7 @@ export function signInWithTokens(tokens) {
export function replaceTokens(tokens) {
if (!tokens?.accessToken || !tokens?.overseasAccessToken) throw new Error('刷新响应缺少双区域访问令牌')
const pair = validatePdaTokenPair(tokens.accessToken, tokens.overseasAccessToken)
const user = userFromClaims(pair)
const user = userFromClaims(pair, session.currentUser)
const nextRefreshToken = tokens.refreshToken || session.refreshToken
// Validate the complete replacement before mutating reactive or persisted session state.
session.domesticToken = tokens.accessToken
@@ -131,6 +136,20 @@ export function replaceTokens(tokens) {
persistSession()
}
export function applyWarehouseProfile(profile) {
if (!session.currentUser) throw new Error('当前登录会话不存在')
const warehouseId = String(profile?.warehouseId || '')
if (!warehouseId || warehouseId !== String(session.currentUser.warehouseId)) throw new Error('仓库主档与当前登录仓库不一致')
session.currentUser = {
...session.currentUser,
warehouse: profile.warehouseName || profile.warehouseCode || `海外仓 #${warehouseId}`,
warehouseCode: String(profile.warehouseCode || ''),
warehouseName: String(profile.warehouseName || '')
}
persistSession()
return session.currentUser
}
export function restoreSession() {
if (!storageAvailable()) return false
const saved = uni.getStorageSync(SESSION_KEY)
@@ -141,7 +160,7 @@ export function restoreSession() {
try {
const pair = validatePdaTokenPair(saved.domesticToken, saved.token)
Object.assign(session, saved, {
currentUser: userFromClaims(pair), tenantId: claimId(pair.domestic, 'tenant_id'),
currentUser: userFromClaims(pair, saved.currentUser), tenantId: claimId(pair.domestic, 'tenant_id'),
apiBase: OVERSEAS_API_ROOT
})
return true
+49 -16
View File
@@ -678,7 +678,7 @@
min-height: 100vh;
margin: 0 auto;
padding-bottom: env(safe-area-inset-bottom, 0px);
background: #f4f7fb;
background: linear-gradient(180deg, #eef5ff 0, #f4f7fb 180px, #f4f7fb 100%);
}
.portal-header-shell { position: sticky; top: 0; z-index: 30; }
@@ -687,7 +687,8 @@
box-sizing: border-box;
padding: var(--status-bar-height, 0px) 8px 0;
color: #ffffff;
background: #135ee8;
background: linear-gradient(115deg, #0f55d5 0%, #1769e8 58%, #2d7df0 100%);
box-shadow: 0 6px 18px rgba(15, 85, 213, 0.2);
}
.portal-header-row {
@@ -710,7 +711,7 @@
display: block;
}
.portal-title { overflow: hidden; font-size: 17px; font-weight: 600; line-height: 48px; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.portal-title { overflow: hidden; font-size: 17px; font-weight: 700; line-height: 48px; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.portal-header-button {
width: 48px;
@@ -738,22 +739,40 @@
.portal-content { padding: 12px 14px 24px; }
.home-menu { padding: 12px 12px 24px; }
.home-group + .home-group { margin-top: 18px; }
.home-group-title { display: flex; align-items: center; margin-bottom: 10px; color: #445166; font-size: 16px; line-height: 24px; }
.home-menu { padding: 14px 12px 28px; }
.home-overview { position: relative; display: flex; box-sizing: border-box; min-height: 116px; margin-bottom: 20px; padding: 15px 16px; align-items: center; justify-content: space-between; gap: 14px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.82); border-radius: 18px; color: #ffffff; background: linear-gradient(130deg, #135ee8 0%, #0f75c7 68%, #0d9488 130%); box-shadow: 0 10px 24px rgba(19, 94, 232, 0.2); }
.home-overview::after { position: absolute; right: -38px; top: -54px; width: 150px; height: 150px; border: 26px solid rgba(255, 255, 255, 0.08); border-radius: 50%; content: ''; }
.home-overview-copy { position: relative; z-index: 1; min-width: 0; flex: 1; }
.home-overview-eyebrow, .home-overview-title, .home-overview-warehouse, .home-overview-status-value, .home-overview-status-label { display: block; }
.home-overview-eyebrow { font-size: 9px; font-weight: 700; line-height: 14px; letter-spacing: 1.3px; opacity: 0.72; }
.home-overview-title { overflow: hidden; margin-top: 6px; font-size: 19px; font-weight: 800; line-height: 27px; text-overflow: ellipsis; white-space: nowrap; }
.home-overview-warehouse { overflow: hidden; margin-top: 6px; font-size: 11px; line-height: 17px; text-overflow: ellipsis; white-space: nowrap; opacity: 0.82; }
.home-overview-status { position: relative; z-index: 1; display: flex; width: 68px; height: 68px; flex-shrink: 0; flex-direction: column; align-items: center; justify-content: center; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 18px; background: rgba(255, 255, 255, 0.15); }
.home-overview-status-value { font-size: 25px; font-weight: 800; line-height: 28px; }
.home-overview-status-label { margin-top: 2px; font-size: 9px; line-height: 14px; opacity: 0.88; }
.home-group + .home-group { margin-top: 20px; }
.home-group-title { display: flex; align-items: center; margin-bottom: 11px; color: #27384d; font-size: 16px; font-weight: 700; line-height: 24px; }
.home-group-mark { width: 4px; height: 20px; flex-shrink: 0; margin-right: 10px; background: #1769e8; }
.home-group-mark-inventory { background: #e49a16; }
.home-group-mark-relabel { background: #8b5cf6; }
.home-group-mark-query { background: #0d9488; }
.home-group-count { display: inline-flex; box-sizing: border-box; min-width: 20px; height: 20px; margin-left: 8px; padding: 0 5px; align-items: center; justify-content: center; border-radius: 10px; color: #607086; font-size: 10px; line-height: 20px; background: #e4eaf1; }
.home-group-disabled .home-group-title { color: #7b8795; }
.home-group-disabled .home-group-mark { background: #aeb8c4; }
.home-operation-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.home-operation { position: relative; display: flex; box-sizing: border-box; min-width: 0; height: 96px; padding: 10px 5px 9px; flex-direction: column; align-items: center; justify-content: center; border: 1px solid #dce3eb; border-radius: 4px; color: #334155; background: #ffffff; }
.home-operation { position: relative; display: flex; box-sizing: border-box; width: 100%; min-width: 0; height: 108px; padding: 9px 4px 8px; align-items: center; flex-direction: column; justify-content: center; border: 1px solid #dce3eb; border-radius: 12px; color: #334155; text-align: center; background: #ffffff; box-shadow: 0 3px 10px rgba(35, 62, 98, 0.06); }
.home-operation:active { border-color: #9bbcf2; background: #edf4ff; }
.home-operation-primary { border-color: #8cb5f5; color: #135ee8; background: #edf4ff; }
.home-operation-primary { height: 88px; padding: 12px 16px; grid-column: 1 / -1; flex-direction: row; justify-content: flex-start; gap: 12px; border-color: #8cb5f5; border-radius: 14px; color: #135ee8; text-align: left; background: linear-gradient(145deg, #f7faff, #eaf2ff); box-shadow: 0 4px 12px rgba(19, 94, 232, 0.1); }
.home-operation-disabled, .home-operation-disabled[disabled] { border-color: #e1e6ec; color: #8b97a5; opacity: 1; background: #f1f3f6; }
.home-operation-disabled:active { border-color: #e1e6ec; background: #f1f3f6; }
.home-operation-icon { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; font-size: 20px; font-weight: 700; }
.home-operation-name { display: block; width: 100%; margin-top: 3px; overflow: hidden; font-size: 14px; line-height: 20px; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.home-operation-pending { color: #52657c; font-size: 11px; line-height: 16px; }
.home-operation-unavailable { color: #929dab; font-size: 11px; line-height: 16px; }
.home-operation-icon { display: flex; width: 40px; height: 40px; flex-shrink: 0; align-items: center; justify-content: center; border-radius: 10px; font-size: 20px; font-weight: 700; background: rgba(255, 255, 255, 0.75); }
.home-operation-name { display: -webkit-box; width: 100%; min-height: 34px; margin-top: 5px; overflow: hidden; color: inherit; font-size: 12px; font-weight: 650; line-height: 17px; text-align: center; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.home-operation-pending, .home-operation-unavailable { width: fit-content; margin-top: 3px; padding: 1px 6px; border-radius: 99px; font-size: 9px; line-height: 14px; }
.home-operation-pending { color: #135ee8; background: #dceaff; }
.home-operation-unavailable { color: #7d8997; background: #e5e9ee; }
.home-operation-primary .home-operation-icon { width: 46px; height: 46px; }
.home-operation-primary .home-operation-name { width: auto; min-width: 0; min-height: 0; margin-top: 0; flex: 1; font-size: 15px; line-height: 21px; text-align: left; }
.home-operation-primary .home-operation-pending { flex-shrink: 0; margin-top: 0; padding: 3px 8px; font-size: 10px; line-height: 16px; }
.header-menu-sheet { box-sizing: border-box; width: 100vw; max-width: 480px; }
.header-menu-content { padding-top: 1px; }
@@ -772,16 +791,27 @@
.operation-info { display: flex; min-width: 0; flex: 1; flex-direction: column; margin-left: 11px; }
.inline-back { height: 48px; padding: 0 4px; color: #42617f; font-size: 13px; line-height: 48px; background: transparent; }
.module-hero { display: flex; align-items: center; margin-top: 5px; padding: 17px; border-radius: 13px; background: #e7f0ff; }
.module-hero { position: relative; display: flex; align-items: center; margin-top: 5px; padding: 17px; overflow: hidden; border: 1px solid rgba(23, 105, 232, 0.09); border-radius: 16px; background: #e7f0ff; box-shadow: 0 5px 16px rgba(32, 67, 111, 0.07); }
.module-hero-inbound { background: #e1f7f0; }
.module-hero-inventory { background: #fff3da; }
.module-hero-relabel { background: #f1eafe; }
.module-hero-query { background: #f0e9ff; }
.module-hero-icon { width: 52px; height: 52px; flex-shrink: 0; margin-right: 13px; border-radius: 14px; color: #ffffff; font-size: 18px; background: $primary; }
.module-hero-inbound .module-hero-icon { background: #0d9488; }
.module-hero-inventory .module-hero-icon { background: #d68c0c; }
.module-hero-relabel .module-hero-icon { background: #7c3aed; }
.module-hero-query .module-hero-icon { background: #6d55c7; }
.module-hero-copy { min-width: 0; flex: 1; }
.module-hero-title { font-size: 18px; font-weight: 700; }
.module-hero-desc { margin-top: 4px; color: #617187; font-size: 11px; }
.module-hero-desc { margin-top: 4px; color: #617187; font-size: 11px; line-height: 17px; }
.module-hero-count { flex-shrink: 0; margin-left: 8px; padding: 4px 7px; border-radius: 99px; color: #49627d; font-size: 9px; line-height: 15px; background: rgba(255, 255, 255, 0.72); }
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 10px; margin: 22px 2px 11px; }
.section-heading .group-title { margin: 0; }
.section-heading-hint { color: #8190a3; font-size: 10px; line-height: 16px; }
.group-title { display: block; margin: 22px 2px 11px; }
.operation-list { overflow: hidden; border-radius: 12px; background: #ffffff; box-shadow: 0 4px 15px rgba(32, 67, 111, 0.06); }
.operation-item, .history-item { display: flex; align-items: center; box-sizing: border-box; width: 100%; min-height: 72px; padding: 12px; border-bottom: 1px solid #edf0f3; text-align: left; background: #ffffff; }
.operation-item, .history-item { display: flex; align-items: center; box-sizing: border-box; width: 100%; min-height: 78px; padding: 13px 12px; border-bottom: 1px solid #edf0f3; text-align: left; background: #ffffff; }
.operation-item:active { background: #f2f7ff; }
.operation-item-disabled, .operation-item-disabled[disabled] { color: #8d98a6; opacity: 1; background: #f2f4f6; }
.operation-item-disabled .operation-icon { color: #8b97a5; background: #e3e7eb; }
.operation-item-disabled .operation-desc { color: #929daa; }
@@ -789,6 +819,9 @@
.operation-icon { width: 39px; height: 39px; flex-shrink: 0; border-radius: 10px; color: #366cae; font-size: 12px; background: #edf4fd; }
.operation-name { font-size: 13px; font-weight: 700; }
.operation-desc { margin-top: 4px; color: $text-secondary; font-size: 11px; line-height: 17px; }
.operation-ready, .operation-locked { flex-shrink: 0; padding: 3px 6px; border-radius: 99px; font-size: 9px; line-height: 15px; }
.operation-ready { color: #0b6f65; background: #dff7f0; }
.operation-locked { color: #7c8795; background: #e5e9ee; }
.operation-count { min-width: 22px; height: 22px; border-radius: 11px; color: #ffffff; font-size: 11px; line-height: 22px; text-align: center; background: $danger; }
.operation-prototype { flex-shrink: 0; padding: 5px 7px; border-radius: 5px; color: #737f8d; font-size: 11px; background: #e1e5e9; }
.operation-arrow { margin-left: 8px; color: #9aa5b1; font-size: 21px; }
@@ -858,7 +891,7 @@
.profile-list { margin-top: 14px; padding: 0 14px; border-radius: 12px; background: #ffffff; }
.profile-row { display: flex; align-items: center; justify-content: space-between; min-height: 52px; border-bottom: 1px solid $border; font-size: 13px; }
.profile-row:last-child { border-bottom: 0; }
.profile-value { color: $text-secondary; font-size: 12px; }
.profile-value { max-width: 66%; color: $text-secondary; font-size: 12px; line-height: 18px; text-align: right; overflow-wrap: anywhere; }
.profile-action, .logout-action { box-sizing: border-box; width: 100%; height: 50px; margin-top: 14px; padding: 0 15px; border-radius: 11px; font-size: 13px; line-height: 50px; text-align: left; background: #ffffff; }
.profile-action { display: flex; justify-content: space-between; color: $text; }
.logout-action { color: $danger; text-align: center; }
+191
View File
@@ -0,0 +1,191 @@
const updateEndpoint = String(import.meta.env.VITE_PDA_UPDATE_URL || '').trim()
let checking = false
function request(options) {
return new Promise((resolve, reject) => {
uni.request({ ...options, success: resolve, fail: reject })
})
}
function getRuntimeInfo() {
return new Promise((resolve, reject) => {
if (typeof plus === 'undefined' || !plus.runtime) {
reject(new Error('当前环境不支持 App 在线更新'))
return
}
plus.runtime.getProperty(plus.runtime.appid, (properties) => {
resolve({
appid: plus.runtime.appid,
appVersion: String(plus.runtime.version || ''),
appVersionCode: Number(plus.runtime.versionCode || 0),
wgtVersion: String(properties?.version || plus.runtime.version || '')
})
})
})
}
function normalizeUpdate(payload) {
const value = payload && typeof payload === 'object' ? payload : {}
if (value.update !== true) return null
const packageType = String(value.packageType || '').toLowerCase()
const downloadUrl = String(value.downloadUrl || '').trim()
const versionName = String(value.versionName || '').trim()
if (!['apk', 'wgt'].includes(packageType) || !downloadUrl || !/^\d+(\.\d+)+$/.test(versionName)) {
throw new Error('更新服务返回了无效的版本信息')
}
if (!/^https:\/\//i.test(downloadUrl)) throw new Error('更新包必须使用 HTTPS 地址')
return {
packageType,
downloadUrl,
versionName,
versionCode: Number(value.versionCode || 0),
mandatory: value.mandatory === true,
releaseNotes: String(value.releaseNotes || '').trim()
}
}
function compareVersions(left, right) {
const leftParts = String(left).split('.').map(Number)
const rightParts = String(right).split('.').map(Number)
const length = Math.max(leftParts.length, rightParts.length)
for (let index = 0; index < length; index += 1) {
const difference = (leftParts[index] || 0) - (rightParts[index] || 0)
if (difference !== 0) return difference
}
return 0
}
function isNewerUpdate(update, runtime) {
if (update.packageType === 'apk') {
return Number.isInteger(update.versionCode) && update.versionCode > runtime.appVersionCode
}
return compareVersions(update.versionName, runtime.wgtVersion) > 0
}
function confirmUpdate(update) {
const notes = update.releaseNotes || '修复已知问题并提升作业稳定性。'
return new Promise((resolve) => {
uni.showModal({
title: `${update.mandatory ? '必须' : '发现'}更新 ${update.versionName}`,
content: notes,
confirmText: '立即更新',
cancelText: '稍后再说',
showCancel: !update.mandatory,
success: ({ confirm }) => resolve(Boolean(confirm)),
fail: () => resolve(false)
})
})
}
function downloadUpdate(update) {
return new Promise((resolve, reject) => {
uni.showLoading({ title: '正在下载 0%', mask: true })
const task = uni.downloadFile({
url: update.downloadUrl,
success: (result) => {
if (result.statusCode === 200 && result.tempFilePath) {
resolve(result.tempFilePath)
return
}
reject(new Error(`更新包下载失败(${result.statusCode || '未知状态'})`))
},
fail: () => reject(new Error('更新包下载失败,请检查网络后重试')),
complete: () => uni.hideLoading()
})
task.onProgressUpdate?.(({ progress }) => {
uni.showLoading({ title: `正在下载 ${progress}%`, mask: true })
})
})
}
function installUpdate(filePath) {
return new Promise((resolve, reject) => {
plus.runtime.install(
filePath,
{ force: false },
resolve,
() => reject(new Error('更新包安装失败,请重新下载'))
)
})
}
function showFailure(error, mandatory) {
const message = error?.message || '检查更新失败,请稍后重试'
if (!mandatory) {
uni.showToast({ title: message, icon: 'none', duration: 3000 })
return Promise.resolve(false)
}
return new Promise((resolve) => {
uni.showModal({
title: '更新失败',
content: message,
confirmText: '重新尝试',
showCancel: false,
complete: () => resolve(true)
})
})
}
async function runUpdate(update) {
const accepted = await confirmUpdate(update)
if (!accepted) return
try {
const filePath = await downloadUpdate(update)
await installUpdate(filePath)
if (update.packageType === 'wgt') {
uni.showModal({
title: '更新完成',
content: '应用将立即重启以启用新版本。',
showCancel: false,
complete: () => plus.runtime.restart()
})
}
} catch (error) {
const retry = await showFailure(error, update.mandatory)
if (retry) await runUpdate(update)
}
}
export async function checkForAppUpdate({ manual = false } = {}) {
if (checking) return
if (!updateEndpoint) {
if (manual) uni.showToast({ title: '未配置更新服务', icon: 'none' })
return
}
checking = true
try {
if (!/^https:\/\//i.test(updateEndpoint)) throw new Error('更新服务必须使用 HTTPS 地址')
const runtime = await getRuntimeInfo()
const response = await request({
url: updateEndpoint,
method: 'GET',
data: {
appid: runtime.appid,
platform: 'android',
appVersion: runtime.appVersion,
appVersionCode: runtime.appVersionCode,
wgtVersion: runtime.wgtVersion
}
})
if (response.statusCode !== 200) throw new Error('更新服务暂时不可用')
const update = normalizeUpdate(response.data?.data ?? response.data)
if (update && isNewerUpdate(update, runtime)) {
await runUpdate(update)
} else if (manual) {
uni.showToast({ title: '当前已是最新版本', icon: 'none' })
}
} catch (error) {
if (manual) await showFailure(error, false)
} finally {
checking = false
}
}
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('App-Plus checks the independent overseas update channel after launch', async () => {
const [app, updater, env] = await Promise.all([
read('../src/App.vue'),
read('../src/utils/app-update.js'),
read('../.env.example')
])
assert.match(app, /#ifdef APP-PLUS[\s\S]*?checkForAppUpdate\(\)/)
assert.match(env, /VITE_PDA_UPDATE_URL=https:\/\//)
assert.match(updater, /plus\.runtime\.getProperty/)
assert.match(updater, /appid:[\s\S]*?platform: 'android'[\s\S]*?appVersion:[\s\S]*?appVersionCode:[\s\S]*?wgtVersion:/)
})
test('overseas online update supports mandatory APK and wgt installation flows', async () => {
const updater = await read('../src/utils/app-update.js')
assert.match(updater, /\['apk', 'wgt'\]\.includes\(packageType\)/)
assert.match(updater, /update\.versionCode > runtime\.appVersionCode/)
assert.match(updater, /compareVersions\(update\.versionName, runtime\.wgtVersion\) > 0/)
assert.match(updater, /更新包必须使用 HTTPS 地址/)
assert.match(updater, /showCancel: !update\.mandatory/)
assert.match(updater, /uni\.downloadFile\([\s\S]*?onProgressUpdate/)
assert.match(updater, /plus\.runtime\.install/)
assert.match(updater, /packageType === 'wgt'[\s\S]*?plus\.runtime\.restart/)
assert.match(updater, /if \(retry\) await runUpdate\(update\)/)
})
+43 -8
View File
@@ -5,8 +5,8 @@ import { readFile } from 'node:fs/promises'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('overseas PDA is an independent Vue 3 app with separate packaging identity', async () => {
const [manifest, pages, pkg, vite] = await Promise.all([
read('../src/manifest.json'), read('../src/pages.json'), read('../package.json'), read('../vite.config.js')
const [manifest, pages, pkg, vite, styles] = await Promise.all([
read('../src/manifest.json'), read('../src/pages.json'), read('../package.json'), read('../vite.config.js'), read('../src/styles/pda.scss')
])
assert.match(manifest, /"name"\s*:\s*"柏悦物流\s*PDA\s*海外版"/)
assert.match(manifest, /"packagename"\s*:\s*"com\.baiyuelogistics\.pda\.overseas"/)
@@ -17,6 +17,10 @@ test('overseas PDA is an independent Vue 3 app with separate packaging identity'
assert.match(vite, /OVERSEAS_API_PROXY_TARGET/)
assert.match(vite, /\/overseas-api/)
assert.match(vite, /replace\(\/\^\\\/overseas-api\//)
assert.match(vite, /removeHeader\('origin'\)/)
assert.match(vite, /removeHeader\('referer'\)/)
assert.match(styles, /home-operation-grid[^}]*grid-template-columns:\s*repeat\(3/)
assert.match(styles, /home-operation[^}]*width:\s*100%/)
})
test('overseas workbench contains only confirmed inbound and in-warehouse domains', async () => {
@@ -34,7 +38,7 @@ test('login has no committed demo credentials and inbound operation implements t
read('../src/pages/login/login.vue'), read('../src/config/runtime.js'),
read('../src/pages/operation/operation.vue'), read('../src/api/inbound.js'), read('../src/api/receiving-adapter.js'), read('../src/api/overseas.js')
])
assert.match(login, /柏悦物流海外版/)
assert.match(login, /\$t\('app\.title'\)/)
assert.doesNotMatch(login, /ChangeMe|platform_admin/)
assert.match(login, /DEV_LOGIN\.username|DEV_LOGIN\.password/)
assert.match(runtime, /runtimeEnvironment\.DEV|VITE_PDA_DEV_USERNAME|VITE_PDA_DEV_PASSWORD/)
@@ -42,8 +46,14 @@ test('login has no committed demo credentials and inbound operation implements t
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_AUTH_API_BASE_URL/)
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_OVERSEAS_API_BASE_URL/)
assert.match(operation, /startScanBroadcast/)
for (const action of ['prepareReceivingContainer', 'getCurrentReceiving', 'resolveCurrentSku', 'resolveCurrentCarton', 'resolveCurrentPallet', 'receiveCurrentBulk', 'receiveCurrentCarton', 'completeCurrentReceiving']) assert.match(operation, new RegExp(action))
for (const path of ['/candidates', '/prepare', '/unload-photos/files', '/sign', '/skus/resolve', '/cartons/resolve', '/pallets/resolve', '/receipts/bulk', '/receipts/carton', '/complete']) assert.match(inbound, new RegExp(path.replaceAll('/', '\\/')))
for (const action of ['prepareReceivingContainer', 'getCurrentReceiving', 'resolveCurrentCarton', 'resolveCurrentPallet', 'receiveCurrentBulk', 'receiveCurrentCarton', 'completeCurrentReceiving']) assert.match(operation, new RegExp(action))
for (const path of ['/candidates', '/prepare', '/unload-photos/files', '/sign', '/cartons/resolve', '/pallets/resolve', '/receipts/bulk', '/receipts/carton', '/complete']) assert.match(inbound, new RegExp(path.replaceAll('/', '\\/')))
assert.doesNotMatch(operation, /resolveCurrentSku|resolvedSku|skuInput|selectedInboundCode|chooseInbound|class="inbound-tabs"/)
assert.doesNotMatch(operation, /InboundList|柜内入仓码/)
assert.doesNotMatch(inbound, /skus\/resolve/)
assert.match(operation, /const payload = isFba \? \{ inboundCode: this\.activeInbound\.inboundCode, palletCode: this\.resolvedPallet\.palletCode, packageCount: Number\(this\.packageCount\) \}/)
assert.doesNotMatch(operation, /skuCode/)
assert.match(operation, /clearReceiptContext\(\)[\s\S]*activeScanTarget = 'carton'/)
for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) assert.match(adapter, new RegExp(role))
for (const forbidden of ['customerId', 'planNo', 'allocationId', 'warehouseId', 'locationId']) assert.doesNotMatch(inbound, new RegExp(forbidden))
assert.match(operation, /isReceivingConflict|recoverCurrentAfterConflict/)
@@ -56,7 +66,32 @@ test('unimplemented warehouse operations cannot submit prototype scans', async (
read('../src/pages/operation/operation.vue'), read('../src/pages/home/home.vue'), read('../src/pages/module/module.vue')
])
assert.doesNotMatch(operation, /prototypeScan|prototypeInput/)
assert.match(operation, /功能暂未开放|扫码不会提交/)
assert.match(home, /暂未开放/)
assert.match(module, /当前仅开放拆柜收货,其他作业暂未开放/)
assert.match(operation, /operation\.unavailableTitle|operation\.unavailableDescription/)
assert.match(home, /common\.unavailable/)
assert.match(module, /module\.onlyReceiving/)
})
test('receiving phases use a compiled container summary with cabinet and aggregate facts', async () => {
const [operation, summary] = await Promise.all([
read('../src/pages/operation/operation.vue'), read('../src/components/ReceivingSessionSummary.vue')
])
assert.match(operation, /import ReceivingSessionSummary/)
assert.equal((operation.match(/<ReceivingSessionSummary/g) || []).length, 3)
assert.doesNotMatch(operation, /const SessionSummary|template:\s*`<view class="container-card summary-card/)
for (const fact of ['containerNo', 'inboundCodeCount', 'plannedPalletCount', 'destinationSummary', 'totalPackages', 'receivedPackages', 'pendingPackages']) assert.match(summary, new RegExp(fact))
assert.match(summary, /receiving-summary-metrics/)
assert.match(summary, /grid-template-columns:repeat\(4/)
assert.match(summary, /padding:10px 12px/)
})
test('Chinese English and Spanish locale resources are installed and selectable', async () => {
const [main, i18n, zh, en, es, profile, inbound] = await Promise.all([
read('../src/main.js'), read('../src/i18n/index.js'), read('../src/i18n/locales/zh-CN.js'),
read('../src/i18n/locales/en.js'), read('../src/i18n/locales/es.js'), read('../src/pages/profile/profile.vue'), read('../src/api/inbound.js')
])
assert.match(main, /app\.use\(i18n\)/)
for (const locale of [zh, en, es]) for (const key of ['operationPhase', 'receivingSummary', 'warehouseOperator']) assert.match(locale, new RegExp(key))
assert.match(i18n, /SUPPORTED_LOCALES = \['zh-CN', 'en', 'es'\]/)
assert.match(profile, /setLocale|LOCALE_OPTIONS/)
assert.match(inbound, /warehouse-profile/)
})
+16 -7
View File
@@ -45,18 +45,16 @@ test('three fixed receiving photo roles and string file IDs are frozen', () => {
assert.doesNotMatch(inbound, /customerId|planNo|allocationId|warehouseId|locationId/)
})
test('camera or album replaces only one fixed slot and failed replacement preserves its previous success', () => {
assert.match(page, /choosePhotos\(index, sourceType\)[\s\S]*uni\.chooseImage\(\{ count: 1, sourceType: \[sourceType\]/)
assert.match(page, /choosePhotos\(index, 'camera'\)/)
assert.match(page, /choosePhotos\(index, 'album'\)/)
test('camera replaces only one fixed slot and failed retake preserves its previous success', () => {
assert.match(page, /choosePhoto\(index\)[\s\S]*uni\.chooseImage\(\{ count: 1, sourceType: \['camera'\]/)
assert.doesNotMatch(page, /sourceType:\s*\['album'\]|从相册选择|换照片/)
assert.match(page, /async replacePhoto\(index, path\)[\s\S]*const previous = receiving\.photoSlots\[index\]/)
assert.match(page, /await uploadCurrentUnloadPhoto\(path\)[\s\S]*fileId: String\(file\.fileId\)[\s\S]*localPath: path/)
assert.match(page, /previous\?\.fileId \? \{ \.\.\.previous, status: 'error'/)
assert.match(page, /status: 'uploading'/)
assert.match(page, /status: 'error'/)
assert.match(page, /已保留原图/)
assert.match(page, /photoSlots\[index\]\?\.fileId \? '重拍' : '拍照'/)
assert.match(page, /photoSlots\[index\]\?\.fileId \? '重新选择' : '从相册选择'/)
assert.match(page, /photoStateLabel\(photo\)[\s\S]*operation\.retakeFailedKept[\s\S]*operation\.uploadFailed/)
assert.match(page, /photoSlots\[index\]\?\.fileId \? \$t\('operation\.retake'\) : \$t\('operation\.takePhoto'\)/)
assert.match(page, /previewPhoto\(index\)/)
assert.match(page, /uploadedPhotoCount\(\) \{ return this\.photoSlots\.filter\(\(item\) => item\?\.fileId\)\.length \}/)
assert.match(page, /photosReady\(\) \{ return this\.uploadedPhotoCount === 3 && !this\.photoSlots\.some/)
@@ -64,6 +62,11 @@ test('camera or album replaces only one fixed slot and failed replacement preser
assert.match(page, /async signContainer\(\) \{ if \(!this\.photosReady\) return;/)
assert.match(page, /const photos = this\.photoSlots\.map\(/)
assert.doesNotMatch(page, /count:\s*9|flatMap\(|photoSlots\[index\]\.push/)
assert.match(page, /\.photo-grid\{display:grid;grid-template-columns:1fr/)
assert.match(page, /grid-template-columns:82px minmax\(0,1fr\) 78px/)
assert.match(page, /class="photo-role-info"/)
assert.match(page, /onShow\(\)[\s\S]*this\.inboundMode && !this\.bootstrapped[\s\S]*this\.bootstrap\(\)/)
assert.match(page, /await this\.applyCurrentReceiving\(current\); this\.bootstrapped = true/)
})
test('local thumbnail, preview, and authenticated session restore are wired to the frozen contract', () => {
@@ -71,3 +74,9 @@ test('local thumbnail, preview, and authenticated session restore are wired to t
assert.deepEqual(photoUiBlockers().map((item) => item.code), [])
assert.ok(photoUiBlockers().every((item) => item.deploymentBlocker === true && item.reason))
})
test('fixed receiving action leaves enough scroll-safe space above the PDA bottom bar', () => {
assert.match(page, /receiving-content\{[^}]*padding-bottom:calc\(152px \+ env\(safe-area-inset-bottom,0px\)\)/)
assert.match(page, /primary-button,\.secondary-button,\.ghost-button\{[^}]*align-items:center;justify-content:center[^}]*line-height:normal;text-align:center/)
assert.match(page, /sticky-action button\{[^}]*align-items:center;justify-content:center;line-height:normal;text-align:center/)
})
+9 -5
View File
@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import {
PDA_AUTHORITY, PDA_ROLE, replaceTokens, restoreSession, session,
PDA_AUTHORITY, PDA_ROLE, applyWarehouseProfile, replaceTokens, restoreSession, session,
decodeAccessToken, signInWithTokens, signOut, validatePdaTokenPair
} from '../src/stores/session.js'
import { receiving } from '../src/stores/receiving.js'
@@ -63,6 +63,14 @@ test('JWT decoding preserves unquoted snowflake subjects and warehouse IDs exact
assert.equal(decoded.overseas_warehouse_id, '2091919753155878914')
})
test('warehouse master profile replaces the technical ID only for the token-bound warehouse', () => {
storage(); signOut(); signInWithTokens(tokenPair())
applyWarehouseProfile({ warehouseId: '900', warehouseCode: 'US-LAX-01', warehouseName: '洛杉矶一号仓' })
assert.equal(session.currentUser.warehouse, '洛杉矶一号仓')
assert.equal(session.currentUser.warehouseCode, 'US-LAX-01')
assert.throws(() => applyWarehouseProfile({ warehouseId: '901', warehouseName: '其他仓' }), /不一致/)
})
test('login fails closed for wrong role, authority, warehouse, application, or token subject', () => {
storage(); signOut()
const invalidPairs = [
@@ -123,9 +131,7 @@ test('sign out clears tokens, frozen warehouse identity, and every receiving dra
receiving.current = { containerNo: 'CONT-1', status: 'RECEIVING' }
receiving.selected = receiving.current
receiving.photoSlots = [{ fileId: '1' }, null, null]
receiving.selectedInboundCode = 'IN-1'
receiving.resolvedCarton = { cartonMark: 'BOX-1' }
receiving.resolvedSku = { skuCode: 'SKU-1' }
receiving.resolvedPallet = { palletCode: 'PALLET-1' }
receiving.lastReceipt = { acceptedPackages: 1 }
@@ -139,9 +145,7 @@ test('sign out clears tokens, frozen warehouse identity, and every receiving dra
assert.equal(receiving.current, null)
assert.equal(receiving.selected, null)
assert.deepEqual(receiving.photoSlots, [null, null, null])
assert.equal(receiving.selectedInboundCode, '')
assert.equal(receiving.resolvedCarton, null)
assert.equal(receiving.resolvedSku, null)
assert.equal(receiving.resolvedPallet, null)
assert.equal(receiving.lastReceipt, null)
})
+5
View File
@@ -6,6 +6,11 @@ function loggedProxy(name, options) {
...options,
configure(proxy) {
proxy.on('proxyReq', (proxyRequest, request) => {
// The browser talks only to this same-origin Vite server. Forwarding its
// localhost Origin/Referer would make the upstream gateway run CORS
// validation again and reject the proxied request before Auth handles it.
proxyRequest.removeHeader('origin')
proxyRequest.removeHeader('referer')
console.info(`[Overseas PDA H5 proxy] ${name}`, `${request.method} ${request.url}`, '->', `${options.target}${proxyRequest.path}`)
})
proxy.on('proxyRes', (response, request) => {