diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..5481b4e
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+VITE_API_BASE_URL=http://127.0.0.1:9000/api
+VITE_H5_API_BASE_URL=/gateway-api
+API_PROXY_TARGET=http://127.0.0.1:9000
+# 国内 PDA 独立更新清单地址;仅 App-Plus 启动时请求。
+VITE_PDA_UPDATE_URL=https://updates.example.com/pda/domestic/latest
diff --git a/AGENTS.md b/AGENTS.md
index cc85b95..c94d497 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,19 +6,29 @@
- README 记录的登录、箱号选择、扫码、整单或部分拣货、异常原因和完成提交是当前静态原型范围;项目使用 uni-app Vue 3,业务页面采用 Vue 3 兼容的 Options API。
- 本项目负责现场执行体验,不承担管理后台的订单配置、客户工作台或通用库存管理能力。
-## 当前阶段门禁
+## 开发与发布边界
-- 当前已有 uni-app Vue 3 静态原型和 `uni_modules/`,没有项目根级依赖清单或可核实的 HBuilderX/Android 构建命令;现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。
-- 目标 PDA 型号、操作系统、扫描头接入方式、离线要求、发布方式及一期同步范围仍需确认。设备与方案确认前,不绑定单一厂商 SDK,也不假设摄像头扫码可替代硬件扫描头。
-- 明确使用的工具链和发布流程后,补充准确的依赖版本、安装、调试、测试和打包命令,并同步更新 `README.md` 与本文件。
+- 当前已有 uni-app Vue 3 页面、`uni_modules/`、`vite.config.js`、`.env.example` 和 H5 同源代理配置。首页当前只展示“入库 → 一件代发”和“出库 → 出库拣货”;“库位查询”等其余能力保留源码与路由但不展示。项目仍没有根级依赖清单、仓库内 npm 构建命令或可核实的 Android 构建命令。H5 已使用 HBuilderX 5.15 自带编译器和 `cli.exe launch web` 验证,具体命令见 `README.md`。现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。
+- PDA 采用 uni-app 开发并支持在线更新;Android 硬件扫描已确认使用广播模式并已接入:Action=`android.intent.action.SCANRESULT`、字符串 Extra=`value`。设备型号不构成开发、发布或验收门禁;继续通过设备适配层接入,不绑定单一厂商 SDK。
+- 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。
+- H5 调试使用 HBuilderX 5.15 自带的 uni-app Vue 3 编译器;Android 发布流程仍待明确后补充准确的依赖版本、打包命令,并同步更新 `README.md` 与本文件。
+- 在 `package.json` 与锁文件正式补齐前,`vite.config.js` 只证明目标配置,不证明依赖可安装;维护者可运行仓库当前可执行的 `node --test tests/*.test.mjs`,或使用已安装的 HBuilderX 5.15 CLI 运行 H5,不得从海外 PDA 或全局缓存猜测国内 PDA 的 uni-app/Vite 版本。
+- App API 根只读 `VITE_API_BASE_URL`;H5 固定优先使用 `VITE_H5_API_BASE_URL=/gateway-api` 并由 `vite.config.js` 同源代理、重写为 Gateway `/api`,避免截获项目自身 `api/` 前端模块。环境地址不写入源码,切换构建环境后不得沿用登录缓存中的旧 API 地址。
+- 国内 Android App 启动后仅在 `APP-PLUS` 调用 `utils/app-update.js`,通过独立 `VITE_PDA_UPDATE_URL` 检查更新;更新服务和下载地址必须为 HTTPS。APK 以整数 `versionCode` 判新,WGT 以 `versionName` 判新;强制更新不可跳过,WGT 安装后立即重启。国内 AppID、包名、签名、版本和更新通道不得复用海外 PDA。
## 作业流程原则
- 扫码是主路径:输入焦点、连续扫描、防抖、重复码、无效码、错箱和扫描反馈必须作为一等场景处理。
-- 明确区分整单拣货、部分拣货、异常挂起和完成提交;关键状态转换需服务端确认,不能只依赖本地页面状态。
-- 作业提交应具备幂等标识,防止弱网、重复点击或重放造成重复扣减与重复完成。
-- 离线队列只有在业务批准后才能实现;队列必须可观察、可重试、可取消,并能识别冲突而不是静默覆盖服务端状态。
-- 与管理后台头程订单、仓库任务和 `services/api` 对齐稳定标识、状态机、时间和异常码,禁止在前端自行发明另一套业务状态。
+- 普通散箱拣货的扫描身份是后端下发的稳定箱唛箱码 `boxCode`,一次有效且未重复的箱码扫描表示拣出一箱;一件代发已封托的 `SEALED_PALLET` 明细允许扫描系统 `palletNo`、扫描托内任一成员 `boxCode` 或在明细卡片手工确认整托,但三种入口都必须按计划快照中的 `palletNo` 以 `PICK_PALLET` 一次性提交,不能逐箱累计、部分拣货或跳拣;只按权威 `unitType=SEALED_PALLET` 识别整托,不能从资源 ID 或 `indivisible` 猜测。SKU、商品件数、入库单号和页面展示文案只用于辅助识别,不得作为扫码匹配键。
+- 拣货明细卡片按现场找货优先级展示库位、托盘码或入仓码、待拣箱数和处理状态;库位从明细 `inventoryResources.locationCode` 去重汇总,缺失时明确显示“库位 —”。商品名称和 SKU 数据可以保留在响应模型中,但不占用 PDA 拣货卡片主区域。
+- 进入任务时由服务端标记“拣货中”;每次有效箱码或托盘码扫描,以及确认拣出、部分拣货、跳拣操作,都必须立即写入服务端,设备持久化草稿只用于恢复页面状态。底部提交只原子收尾尚未处理的明细并进入装柜取证,不重复提交已落库动作。
+- 允许部分拣货和跳拣,但每个未完成项必须填写异常原因;存在未解决异常时任务挂起,不能完成或推进 `LOADED`。
+- 作业提交使用对员工不可见的轻量幂等标识和服务端版本;重复提交返回原结果,版本冲突提示刷新后重试,不静默覆盖。
+- 提交成功或明确放弃后清理草稿;退出应用或设备重启后同一作业员、任务和柜次可恢复,其他账号不得继承。
+- PDA 仅允许现场拍照;Admin 可通过独立受权入口人工上传。五类证据各至少 1 张有效照片,同类型允许多张追加;缩略图使用右上角“×”作为员工可见的删除入口,不显示“作废”文案,服务端仍对错误或重复照片保留删除原因、操作人和时间,不硬删除。设备端证据查询、上传、预览和删除统一按当前 `taskId + warehouseId` 走任务专用 API,服务端再绑定真实柜 `containerBookingId`;不得调用旧入仓单/客户作用域证据接口。
+- 与管理后台头程订单、仓库任务和 `baiyue-firstleg` Controller/OpenAPI 对齐稳定标识、状态机、时间和异常码,禁止在前端自行发明另一套业务状态。
+- 一件代发清点组托使用独立 `/first-leg/pda-dropship` 任务链:箱码先经只读识别再确认实收;系统托盘码可先于或晚于箱码扫描。`count` 携托盘码时直接绑定,箱码先扫则进入待绑托区并由 `bind-pending` 批量绑定;换托只使用服务端 `cartonId`,封托后按整托不可拆。只消费 `platform:firstleg:pda:view` 与 `platform:firstleg:pda:work` 权限结果,不在 PDA 自建权限语义。
+- 清点组托使用测量审核预生成的 DP 托盘;扫描托盘通过 `bind-pending` 原子绑定待绑箱。幂等键按操作员、任务、载荷和版本稳定生成,失败重试复用;换托先移回待绑区再扫描目标托盘,封托不可拆分。装柜拣货扫描后等待正式整托 API 成功再更新本地结果。
## 设备、安全与可用性
@@ -32,6 +42,43 @@
## 质量门禁
-- 在真实目标 PDA 上验证扫描速度、连续扫描、断网重连、低电量、前后台切换和重复提交。
+- 在真实目标 PDA 上验证扫描速度、连续扫描、断网阻断与恢复刷新、低电量、前后台切换和重复提交。
- 状态转换、扫码分类与页面契约使用 `node --test tests/*.test.mjs` 验证;新增本地行为时继续补充对应自动化测试。
-- 当前原型没有可核实的 HBuilderX/Android 构建命令,不得声称打包或真机检查已通过;交付说明应明确实际验证方式、设备、系统版本和仍未覆盖的风险。
+- 修改在线更新协议、版本比较、强制更新、安装或重启行为时运行 `node --test tests/app-update.test.mjs`;新增原生模块、权限或不兼容运行时能力必须发布 APK,不能只发布 WGT。
+- 当前没有依赖清单、仓库内 npm 构建命令或可核实的 Android 构建命令;H5 必须按 `README.md` 使用 HBuilderX 5.15 CLI 实际启动后才可声称通过,不得仅凭 `vite.config.js` 声称 H5、App 打包或真机检查已通过。交付说明应明确实际验证方式、设备、系统版本和仍未覆盖的风险。
+
+## 页面与能力快速查询
+
+| 目标 | 入口 | 继续查询 |
+|---|---|---|
+| 页面注册与启动 | `pages.json`、`main.js`、`App.vue` | 全局样式和应用生命周期 |
+| API 环境与 H5 开发代理 | `config/runtime.js`、`.env.example`、`vite.config.js` | App `VITE_API_BASE_URL`;H5 同源 `VITE_H5_API_BASE_URL=/gateway-api`,代理上游 `API_PROXY_TARGET` 并重写为 `/api`;会话恢复不得覆盖当前构建地址 |
+| 部分拣货布局与库位展示 | `pages/picking/picking.vue`、`styles/pda.scss` | 库位为空时不渲染空标签或残留分隔符;部分拣货数量必须小于需求且不超过服务端库存/库位可用量,合同 `tests/partial-layout.test.mjs`、`tests/picking-location-display.test.mjs` |
+| Android APK/WGT 在线更新 | `App.vue`、`utils/app-update.js`、`.env.example`、`manifest.json` | `VITE_PDA_UPDATE_URL` 使用国内独立 HTTPS 通道;合同 `tests/app-update.test.mjs`,H5 不执行更新逻辑 |
+| 登录与会话 | `pages/login/`、`stores/session.js` | 远程鉴权适配和会话清理 |
+| 工作台、仓库选择与模块入口 | `pages/home/`、`components/PortalHeader.vue`、`stores/session.js`、`pages/module/`、`pages/operation/` | 左上角只提供启用国内仓选择,选择结果持久化;`data/demo-data.js` 的 `visible` 控制展示、`available` 控制可操作;隐藏能力不得删除源码或路由 |
+| 待处理任务、服务端就绪与领取 | `pages/tasks/tasks.vue` | `api/pda-loading.js` 的 `listLoadingOrders`、`getContainerPickingTask`、`claimContainerPickingTask`;合并任务和缺少柜号/仓库/版本的任务不可进入 |
+| 一件代发 | `pages/dropship-tasks/dropship-tasks.vue`、`pages/dropship-palletization/dropship-palletization.vue` | `api/pda-dropship.js`;作为入库作业,测量审核按预计托数预生成 DP 托盘码,由 Admin 电脑端合并打印后贴到空托盘;PDA 不承载打印,只直接扫码并展示当前操作托盘;PDA 明确执行逐箱清点并支持先扫箱或先扫托、归托、换托/移回待绑托、逐托封托,以及在托盘未进入装柜计划前解除封托;同一国内作业仓的多票可继续装入同一开放托盘,单票全部归托不等于托盘装满,实物装满后才封托;预计托数与已生成托盘数不一致时失败关闭;公网前缀 `/api/platform/v1/first-leg/pda-dropship/**` |
+| 业务编号展示与错误脱敏 | `utils/user-facing.js`、`tests/user-facing-identifiers.test.mjs` | 任务和作业页优先显示真实集装箱号,缺失时显示柜次号,再缺失显示“集装箱号 —”;不得展示 taskId、inboundId、orderId、loadingPlanId、containerBookingId 或长整型内部 ID,错误仍保留 code、requestId、traceId |
+| 拣货任务与箱码扫码 | `pages/picking/`、`stores/picking.js` | `api/pda-loading.js`、`utils/scan-broadcast.js`、`components/ScanTargetInput.vue`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml` |
+| 一件代发清点组托状态与草稿 | `pages/dropship-palletization/dropship-palletization.vue` | `stores/dropship-palletizing.js`、`utils/dropship-palletizing-state.js`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml`,合同 `tests/dropship-palletizing-*.test.mjs` |
+| 装柜取证与完成 | `pages/picking/picking.vue` 的 `evidence` 阶段 | 正式边界是 `api/pda-loading.js` 的证据查询、预览、上传、作废和 `completeContainerPickingTask` 全部按当前任务与仓库进入;不得保留按入仓单/客户作用域的旧证据调用。上传队列 `utils/evidence-upload-queue.js`,合同 `tests/pda-loading-api.test.mjs`、`tests/picking-evidence-stage.test.mjs`、`tests/seal-save-idempotency.test.mjs` |
+| 扫描诊断 | `pages/scanner-test/` | `utils/scan-broadcast.js` 与广播 Action/Extra |
+| 作业记录与人员 | `pages/history/`、`pages/profile/` | session 与本地敏感数据清理 |
+| 页面与真实 API 合同 | `tests/page-contract.test.mjs`、`tests/pda-loading-api.test.mjs` | 覆盖部分拣货页面、提交门禁、库位不足反馈和国内 PDA 真实接口路径;统一由 `node --test tests/*.test.mjs` 执行 |
+
+## 全量验收索引
+
+当前按 PDA 全量作业链路验收。业务域文档用于核对功能来源和验收证据,不限制本次验收范围;从 `pages.json` 定位页面,沿页面 → `stores/` → `utils/` → 设备适配层检查。本表只描述 PDA 作业端,不代表后台或 Client 已具备对应能力。
+
+| 验收域 | PDA 范围与代码入口 | 验收首读 |
+|---|---|---|---|
+| 作业全链路 | 登录、工作台、一件代发扫码清点/归托/封托、出库扫码拣货、装柜证据、草稿持久化、批量提交、异常和在线更新 | `pages/login/`、`pages/home/`、`pages/dropship-tasks/`、`pages/dropship-palletization/`、`pages/picking/`、`stores/session.js`、`stores/picking.js`、`utils/scan-broadcast.js`、`pages.json` | 按业务选择 `../../docs/订单与客户/`、`../../docs/入库测量与计费/`、`../../docs/国内货柜履约/` 和 `README.md` 的契约与验收规则 |
+
+作业边界:PDA 不承担客户工作台或后台配置;服务端状态、权限、幂等和 `LOADED` 门禁是验收事实来源。发布采用既定 uni-app 在线更新安排。
+
+## 全量验收脚本
+
+- PDA 不单独定义一套“全量 HTTP”入口;PDA 服务链路由 Admin 全量 HTTP 入口中的 `phase3-pda-convergence`、装柜、证据和出仓模块覆盖。
+- 本目录 `node --test tests/*.test.mjs` 仅是 PDA 本地页面/状态质量检查,不代表服务端作业链路已通过。
+- 服务 HTTP 通过后,再按真实 uni-app 页面完成扫码输入、网络阻断/恢复、草稿恢复、批量提交、窄屏和在线更新验收;真机检查属于最后的交互验收。
diff --git a/App.vue b/App.vue
index bee3444..f5ee245 100644
--- a/App.vue
+++ b/App.vue
@@ -1,5 +1,13 @@
diff --git a/pages/dropship-tasks/dropship-tasks.vue b/pages/dropship-tasks/dropship-tasks.vue
new file mode 100644
index 0000000..22d150c
--- /dev/null
+++ b/pages/dropship-tasks/dropship-tasks.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+ 待入库任务{{ loading ? '正在同步' : `共 ${tasks.length} 个任务` }}
+
+
+ {{ error }}
+ 暂无待入库任务
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/history/history.vue b/pages/history/history.vue
index 3c88bd2..ac5c77c 100644
--- a/pages/history/history.vue
+++ b/pages/history/history.vue
@@ -1,7 +1,7 @@
- {{ overview.completedCount }}今日完成{{ overview.pendingBoxCount }}待处理最近作业▤暂无作业记录完成的现场任务会显示在这里✓集装箱拣货{{ entry.box.boxNo }} · {{ entry.box.orderNo }}已完成
+ {{ overview.completedCount }}今日完成{{ overview.pendingBoxCount }}待处理最近作业▤暂无作业记录完成的现场任务会显示在这里✓出库拣货 · {{ containerReference(entry.box) }}已完成
diff --git a/pages/home/home.vue b/pages/home/home.vue
index 05ef28b..7727584 100644
--- a/pages/home/home.vue
+++ b/pages/home/home.vue
@@ -1,6 +1,6 @@
-
+
@@ -20,12 +20,13 @@
diff --git a/pages/login/login.vue b/pages/login/login.vue
index feb21e2..626277a 100644
--- a/pages/login/login.vue
+++ b/pages/login/login.vue
@@ -1,38 +1,66 @@
-
+
柏悦物流 · PDA作业端仓库现场作业系统
工号 / 用户名
密码
- 作业仓库{{ form.warehouse }}⌄
+ 记住账号
{{ error }}
-
- 快捷登录(演示账号)
+
diff --git a/pages/module/module.vue b/pages/module/module.vue
index f8df248..7b22309 100644
--- a/pages/module/module.vue
+++ b/pages/module/module.vue
@@ -5,7 +5,7 @@
{{ currentModule.icon }}{{ currentModule.name }}{{ currentModule.description }}
业务功能
- 第一版本仅开放“出库 → 集装箱拣货”,其他业务将在后续版本接入。
+ 当前展示“一件代发”和“出库拣货”。
@@ -18,11 +18,22 @@
export default {
components: { PortalHeader },
data() { return { user: session.currentUser, moduleKey: 'outbound', overview: getPickingOverview() } },
- computed: { currentModule() { return MODULES.find((module) => module.key === this.moduleKey) || MODULES[0] } },
+ computed: {
+ currentModule() {
+ const module = MODULES.find((item) => item.key === this.moduleKey) || MODULES[0]
+ return { ...module, operations: module.operations.filter((operation) => operation.visible) }
+ }
+ },
onLoad(options) { this.moduleKey = options.key || 'outbound' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() },
methods: {
- openOperation(operation) { if (operation.available) uni.navigateTo({ url: '/pages/picking/picking' }) }
+ openOperation(operation) {
+ if (!operation.available) return
+ const routes = { '一件代发': '/pages/dropship-tasks/dropship-tasks', '出库拣货': '/pages/tasks/tasks' }
+ const url = routes[operation.name]
+ || `/pages/operation/operation?module=${encodeURIComponent(this.moduleKey)}&name=${encodeURIComponent(operation.name)}`
+ uni.navigateTo({ url })
+ }
}
}
diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue
index 8b74163..5d8c823 100644
--- a/pages/picking/picking.vue
+++ b/pages/picking/picking.vue
@@ -4,49 +4,52 @@
-
+
- {{ box.boxNo }}{{ box.orderNo }}
+ {{ containerReference(box) }}
已完成
有草稿
-
+
+
+
+
+
-
+
- 支持硬件广播扫码,也可点击输入后按“完成”确认
{{ lastScanResult.type === 'success' ? '✓' : '!' }}
最近扫码 · {{ lastScanResult.title }}
- {{ lastScanResult.sku }}
+ {{ lastScanResult.boxCode }}
{{ lastScanResult.detail }}
- {{ entry.sku }} · {{ entry.title }}
+ {{ entry.boxCode }} · {{ entry.title }}
@@ -54,18 +57,8 @@
@@ -76,7 +69,7 @@
□
- 该箱号暂无拣货任务
+ 当前集装箱暂无拣货明细
- {{ item.sku }}
+ {{ isSealedPalletItem(item) ? item.palletNo : '拣货明细' }}
已拣 ✓
@@ -93,7 +86,7 @@
已跳拣
- 需拣 {{ item.qty }} {{ item.unit }}
+ 需拣 {{ item.qty }} 箱
· 实拣 {{ partialQuantities[item.id] || 0 }}
@@ -102,28 +95,86 @@
{{ itemDescription(item) }}
- {{ item.location }}
+ 库位 {{ item.location || '—' }}
-
+
+
+
+
-
-
+
+ 已同步服务端
+
-
+
+
+ 装柜证据每类至少 1 张,拍照后自动上传,可继续追加
+ {{ remoteSyncState.label }}
+
+
+
+
+
+
+
+ 图片处理中
+ {{ photo.fileName || '已记录' }}
+
+
+
+
+ {{ evidenceUploadState(slot.type) }}
+
+
+ {{ slot.type === 'SEAL' ? '请填写封签号,保存后上传' : '照片待自动上传' }}
+
+
+
+
+
+
+
+
+ {{ remoteError }}
+
+
+ 状态尚未同步,请刷新重试
+
+
+
+
+ 拣货已完成,装柜状态尚未同步,请刷新重试
+
+
+
+
- {{ summary.pending }} 件待拣
+ {{ summary.pending }} 箱待拣
{{ submitStatusHint }}
-
@@ -141,7 +192,7 @@
@tap="selectBoxFromList(entry)"
>
- {{ entry.box.boxNo }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }}
+ {{ containerReference(entry.box) }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }}
{{ boxListDetail(entry) }}
@@ -176,31 +227,32 @@
- {{ partialTargetItem.sku }}
- {{ partialTargetItem.desc }}
- 需拣总量:{{ partialTargetItem.qty }} {{ partialTargetItem.unit }}
+ {{ partialTargetItem.sku }}{{ partialTargetItem.desc }}
+ 需拣{{ partialTargetItem.qty }}{{ partialTargetItem.unit }}
-
- 实际拣出
-
+ 实际拣出数量
+
- {{ partialTargetItem ? partialTargetItem.unit : '' }}
+ />{{ partialTargetItem ? partialTargetItem.unit : '' }}
- {{ option.label }} ({{ option.value }})
+ {{ option.label }}{{ option.value }}{{ partialTargetItem ? partialTargetItem.unit : '' }}
{{ partialValidationMessage }}
@@ -231,7 +283,7 @@
确认提交后,以下待拣商品会被明确标记为“未拣出”,本次演示提交不可撤销。
- {{ item.sku }} · {{ item.location }}
+ {{ item.location ? `${item.sku} · ${item.location}` : item.sku }}
另有 {{ pendingItems.length - 3 }} 件
选择未完成原因
@@ -249,7 +301,11 @@
✓
- 箱号 {{ currentBox.boxNo }} · 订单 {{ currentBox.orderNo }}
+ {{ containerReference(currentBox) }}
+
+ 装柜照片已拍 {{ evidenceCapturedTypeCount }}/{{ evidenceSlots.length }} 类
+ {{ sealNo ? '封签号已填写' : '封签号未填写' }}
+
{{ completionSummary.picked }}
@@ -270,35 +326,47 @@
返回查看
- {{ nextBoxButtonText }}
+ {{ evidenceNextButtonText }}
-
-
-
- ✓
- 本班次演示任务 {{ boxes.length }} 箱已全部处理
- 返回工作台
-
-
-
{{ toast.message }}
diff --git a/pages/tasks/tasks.vue b/pages/tasks/tasks.vue
new file mode 100644
index 0000000..a17dec1
--- /dev/null
+++ b/pages/tasks/tasks.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+ 待处理任务
+ {{ loading ? '正在同步任务' : `共 ${tasks.length} 个待处理任务` }}
+
+ {{ loading ? '刷新中' : '刷新' }}
+
+ {{ error }}
+ 正在加载真实任务...
+ 暂无待处理的拣货任务
+ {{ notice }}
+
+
+
+ {{ containerReference(task) }}
+ {{ statusLabel(task.workflowStatus) }}
+
+
+
+
+
+ {{ toast.message }}
+
+
+
+
+
+
diff --git a/static/app-icons/1024x1024.png b/static/app-icons/1024x1024.png
new file mode 100644
index 0000000..625cf88
Binary files /dev/null and b/static/app-icons/1024x1024.png differ
diff --git a/static/app-icons/120x120.png b/static/app-icons/120x120.png
new file mode 100644
index 0000000..e0cba41
Binary files /dev/null and b/static/app-icons/120x120.png differ
diff --git a/static/app-icons/144x144.png b/static/app-icons/144x144.png
new file mode 100644
index 0000000..6445a15
Binary files /dev/null and b/static/app-icons/144x144.png differ
diff --git a/static/app-icons/152x152.png b/static/app-icons/152x152.png
new file mode 100644
index 0000000..56c10e3
Binary files /dev/null and b/static/app-icons/152x152.png differ
diff --git a/static/app-icons/167x167.png b/static/app-icons/167x167.png
new file mode 100644
index 0000000..5b4d55e
Binary files /dev/null and b/static/app-icons/167x167.png differ
diff --git a/static/app-icons/180x180.png b/static/app-icons/180x180.png
new file mode 100644
index 0000000..4ef66a5
Binary files /dev/null and b/static/app-icons/180x180.png differ
diff --git a/static/app-icons/192x192.png b/static/app-icons/192x192.png
new file mode 100644
index 0000000..71ad1f5
Binary files /dev/null and b/static/app-icons/192x192.png differ
diff --git a/static/app-icons/20x20.png b/static/app-icons/20x20.png
new file mode 100644
index 0000000..ef1fc58
Binary files /dev/null and b/static/app-icons/20x20.png differ
diff --git a/static/app-icons/29x29.png b/static/app-icons/29x29.png
new file mode 100644
index 0000000..634a30b
Binary files /dev/null and b/static/app-icons/29x29.png differ
diff --git a/static/app-icons/40x40.png b/static/app-icons/40x40.png
new file mode 100644
index 0000000..ff4fec3
Binary files /dev/null and b/static/app-icons/40x40.png differ
diff --git a/static/app-icons/58x58.png b/static/app-icons/58x58.png
new file mode 100644
index 0000000..4edd637
Binary files /dev/null and b/static/app-icons/58x58.png differ
diff --git a/static/app-icons/60x60.png b/static/app-icons/60x60.png
new file mode 100644
index 0000000..00169cf
Binary files /dev/null and b/static/app-icons/60x60.png differ
diff --git a/static/app-icons/72x72.png b/static/app-icons/72x72.png
new file mode 100644
index 0000000..e444eef
Binary files /dev/null and b/static/app-icons/72x72.png differ
diff --git a/static/app-icons/76x76.png b/static/app-icons/76x76.png
new file mode 100644
index 0000000..7ce1e94
Binary files /dev/null and b/static/app-icons/76x76.png differ
diff --git a/static/app-icons/80x80.png b/static/app-icons/80x80.png
new file mode 100644
index 0000000..4f035e4
Binary files /dev/null and b/static/app-icons/80x80.png differ
diff --git a/static/app-icons/87x87.png b/static/app-icons/87x87.png
new file mode 100644
index 0000000..d6cc8f2
Binary files /dev/null and b/static/app-icons/87x87.png differ
diff --git a/static/app-icons/96x96.png b/static/app-icons/96x96.png
new file mode 100644
index 0000000..3d0a2d6
Binary files /dev/null and b/static/app-icons/96x96.png differ
diff --git a/static/brand-mark-white.png b/static/brand-mark-white.png
new file mode 100644
index 0000000..6fb87e3
Binary files /dev/null and b/static/brand-mark-white.png differ
diff --git a/stores/dropship-palletizing.js b/stores/dropship-palletizing.js
new file mode 100644
index 0000000..d20a993
--- /dev/null
+++ b/stores/dropship-palletizing.js
@@ -0,0 +1,36 @@
+const STORAGE_PREFIX = 'pda-dropship-palletizing-draft'
+
+function clone(value) {
+ return JSON.parse(JSON.stringify(value))
+}
+
+function text(value) {
+ return value == null ? '' : String(value)
+}
+
+export function getDropshipPalletizingDraftKey(identity = {}) {
+ const parts = [text(identity.operatorId), text(identity.taskId), text(identity.warehouseId)]
+ return parts.every(Boolean) ? `${STORAGE_PREFIX}:${parts.map(encodeURIComponent).join(':')}` : ''
+}
+
+export function saveDropshipPalletizingDraft(identity, state) {
+ const key = getDropshipPalletizingDraftKey(identity)
+ if (!key || typeof uni === 'undefined' || typeof uni.setStorageSync !== 'function') return false
+ uni.setStorageSync(key, { identity: { ...identity }, state: clone(state), updatedAt: new Date().toISOString() })
+ return true
+}
+
+export function restoreDropshipPalletizingDraft(identity) {
+ const key = getDropshipPalletizingDraftKey(identity)
+ if (!key || typeof uni === 'undefined' || typeof uni.getStorageSync !== 'function') return null
+ const saved = uni.getStorageSync(key)
+ if (!saved?.state || text(saved.identity?.operatorId) !== text(identity.operatorId) || text(saved.identity?.taskId) !== text(identity.taskId) || text(saved.identity?.warehouseId) !== text(identity.warehouseId)) return null
+ return clone(saved.state)
+}
+
+export function clearDropshipPalletizingDraft(identity) {
+ const key = getDropshipPalletizingDraftKey(identity)
+ if (!key || typeof uni === 'undefined' || typeof uni.removeStorageSync !== 'function') return false
+ uni.removeStorageSync(key)
+ return true
+}
diff --git a/stores/picking.js b/stores/picking.js
index 6cde451..af2fb25 100644
--- a/stores/picking.js
+++ b/stores/picking.js
@@ -1,5 +1,6 @@
import { createBoxes } from '@/data/demo-data.js'
import { createItemStates } from '@/utils/picking-state.js'
+import { stableTaskIdentity, stableTaskIdentityKey } from '@/utils/task-identity.js'
function clone(value) {
return JSON.parse(JSON.stringify(value))
@@ -11,18 +12,101 @@ function createInitialState() {
boxes,
currentBoxIndex: 0,
itemStates: createItemStates(boxes[0].items),
- partialQuantities: {}, boxDrafts: {}, completedBoxes: {}, scanValue: '', scanHistory: []
+ partialQuantities: {}, scannedBoxCodes: {}, boxDrafts: {}, completedBoxes: {}, scanValue: '', scanHistory: []
}
}
let state = createInitialState()
+const STORAGE_PREFIX = 'pda-picking-draft'
+const COMPLETION_PREFIX = 'pda-loading-completion'
+
+function storageAvailable() {
+ return typeof uni !== 'undefined' && typeof uni.getStorageSync === 'function' && typeof uni.setStorageSync === 'function'
+}
+
+export function getPickingDraftKey(identity = {}) {
+ const identityKey = stableTaskIdentityKey(identity)
+ return identityKey ? `${STORAGE_PREFIX}:${identityKey}` : ''
+}
+
+function draftPayload(nextState, identity) {
+ const value = stableTaskIdentity(identity)
+ return {
+ operatorId: value.operatorId,
+ taskId: value.taskId,
+ loadingPlanId: value.loadingPlanId,
+ containerBookingId: value.containerBookingId,
+ warehouseId: identity.warehouseId ?? null,
+ currentBoxIndex: nextState.currentBoxIndex,
+ itemStates: nextState.itemStates,
+ partialQuantities: nextState.partialQuantities,
+ scannedBoxCodes: nextState.scannedBoxCodes,
+ boxDrafts: nextState.boxDrafts,
+ completedBoxes: nextState.completedBoxes,
+ scanHistory: nextState.scanHistory,
+ updatedAt: new Date().toISOString()
+ }
+}
+
export function getPickingSession() {
return clone(state)
}
-export function savePickingSession(nextState) {
+export function savePickingSession(nextState, identity = {}) {
state = clone(nextState)
+ const key = getPickingDraftKey(identity)
+ if (key && storageAvailable()) uni.setStorageSync(key, draftPayload(state, identity))
+}
+
+export function restorePickingSession(identity = {}) {
+ const key = getPickingDraftKey(identity)
+ if (!key || !storageAvailable()) return null
+ const saved = uni.getStorageSync(key)
+ const expected = stableTaskIdentity(identity)
+ if (!saved || saved.operatorId !== expected.operatorId || saved.taskId !== expected.taskId || saved.loadingPlanId !== expected.loadingPlanId || saved.containerBookingId !== expected.containerBookingId) return null
+ const restored = { ...state, ...clone(saved) }
+ delete restored.operatorId
+ delete restored.taskId
+ delete restored.loadingPlanId
+ delete restored.containerBookingId
+ delete restored.warehouseId
+ delete restored.updatedAt
+ state = restored
+ return clone(state)
+}
+
+export function clearPickingDraft(identity = {}) {
+ const key = getPickingDraftKey(identity)
+ if (key && typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(key)
+}
+
+function completionKey(identity = {}) {
+ const identityKey = stableTaskIdentityKey(identity)
+ return identityKey ? `${COMPLETION_PREFIX}:${identityKey}` : ''
+}
+
+export function saveLoadingCompletionAttempt(identity = {}, attempt = {}) {
+ const key = completionKey(identity)
+ if (!key || !storageAvailable() || !attempt.operationId) return
+ uni.setStorageSync(key, {
+ operationId: String(attempt.operationId),
+ expectedTaskVersion: Number(attempt.expectedTaskVersion),
+ remark: attempt.remark || '',
+ updatedAt: new Date().toISOString()
+ })
+}
+
+export function restoreLoadingCompletionAttempt(identity = {}) {
+ const key = completionKey(identity)
+ if (!key || !storageAvailable()) return null
+ const saved = uni.getStorageSync(key)
+ return saved?.operationId && Number.isInteger(Number(saved.expectedTaskVersion)) ? clone(saved) : null
+}
+
+export function clearLoadingCompletionAttempt(identity = {}) {
+ const key = completionKey(identity)
+ if (key && typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(key)
}
export function resetPickingSession() {
diff --git a/stores/session.js b/stores/session.js
index 46b2861..a5fa224 100644
--- a/stores/session.js
+++ b/stores/session.js
@@ -1,20 +1,162 @@
import { reactive } from 'vue'
import { resetPickingSession } from '@/stores/picking.js'
+import { PLATFORM_API_ROOT } from '@/config/runtime.js'
-export const session = reactive({ currentUser: null })
+const SESSION_KEY = 'pda-auth-session'
+const LOGIN_PREFERENCE_KEY = 'pda-login-account-preference'
-export function signIn(account, warehouse) {
- session.currentUser = { id: account.id, name: account.name, role: account.role, warehouse }
+export const session = reactive({ currentUser: null, token: '', refreshToken: '', tenantId: '', apiBase: PLATFORM_API_ROOT })
+
+function decodeBase64Url(value) {
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
+ if (typeof atob === 'function') return decodeURIComponent(Array.from(atob(padded), (char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`).join(''))
+ throw new Error('当前运行环境无法解析登录令牌')
+}
+
+export function decodeAccessToken(token) {
+ const payload = String(token || '').split('.')[1]
+ if (!payload) throw new Error('登录响应中的 accessToken 格式无效')
+ try {
+ return JSON.parse(decodeBase64Url(payload))
+ } catch (error) {
+ if (error.message === '当前运行环境无法解析登录令牌') throw error
+ throw new Error('登录响应中的 accessToken 无法解析')
+ }
+}
+
+export function isAccessTokenExpiring(token = session.token, skewSeconds = 30) {
+ try {
+ const expiresAt = Number(decodeAccessToken(token).exp || 0) * 1000
+ return !expiresAt || expiresAt <= Date.now() + skewSeconds * 1000
+ } catch (_) {
+ return true
+ }
+}
+
+function storageAvailable() {
+ return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function'
+}
+
+export function loadLoginPreference() {
+ if (!storageAvailable()) return { rememberAccount: true, account: '' }
+ const saved = uni.getStorageSync(LOGIN_PREFERENCE_KEY)
+ if (!saved || typeof saved !== 'object') return { rememberAccount: true, account: '' }
+ return { rememberAccount: saved.rememberAccount !== false, account: String(saved.account || '') }
+}
+
+export function saveLoginPreference(rememberAccount, account = '') {
+ if (!storageAvailable()) return
+ uni.setStorageSync(LOGIN_PREFERENCE_KEY, {
+ rememberAccount: Boolean(rememberAccount),
+ account: rememberAccount ? String(account || '').trim() : ''
+ })
+}
+
+function persistSession() {
+ if (!storageAvailable()) return
+ uni.setStorageSync(SESSION_KEY, {
+ currentUser: session.currentUser,
+ token: session.token,
+ refreshToken: session.refreshToken,
+ tenantId: session.tenantId
+ })
+}
+
+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.token = options.token || account.token || ''
+ session.refreshToken = options.refreshToken || account.refreshToken || ''
+ session.tenantId = options.tenantId || account.tenantId || ''
+ // The endpoint belongs to the installed build, not to a persisted login.
+ // Otherwise changing environments leaves existing devices on the old API.
+ session.apiBase = PLATFORM_API_ROOT
+ persistSession()
return session.currentUser
}
+export function signInWithTokens(tokens) {
+ if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken')
+ if (!tokens?.refreshToken) throw new Error('登录响应缺少 refreshToken')
+ const claims = decodeAccessToken(tokens.accessToken)
+ if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端')
+ if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('登录状态已过期')
+ return signIn({
+ id: claims.user_id ?? '',
+ name: claims.username || '仓库作业员',
+ role: Array.isArray(claims.roles) ? claims.roles.join('、') : '',
+ roles: claims.roles || []
+ }, '', {
+ token: tokens.accessToken,
+ refreshToken: tokens.refreshToken || '',
+ tenantId: claims.tenant_id == null ? '' : String(claims.tenant_id)
+ })
+}
+
+export function selectWarehouse(warehouse) {
+ if (!session.currentUser) throw new Error('请先登录')
+ if (!warehouse?.id || !warehouse?.warehouseName) throw new Error('请选择有效的作业仓库')
+ const warehouseId = String(warehouse.id)
+ if (String(session.currentUser.warehouseId || '') !== warehouseId) resetPickingSession()
+ session.currentUser = {
+ ...session.currentUser,
+ warehouseId,
+ warehouse: String(warehouse.warehouseName),
+ warehouseCode: String(warehouse.warehouseCode || '')
+ }
+ persistSession()
+ return session.currentUser
+}
+
+export function updateTokenPair(tokens) {
+ if (!tokens?.accessToken) throw new Error('刷新响应缺少 accessToken')
+ if (!tokens?.refreshToken) throw new Error('刷新响应缺少 refreshToken')
+ const claims = decodeAccessToken(tokens.accessToken)
+ if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端')
+ if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('刷新后的登录状态已过期')
+ const previousUser = session.currentUser || {}
+ session.currentUser = {
+ ...previousUser,
+ id: String(claims.user_id ?? previousUser.id ?? ''),
+ name: claims.username || previousUser.name || '仓库作业员',
+ role: Array.isArray(claims.roles) ? claims.roles.join('、') : (previousUser.role || ''),
+ roles: claims.roles || previousUser.roles || []
+ }
+ session.token = tokens.accessToken
+ session.refreshToken = tokens.refreshToken
+ session.tenantId = claims.tenant_id == null ? session.tenantId : String(claims.tenant_id)
+ persistSession()
+ return session.token
+}
+
+export function restoreSession() {
+ if (!storageAvailable()) return false
+ const saved = uni.getStorageSync(SESSION_KEY)
+ if (!saved?.token || !saved?.currentUser) return false
+ try {
+ const claims = decodeAccessToken(saved.token)
+ if (claims.app_code !== 'PLATFORM') throw new Error('登录域无效')
+ if (claims.exp && claims.exp * 1000 <= Date.now() && !saved.refreshToken) throw new Error('登录状态已过期')
+ Object.assign(session, saved, { apiBase: saved.apiBase || PLATFORM_API_ROOT })
+ return true
+ } catch (_) {
+ signOut()
+ return false
+ }
+}
+
export function signOut() {
session.currentUser = null
+ session.token = ''
+ session.refreshToken = ''
+ session.tenantId = ''
+ session.apiBase = PLATFORM_API_ROOT
+ if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY)
resetPickingSession()
}
export function requireSession() {
- if (session.currentUser) return true
+ if (session.currentUser || restoreSession()) return true
uni.reLaunch({ url: '/pages/login/login' })
return false
}
diff --git a/styles/pda.scss b/styles/pda.scss
index 08c9bc0..259e01b 100644
--- a/styles/pda.scss
+++ b/styles/pda.scss
@@ -24,8 +24,9 @@
letter-spacing: 0;
}
- button::after {
- border: 0;
+ button::after,
+ uni-button::after {
+ border: 0 !important;
}
button:focus-visible,
@@ -59,42 +60,10 @@
}
.login-logo-icon {
- position: relative;
- box-sizing: border-box;
- width: 72px;
- height: 72px;
+ display: block;
+ width: 78px;
+ height: 76px;
margin-bottom: 18px;
- border-radius: 20px;
- background: rgba(255, 255, 255, 0.16);
- box-shadow: 0 8px 24px rgba(5, 51, 118, 0.18);
- }
-
- .login-logo-icon::before {
- position: absolute;
- inset: 19px;
- content: '';
- border: 3px solid #ffffff;
- border-radius: 4px;
- }
-
- .logo-grid-line {
- position: absolute;
- z-index: 1;
- background: #ffffff;
- }
-
- .logo-grid-line-horizontal {
- left: 20px;
- top: 31px;
- width: 32px;
- height: 3px;
- }
-
- .logo-grid-line-vertical {
- left: 31px;
- top: 20px;
- width: 3px;
- height: 32px;
}
.login-logo-title {
@@ -165,6 +134,20 @@
font-size: 13px;
}
+ .login-remember-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 48px;
+ margin: -8px 0 10px;
+ color: $text-secondary;
+ }
+
+ .login-remember-label {
+ font-size: 14px;
+ font-weight: 500;
+ }
+
.login-btn {
width: 100%;
height: 50px;
@@ -231,7 +214,7 @@
.app-header {
box-sizing: border-box;
- padding: calc(var(--status-bar-height, 0px) + 12px) 14px 12px;
+ padding: var(--status-bar-height, 0px) 10px 0;
color: #ffffff;
background: $primary;
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.22);
@@ -244,8 +227,7 @@
.pick-item-row1,
.pick-item-row2,
.submit-info,
- .box-list-item,
- .partial-input-row {
+ .box-list-item {
display: flex;
align-items: center;
}
@@ -266,9 +248,9 @@
.header-title {
flex: 1;
- font-size: 20px;
+ font-size: 18px;
font-weight: 700;
- line-height: 28px;
+ line-height: 24px;
}
.header-actions {
@@ -291,6 +273,10 @@
padding: 1px 0 104px;
}
+ .work-content.evidence-active {
+ padding-bottom: 1px;
+ }
+
.box-select-wrap {
position: relative;
z-index: 5;
@@ -303,31 +289,82 @@
justify-content: space-between;
box-sizing: border-box;
width: 100%;
- height: 48px;
- padding: 0 14px;
+ min-height: 48px;
+ padding: 8px 14px;
border: 2px solid $primary;
border-radius: 12px;
color: $primary;
font-size: 14px;
font-weight: 500;
- line-height: 44px;
+ line-height: 20px;
text-align: left;
background: $card;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
+ .box-select-summary {
+ min-width: 0;
+ flex: 1;
+ line-height: 20px;
+ overflow-wrap: anywhere;
+ white-space: normal;
+ }
+
.box-chevron {
+ flex-shrink: 0;
+ margin-left: 8px;
font-size: 12px;
transition: transform 160ms ease-out;
}
+ .stage-switch {
+ display: flex;
+ gap: 4px;
+ margin: 0 12px 10px;
+ padding: 4px;
+ border: 1px solid $border;
+ border-radius: 10px;
+ background: #eef2f7;
+ }
+
+ .stage-tab {
+ flex: 1;
+ min-height: 42px;
+ padding: 0 8px;
+ border: 0;
+ border-radius: 7px;
+ color: $text-secondary;
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 42px;
+ background: transparent;
+ }
+
+ .stage-tab.active {
+ color: $primary;
+ background: #ffffff;
+ box-shadow: 0 1px 4px rgba(31, 41, 55, 0.12);
+ }
+
+ .stage-tab.locked {
+ color: #9aa5b1;
+ }
+
+ .stage-tab-hint {
+ display: block;
+ margin-top: -9px;
+ font-size: 9px;
+ font-weight: 400;
+ line-height: 12px;
+ }
+
.box-chevron.open {
transform: rotate(180deg);
}
.box-dropdown {
position: absolute;
- top: 53px;
+ top: calc(100% + 5px);
left: 0;
right: 0;
overflow: hidden;
@@ -352,6 +389,8 @@
border-bottom: 0;
}
+ .box-option > view { min-width: 0; flex: 1; }
+
.box-option.active {
color: $primary;
font-weight: 600;
@@ -360,8 +399,8 @@
.box-option.completed { color: #596675; background: #f3f7f6; }
.box-option-number, .box-option-order { display: block; }
- .box-option-number { font-size: 13px; font-weight: 700; }
- .box-option-order { margin-top: 3px; color: $text-secondary; font-size: 11px; }
+ .box-option-number { font-size: 13px; font-weight: 700; overflow-wrap: anywhere; }
+ .box-option-order { margin-top: 3px; color: $text-secondary; font-size: 11px; line-height: 17px; overflow-wrap: anywhere; }
.box-option-state { padding: 5px 7px; border-radius: 5px; color: #08786c; font-size: 11px; background: $success-light; }
.box-option-state.draft { color: #865b0b; background: $warning-light; }
@@ -377,8 +416,6 @@
gap: 10px;
}
- .scan-hardware-tip { display: block; margin-top: 9px; color: #41668f; font-size: 11px; font-weight: 600; line-height: 17px; }
-
.last-scan {
display: flex;
align-items: center;
@@ -403,31 +440,37 @@
.progress-section {
margin: 0 12px 10px;
- padding: 13px 14px;
+ padding: 10px 12px;
border-radius: 12px;
background: $card;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.progress-header {
- align-items: flex-start;
+ align-items: center;
justify-content: space-between;
- gap: 10px;
+ gap: 8px;
}
.progress-title {
+ flex-shrink: 0;
font-size: 14px;
font-weight: 700;
}
.progress-stats {
color: $text-secondary;
- font-size: 12px;
+ font-size: 11px;
text-align: right;
+ white-space: nowrap;
}
- .progress-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 7px; }
- .box-tasks-btn { height: 48px; padding: 0 12px; border: 1px solid #cdd9e7; border-radius: 8px; color: #3f5f81; font-size: 12px; line-height: 46px; background: #f7faff; }
+ .progress-actions { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: flex-end; gap: 8px; }
+ .box-tasks-btn { display: flex; height: 40px; flex-shrink: 0; align-items: center; gap: 3px; padding: 0 9px 0 11px; border: 0; border-radius: 999px; color: #245c9c; font-size: 11px; line-height: 40px; background: #edf5ff; }
+ .box-tasks-btn::after { border: 0; }
+ .box-tasks-btn:active { color: #0f4f9b; background: #dcecff; }
+ .box-tasks-count { color: $primary; font-weight: 700; }
+ .box-tasks-arrow { margin-left: 1px; color: $primary; font-size: 16px; line-height: 16px; }
.progress-number {
color: $success;
@@ -436,8 +479,8 @@
.progress-bar-bg {
overflow: hidden;
- height: 7px;
- margin-top: 10px;
+ height: 5px;
+ margin-top: 8px;
border-radius: 999px;
background: #e5e7eb;
}
@@ -493,9 +536,11 @@
}
.pick-item-sku {
- flex-shrink: 0;
+ min-width: 0;
+ flex: 1;
font-size: 15px;
font-weight: 700;
+ overflow-wrap: anywhere;
}
.pick-item-meta {
@@ -552,11 +597,17 @@
background: #e9f4ff;
}
+ .pick-item-location.location-missing {
+ color: #b45309;
+ background: #fff7e8;
+ }
+
.pick-item-actions {
display: grid;
- grid-template-columns: 1.15fr 1fr 0.78fr;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
- margin-top: 12px;
+ width: 100%;
+ margin-top: 14px;
}
.processed-actions {
@@ -564,18 +615,30 @@
}
.pick-btn {
+ display: flex;
+ box-sizing: border-box;
+ width: 100%;
height: 48px;
- padding: 0 6px;
- border-radius: 7px;
+ min-width: 0;
+ margin: 0;
+ padding: 0 8px;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid transparent;
+ border-radius: 10px;
font-size: 13px;
- font-weight: 600;
- line-height: 48px;
+ font-weight: 700;
+ line-height: 18px;
+ white-space: nowrap;
+ transition: transform 120ms ease-out, box-shadow 120ms ease-out, background-color 120ms ease-out;
}
- .pick-btn-confirm { color: #ffffff; background: $success; }
- .pick-btn-partial { border: 1px solid #75a5ef; color: #4a7dff; background: #eef5ff; }
- .pick-btn-skip { color: $skip; background: #eef0f3; }
- .pick-btn-undo { border: 1px solid #d8dde4; color: $text-secondary; background: #ffffff; }
+ .pick-btn::after { border: 0; }
+ .pick-btn:active { transform: translateY(1px); box-shadow: none; }
+ .pick-btn-confirm { border-color: #0d8f84; color: #ffffff; background: $success; box-shadow: 0 3px 8px rgba(13, 148, 136, 0.22); }
+ .pick-btn-partial { border-color: #75a5ef; color: #2467d4; background: #eef5ff; box-shadow: 0 2px 6px rgba(74, 125, 255, 0.12); }
+ .pick-btn-skip { border-color: #d8dee7; color: #536174; background: #f5f7fa; }
+ .pick-btn-undo { border-color: #efb7b7; color: #b42323; background: #fff5f5; box-shadow: 0 2px 6px rgba(180, 35, 35, 0.08); }
.empty-state {
display: flex;
@@ -662,7 +725,7 @@
.portal-header {
box-sizing: border-box;
- padding: calc(var(--status-bar-height, 0px) + 6px) 10px 6px;
+ padding: var(--status-bar-height, 0px) 8px 0;
color: #ffffff;
background: #135ee8;
}
@@ -687,7 +750,7 @@
display: block;
}
- .portal-title { overflow: hidden; font-size: 18px; font-weight: 600; line-height: 48px; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
+ .portal-title { overflow: hidden; font-size: 17px; font-weight: 600; line-height: 48px; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.portal-header-button {
width: 48px;
@@ -713,29 +776,36 @@
}
.portal-header-spacer { width: 48px; height: 48px; }
- .portal-content { padding: 16px 14px 24px; }
+ .portal-content { padding: 12px 14px 24px; }
- .home-menu { padding: 16px 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-group-mark { width: 4px; height: 20px; flex-shrink: 0; margin-right: 10px; background: #1769e8; }
+ .home-menu { padding: 10px 10px 18px; }
+ .home-group + .home-group { margin-top: 11px; }
+ .home-group-title { display: flex; align-items: center; margin-bottom: 6px; color: #27364a; font-size: 14px; font-weight: 700; line-height: 20px; }
+ .home-group-mark { width: 3px; height: 16px; flex-shrink: 0; margin-right: 7px; border-radius: 2px; background: #1769e8; }
.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:active { border-color: #9bbcf2; background: #edf4ff; }
- .home-operation-primary { border-color: #8cb5f5; color: #135ee8; background: #edf4ff; }
- .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-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
+ .home-operation { position: relative; display: flex; box-sizing: border-box; width: 100%; min-width: 0; min-height: 78px; margin: 0; padding: 6px 3px 5px; align-self: stretch; flex-direction: column; align-items: center; justify-content: center; border: 1px solid #e0e6ee; border-radius: 7px; color: #3b4b60; background: #ffffff; box-shadow: 0 1px 2px rgba(38, 54, 74, 0.05); }
+ .home-operation:active { border-color: #77a7f2; background: #edf4ff; transform: translateY(1px); }
+ .home-operation-primary { border-color: #6d9ff0; color: #0d58d3; background: #f3f7ff; box-shadow: 0 2px 6px rgba(19, 94, 232, 0.14); }
+ .home-operation-disabled, .home-operation-disabled[disabled] { border-color: #e3e8ef; color: #7f8b99; opacity: 1; background: #fafbfc; box-shadow: none; }
+ .home-operation-disabled:active { border-color: #e3e8ef; background: #fafbfc; transform: none; }
+ .home-operation-icon { display: flex; align-items: center; justify-content: center; width: 30px; height: 26px; flex: 0 0 26px; font-size: 20px; font-weight: 700; }
+ .home-operation-name { display: block; width: 100%; min-height: 30px; margin-top: 1px; font-size: 12px; font-weight: 600; line-height: 15px; text-align: center; overflow-wrap: anywhere; white-space: normal; }
+ .home-operation-meta { display: inline-flex; min-height: 14px; margin-top: 2px; padding: 0 5px; align-items: center; justify-content: center; border-radius: 7px; font-size: 9px; font-weight: 600; line-height: 14px; white-space: nowrap; }
+ .home-operation-pending { color: #0d58d3; background: #dfeaff; }
+ .home-operation-unavailable { color: #778494; background: #edf0f4; }
.header-menu-sheet { box-sizing: border-box; width: 100vw; max-width: 480px; }
.header-menu-content { padding-top: 1px; }
.header-menu-title { display: block; margin: 17px 16px 8px; color: #1f2937; font-size: 17px; font-weight: 700; }
.header-menu-item { display: grid; box-sizing: border-box; width: calc(100% - 32px); height: 54px; margin: 0 16px; padding: 0 4px; align-items: center; grid-template-columns: 32px minmax(0, 1fr) 24px; border-bottom: 1px solid #e5e7eb; color: #334155; font-size: 15px; line-height: 54px; text-align: left; background: #ffffff; }
+ .header-menu-state { padding: 26px 16px; color: #64748b; font-size: 13px; text-align: center; }
+ .header-menu-error { color: #b42318; }
+ .warehouse-menu-item { height: 58px; line-height: normal; }
+ .warehouse-menu-item.selected { color: #155eef; background: #f5f8ff; }
+ .warehouse-menu-item > view { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
+ .warehouse-menu-code { color: #8491a3; font-size: 10px; }
.header-menu-close { box-sizing: border-box; width: calc(100% - 32px); height: 48px; margin: 12px 16px calc(14px + env(safe-area-inset-bottom, 0px)); border-radius: 6px; color: #334155; font-size: 14px; line-height: 48px; background: #eef2f6; }
.group-title { font-size: 16px; font-weight: 700; }
@@ -836,8 +906,11 @@
.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-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; }
+ .profile-row-action { box-sizing: border-box; width: 100%; height: 52px; margin: 0; padding: 0; border-radius: 0; color: $text; line-height: normal; text-align: left; background: transparent; }
+ .profile-row-action::after { border: 0; }
+ .profile-row-action:active { background: #f7f9fc; }
+ .profile-row-arrow { color: $text-secondary; font-size: 18px; }
+ .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; background: #ffffff; }
.logout-action { color: $danger; text-align: center; }
.logout-dialog-card { box-sizing: border-box; width: calc(100vw - 48px); max-width: 320px; overflow: hidden; border-radius: 12px; background: #ffffff; box-shadow: 0 18px 50px rgba(0, 0, 0, 0.24); }
.logout-dialog-title { display: block; padding: 24px 22px 0; color: $danger; font-size: 17px; font-weight: 700; text-align: center; }
@@ -892,8 +965,8 @@
flex-direction: column;
}
- .box-list-number { font-size: 13px; font-weight: 600; }
- .box-list-detail { margin-top: 4px; color: $text-secondary; font-size: 12px; line-height: 18px; }
+ .box-list-number { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
+ .box-list-detail { margin-top: 4px; color: $text-secondary; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; }
.box-list-tag {
flex-shrink: 0;
@@ -982,61 +1055,81 @@
.partial-info {
display: flex;
- flex-direction: column;
- padding: 12px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 12px;
border-radius: 9px;
background: $primary-light;
}
- .partial-info-sku { color: $primary; font-size: 15px; font-weight: 700; }
- .partial-info-desc { margin-top: 4px; color: $text-secondary; font-size: 12px; }
- .partial-info-qty { margin-top: 7px; font-size: 13px; }
- .partial-info-qty text { font-size: 16px; font-weight: 700; }
+ .partial-info-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; }
+ .partial-info-sku { overflow: hidden; color: $primary; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
+ .partial-info-desc { overflow: hidden; margin-top: 2px; color: $text-secondary; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+ .partial-info-qty { display: flex; flex-shrink: 0; align-items: baseline; gap: 3px; color: $text-secondary; font-size: 11px; }
+ .partial-info-qty-label { margin-right: 1px; }
+ .partial-info-qty-number { color: $text; font-size: 18px; font-weight: 700; }
- .partial-input-row {
- gap: 9px;
- margin-top: 14px;
- }
+ .partial-field { margin-top: 12px; }
.partial-input-label {
- flex-shrink: 0;
- font-size: 13px;
+ display: block;
+ margin-bottom: 6px;
+ color: $text-secondary;
+ font-size: 12px;
+ font-weight: 600;
}
+ .partial-input-control { display: flex; height: 50px; align-items: center; overflow: hidden; border: 2px solid $primary; border-radius: 9px; background: #ffffff; }
.partial-input {
box-sizing: border-box;
flex: 1;
min-width: 0;
- height: 48px;
- padding: 0 12px;
- border: 2px solid $primary;
- border-radius: 8px;
+ height: 46px;
+ padding: 0 8px 0 14px;
+ border: 0;
font-size: 18px;
font-weight: 700;
- text-align: center;
+ text-align: left;
}
+ .partial-input-placeholder { color: #98a2b3; font-size: 14px; font-weight: 400; }
- .partial-input-unit { flex-shrink: 0; color: $text-secondary; font-size: 13px; }
+ .partial-input-unit { flex-shrink: 0; padding: 0 14px 0 8px; color: $text-secondary; font-size: 13px; }
.partial-quick-row {
display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
- margin-top: 12px;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ align-items: stretch;
+ gap: 6px;
+ margin-top: 10px;
}
.partial-quick-btn {
+ box-sizing: border-box;
+ display: flex;
+ width: 100%;
height: 48px;
- padding: 0 6px;
+ margin: 0;
+ padding: 4px 2px;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
border: 1px solid #b9c9df;
border-radius: 7px;
color: #436080;
- font-size: 12px;
- line-height: 48px;
+ font-size: 11px;
+ line-height: 16px;
background: #f4f8fc;
}
+ .partial-quick-btn.selected { border-color: $primary; color: $primary; background: $primary-light; }
+ .partial-quick-ratio, .partial-quick-value { display: block; }
+ .partial-quick-ratio { font-weight: 700; }
+ .partial-quick-value { color: $text-secondary; font-size: 10px; }
+ .partial-quick-btn.selected .partial-quick-value { color: #356cae; }
.partial-validation { display: block; margin-top: 10px; color: $danger; font-size: 12px; line-height: 18px; }
+ .partial-modal .modal-actions { margin-top: 12px; }
+ .partial-modal .modal-cancel-btn, .partial-modal .modal-confirm-btn { box-sizing: border-box; width: 100%; margin: 0; }
.modal-confirm-btn[disabled] { color: #8a9099; background: #e4e6eb; opacity: 1; }
.decision-desc { display: block; color: $text-secondary; font-size: 13px; line-height: 20px; text-align: center; }
.submit-decision-modal { padding: 18px 16px 16px; }
@@ -1054,8 +1147,6 @@
.incomplete-reason { box-sizing: border-box; min-height: 48px; padding: 10px 12px; border: 1px solid $border; border-radius: 8px; color: $text; font-size: 13px; line-height: 20px; text-align: left; background: #ffffff; }
.incomplete-reason.selected { border-color: $danger; color: #a51d1d; background: $danger-light; }
.danger-confirm { background: $danger; }
- .all-complete-btn { width: 100%; height: 48px; margin-top: 20px; border-radius: 9px; color: #ffffff; font-size: 14px; font-weight: 700; line-height: 48px; background: $primary; }
-
.complete-modal {
box-sizing: border-box;
width: calc(100vw - 32px);
@@ -1087,7 +1178,8 @@
}
.complete-title { font-size: 20px; font-weight: 700; }
- .complete-desc { margin-top: 6px; color: $text-secondary; font-size: 12px; }
+ .complete-desc { margin-top: 6px; color: $text-secondary; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; }
+ .complete-evidence-status { display: flex; margin-top: 14px; padding: 10px 12px; align-items: center; justify-content: space-between; gap: 8px; border-radius: 9px; color: #285d9e; font-size: 12px; font-weight: 600; background: #edf4ff; }
.complete-stats {
display: flex;
@@ -1114,6 +1206,55 @@
.gray { color: $skip; }
.red { color: $danger; }
+ .loading-evidence-section {
+ margin: 8px 12px 32px;
+ padding: 12px;
+ border: 1px solid #d8e4f4;
+ border-radius: 10px;
+ background: $card;
+ box-shadow: 0 4px 15px rgba(32, 67, 111, 0.07);
+ }
+ .section-heading-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
+ .section-title { display: block; color: $text; font-size: 15px; font-weight: 700; }
+ .section-subtitle { display: block; margin-top: 4px; color: $text-secondary; font-size: 11px; line-height: 17px; }
+ .sync-state { flex-shrink: 0; padding: 4px 7px; border-radius: 99px; font-size: 11px; background: #eef2f7; }
+ .sync-state.ready { color: $success; background: #e8f8ef; }
+ .sync-state.error { color: $danger; background: #fff0f0; }
+ .sync-state.loading { color: #8a5a00; background: #fff7df; }
+ .evidence-grid { display: flex; flex-direction: column; margin-top: 10px; }
+ .evidence-card { margin-bottom: 8px; padding: 7px 9px; border: 1px solid #d7e1ed; border-radius: 9px; background: #f8fafc; }
+ .evidence-card:last-child { margin-bottom: 0; }
+ .evidence-card.filled { border-color: #a7d6ba; background: #f4fbf6; }
+ .evidence-card-header { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: 10px; }
+ .evidence-card-main { display: flex; min-width: 0; align-items: center; gap: 9px; }
+ .evidence-step { display: flex; width: 28px; height: 28px; flex-shrink: 0; align-items: center; justify-content: center; border-radius: 50%; color: #47617f; font-size: 11px; font-weight: 700; background: #e8eef6; }
+ .evidence-step.complete { color: #ffffff; background: $success; }
+ .evidence-card-title { display: flex; min-width: 0; flex-direction: column; gap: 1px; }
+ .evidence-label { display: block; color: $text; font-size: 12px; font-weight: 700; }
+ .evidence-count { color: $text-secondary; font-size: 10px; line-height: 15px; }
+ .evidence-count.complete { color: #087443; }
+ .evidence-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 9px; }
+ .evidence-photo-item { position: relative; min-width: 0; padding: 4px; border: 1px solid #dbe5ef; border-radius: 8px; background: #ffffff; }
+ .evidence-thumbnail { display: flex; width: 100%; height: 68px; align-items: center; justify-content: center; border-radius: 6px; color: $text-secondary; font-size: 9px; line-height: 13px; text-align: center; background: #e7edf5; }
+ .evidence-thumbnail-placeholder { box-sizing: border-box; padding: 6px; }
+ .evidence-file { display: block; overflow: hidden; margin-top: 4px; padding: 0 2px; color: $text-secondary; font-size: 9px; line-height: 14px; text-overflow: ellipsis; white-space: nowrap; }
+ .evidence-delete-btn { position: absolute; top: 1px; right: 1px; width: 40px; min-width: 40px; height: 40px; margin: 0; padding: 0; border: 0; color: #ffffff; font-size: 29px; font-weight: 400; line-height: 40px; text-align: center; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.95); background: transparent; }
+ .evidence-delete-btn::after { border: 0; }
+ .evidence-upload-state { display: block; margin-top: 5px; color: $text-secondary; font-size: 10px; line-height: 15px; }
+ .evidence-upload-state.uploading, .evidence-upload-state.pending { color: #174d91; }
+ .evidence-upload-state.failed { color: $danger; }
+ .evidence-upload-state.succeeded { color: $success; }
+ .evidence-btn { min-height: 40px; margin-top: 8px; padding: 0 9px; border: 1px solid #9db7d9; border-radius: 7px; color: #174d91; font-size: 12px; line-height: 40px; background: #ffffff; }
+ .evidence-add-btn { flex-shrink: 0; min-width: 76px; min-height: 42px; margin-top: 0; line-height: 42px; }
+ .evidence-retry-btn { min-height: 40px; margin-top: 6px; padding: 0 9px; border: 1px solid $danger; border-radius: 7px; color: $danger; font-size: 12px; line-height: 40px; background: #fff5f4; }
+ .seal-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
+ .seal-input { flex: 1; height: 42px; box-sizing: border-box; padding: 0 10px; border: 1px solid $border; border-radius: 7px; font-size: 12px; background: #f8fafc; }
+ .seal-btn { flex-shrink: 0; margin-top: 0; }
+ .remote-error { display: block; margin-top: 9px; color: $danger; font-size: 11px; line-height: 17px; }
+ .loading-complete-btn { width: 100%; min-height: 46px; margin-top: 12px; border-radius: 8px; color: #ffffff; font-size: 13px; font-weight: 700; line-height: 46px; background: $primary; }
+ .loading-complete-btn[disabled] { color: #8c96a3; background: #e5e9ef; }
+ .version-hint { display: block; margin-top: 6px; color: $text-secondary; font-size: 10px; line-height: 15px; text-align: center; }
+
.app-toast {
position: fixed;
z-index: 30;
@@ -1144,12 +1285,14 @@
@media (max-width: 340px) {
.login-page { padding-right: 16px; padding-left: 16px; }
+ .home-menu { padding-right: 7px; padding-left: 7px; }
+ .home-operation-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
+ .home-operation { min-height: 76px; }
.header-title { font-size: 18px; }
.box-select-btn { padding: 0 10px; font-size: 12px; }
.pick-item { padding-right: 10px; padding-left: 10px; }
- .pick-item-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .pick-btn-confirm { grid-column: 1 / -1; }
.processed-actions { grid-template-columns: 1fr; }
+ .evidence-photo-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.submit-section { gap: 8px; width: calc(100% - 12px); padding-right: 8px; padding-left: 8px; }
.submit-info { width: 96px; min-width: 96px; }
}
diff --git a/tests/api-url.test.mjs b/tests/api-url.test.mjs
new file mode 100644
index 0000000..e54ae9b
--- /dev/null
+++ b/tests/api-url.test.mjs
@@ -0,0 +1,24 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { resolveEvidencePreviewUrl } from '../utils/api-url.js'
+
+const previewPath = '/api/platform/v1/inbounds/100/loading/evidence/200/content/pda?tenantId=300'
+
+test('evidence preview uses the H5 gateway proxy prefix after reopening a task', () => {
+ assert.equal(
+ resolveEvidencePreviewUrl(previewPath, '/gateway-api/platform/v1'),
+ '/gateway-api/platform/v1/inbounds/100/loading/evidence/200/content/pda?tenantId=300'
+ )
+})
+
+test('evidence preview uses the configured absolute gateway origin on a PDA', () => {
+ assert.equal(
+ resolveEvidencePreviewUrl(previewPath, 'https://test.example.com/api/platform/v1'),
+ 'https://test.example.com/api/platform/v1/inbounds/100/loading/evidence/200/content/pda?tenantId=300'
+ )
+})
+
+test('absolute evidence preview URLs remain unchanged', () => {
+ const absoluteUrl = 'https://files.example.com/evidence/200.png'
+ assert.equal(resolveEvidencePreviewUrl(absoluteUrl, '/gateway-api/platform/v1'), absoluteUrl)
+})
diff --git a/tests/app-update.test.mjs b/tests/app-update.test.mjs
new file mode 100644
index 0000000..7b0d8e6
--- /dev/null
+++ b/tests/app-update.test.mjs
@@ -0,0 +1,43 @@
+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 update channel after launch', async () => {
+ const [app, updater, env] = await Promise.all([
+ read('../App.vue'),
+ read('../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('online update supports mandatory APK and wgt installation flows', async () => {
+ const updater = await read('../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\)/)
+})
+
+test('profile exposes manual App update checking and the runtime version', async () => {
+ const profile = await read('../pages/profile/profile.vue')
+
+ assert.match(profile, /应用版本[\s\S]*?\{\{ appVersion \}\}/)
+ assert.match(profile, /profile-list[\s\S]*?应用版本[\s\S]*?profile-row-action" @tap="checkUpdate"[\s\S]*?检查更新[\s\S]*?profile-row-action" @tap="showSettings"[\s\S]*?系统设置/)
+ assert.doesNotMatch(profile, /class="profile-action"/)
+ assert.match(profile, /plus\.runtime\.version/)
+ assert.match(profile, /checkForAppUpdate\(\{ manual: true \}\)/)
+ assert.match(profile, /#ifndef APP-PLUS[\s\S]*?请在 PDA App 中检查更新/)
+})
diff --git a/tests/auth-session.test.mjs b/tests/auth-session.test.mjs
new file mode 100644
index 0000000..e6a1108
--- /dev/null
+++ b/tests/auth-session.test.mjs
@@ -0,0 +1,60 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
+
+test('authenticated PDA requests refresh once and replay once after unauthorized', async () => {
+ const http = await read('../api/http.js')
+ assert.match(http, /let refreshPromise = null/)
+ assert.match(http, /refreshPromise = \(async \(\) =>/)
+ assert.match(http, /session\.token && session\.token !== accessToken \? session\.token : await refreshAccessToken\(\)/)
+ assert.match(http, /response = await execute\(accessToken\)[\s\S]*?isUnauthorized\(response\)[\s\S]*?response = await execute\(accessToken\)/)
+ assert.match(http, /apiRequest/)
+ assert.match(http, /apiDownload/)
+ assert.match(http, /apiUpload/)
+})
+
+test('refresh rotation persists the complete token pair and preserves operator context', async () => {
+ const [session, auth] = await Promise.all([read('../stores/session.js'), read('../api/auth.js')])
+ assert.match(session, /export function updateTokenPair\(tokens\)/)
+ assert.match(session, /session\.refreshToken = tokens\.refreshToken/)
+ assert.match(session, /const previousUser = session\.currentUser \|\| \{\}/)
+ assert.match(session, /persistSession\(\)/)
+ assert.match(session, /isAccessTokenExpiring/)
+ assert.match(auth, /authRequest\('\/auth\/refresh', \{ refreshToken \}\)/)
+})
+
+test('app launch restores and validates persisted tokens before entering the workbench', async () => {
+ const [login, http] = await Promise.all([read('../pages/login/login.vue'), read('../api/http.js')])
+ assert.match(login, /async onLoad\(\)[\s\S]*?restoreSession\(\)[\s\S]*?await ensureAccessToken\(\)[\s\S]*?pages\/home\/home/)
+ assert.match(http, /export function ensureAccessToken\(\)/)
+ assert.match(login, /暂时无法恢复登录状态/)
+})
+
+test('confirmed session expiry clears sensitive state and redirects only once', async () => {
+ const http = await read('../api/http.js')
+ assert.match(http, /observedRefreshToken !== session\.refreshToken/)
+ assert.match(http, /signOut\(\)[\s\S]*?redirectAfterSessionExpiry\(\)/)
+ assert.match(http, /if \(redirectingToLogin\) return/)
+ assert.match(http, /url: '\/pages\/login\/login'/)
+ assert.match(http, /if \(isUnauthorized\(error\)\) return expireSession/)
+})
+
+test('manual logout revokes the refresh token without delaying local cleanup', async () => {
+ const [profile, auth] = await Promise.all([read('../pages/profile/profile.vue'), read('../api/auth.js')])
+ assert.match(auth, /export function logout\(refreshToken, accessToken\)/)
+ assert.match(profile, /const refreshToken = session\.refreshToken[\s\S]*?const accessToken = session\.token[\s\S]*?signOut\(\)[\s\S]*?uni\.reLaunch/)
+ assert.match(profile, /logoutRequest\(refreshToken, accessToken\)\.catch/)
+})
+
+test('production login starts empty and remembers only the account preference', async () => {
+ const [login, session] = await Promise.all([read('../pages/login/login.vue'), read('../stores/session.js')])
+ assert.match(login, /form: \{ userId: '', password: '' \}/)
+ assert.doesNotMatch(login, /platform_admin|ChangeMe123!/)
+ assert.match(session, /export function saveLoginPreference\(rememberAccount, account = ''\)/)
+ const savePreference = session.match(/export function saveLoginPreference[\s\S]*?\n\}/)?.[0] || ''
+ assert.match(savePreference, /account: rememberAccount/)
+ assert.doesNotMatch(savePreference, /password/)
+ assert.match(login, /if \(!this\.rememberAccount\) saveLoginPreference\(false\)/)
+})
diff --git a/tests/dropship-palletization.test.mjs b/tests/dropship-palletization.test.mjs
new file mode 100644
index 0000000..72abb22
--- /dev/null
+++ b/tests/dropship-palletization.test.mjs
@@ -0,0 +1,77 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
+
+test('domestic workbench exposes a real one-click dropship palletization entry', async () => {
+ const [data, home, modulePage, pages] = await Promise.all([
+ read('../data/demo-data.js'), read('../pages/home/home.vue'), read('../pages/module/module.vue'), read('../pages.json')
+ ])
+ assert.match(data, /key: 'inbound'[\s\S]*?name: '一件代发'[\s\S]*?available: true, visible: true/)
+ assert.match(home, /'一件代发': '\/pages\/dropship-tasks\/dropship-tasks'/)
+ assert.match(modulePage, /'一件代发': '\/pages\/dropship-tasks\/dropship-tasks'/)
+ assert.match(pages, /pages\/dropship-tasks\/dropship-tasks/)
+ assert.match(pages, /pages\/dropship-palletization\/dropship-palletization/)
+})
+
+test('dropship palletization adapter uses only the dedicated production PDA contract', async () => {
+ const api = await read('../api/pda-dropship.js')
+ for (const route of [
+ '/tasks/pending', '/cartons/resolve', '/cartons/count',
+ '/pallets/bind-pending', '/seal', '/unseal'
+ ]) assert.match(api, new RegExp(route.replaceAll('/', '\\/')))
+ assert.doesNotMatch(api, /apiDownload|downloadAllDropshipPalletLabels|pallets\/print/)
+ assert.match(api, /tasks\/pending\?\$\{params\.join\('&'\)\}/)
+ assert.match(api, /warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/)
+ assert.match(api, /operationId\('count'/)
+ assert.match(api, /operationId\('bind'/)
+ assert.match(api, /function stableId\(value\)[\s\S]*?String\(value \?\? ''\)\.trim\(\)/)
+ assert.match(api, /warehouseId: stableId\(warehouseId\)/)
+ assert.match(api, /warehouseId: stableId\(payload\.warehouseId\)/)
+ assert.doesNotMatch(api, /customerId|tenantId|inboundId/)
+})
+
+test('palletization UI accepts carton and pre-generated pallet scans in either order', async () => {
+ const [tasks, work] = await Promise.all([
+ read('../pages/dropship-tasks/dropship-tasks.vue'),
+ read('../pages/dropship-palletization/dropship-palletization.vue')
+ ])
+ for (const text of ['待入库任务', '入仓码', '进入作业']) assert.match(tasks, new RegExp(text))
+ assert.match(tasks, /\.search-card\s*\{[^}]*display:\s*flex;[^}]*box-shadow:\s*none;/)
+ assert.match(tasks, /\.task-list\s*\{[^}]*overflow:\s*hidden;[^}]*border-radius:\s*7px;/)
+ assert.match(tasks, /\.task-card\s*\{[^}]*margin:\s*0;[^}]*border-radius:\s*0;[^}]*box-shadow:\s*none;/)
+ for (const text of ['一件代发入库', '领取并开始入库', '扫箱清点', '读取 SKU', '确认清点', '扫托盘', '可选', '差异说明', '托盘码', '封托']) assert.match(work, new RegExp(text))
+ for (const text of ['sku-label', 'sku-code', 'quantity-stepper', 'adjustQuantity']) assert.match(work, new RegExp(text))
+ assert.match(work, /减少 \$\{line\.skuCode\} 清点数量/)
+ assert.match(work, /增加 \$\{line\.skuCode\} 清点数量/)
+ for (const text of ['compact-metrics', 'compact-action']) assert.match(work, new RegExp(text))
+ assert.doesNotMatch(work, /task\.customerName/)
+ assert.match(work, /\.task-summary-card, \.section-card \{[^}]*border:\s*0;[^}]*border-bottom:\s*6px solid[^}]*border-radius:\s*0;[^}]*box-shadow:\s*none;/)
+ assert.match(work, /\.carton-card, \.pallet-card \{[^}]*border:\s*0;[^}]*border-top:\s*1px solid[^}]*border-radius:\s*0;[^}]*background:\s*transparent;/)
+ for (const text of ['计划 {{ carton.expectedSkuQuantity', "carton.cargoName", "carton.countStatus === 'UNCOUNTED'"]) assert.doesNotMatch(work, new RegExp(text))
+ assert.match(work, /canResolveCarton\(\)[\s\S]*?boxCode[\s\S]*?!this\.loading[\s\S]*?!this\.submitting/)
+ for (const text of ['expectedPalletCount', 'generatedPalletCount', 'palletSetupReady', 'palletSetupMessage']) assert.match(work, new RegExp(text))
+ assert.match(work, /[\s\S]*?ScanTargetInput[\s\S]*?count-unavailable/)
+ assert.match(work, /if \(!this\.palletSetupReady\) \{ this\.error = this\.palletSetupMessage; return \}/)
+ for (const text of ['绑定到扫描托盘', '生成 DP 托盘并绑定', '确认封托']) assert.doesNotMatch(work, new RegExp(text))
+ assert.match(work, /class="task-summary-card"/)
+ assert.match(work, /\.task-summary-card\s*\{\s*display:\s*block;/)
+ assert.doesNotMatch(work, /class="summary-card"/)
+ assert.match(work, /startScanBroadcast/)
+ assert.match(work, /lineDiffers[\s\S]*?ACCEPT_VARIANCE/)
+ assert.match(work, /bindDropshipPendingCartons/)
+ assert.match(work, /palletNo: this\.assignableCurrentPallet/)
+ assert.match(work, /继续装其他入仓单/)
+ assert.match(work, /selectedPalletNo/)
+ assert.match(work, /已选择仓库开放托盘/)
+ assert.match(work, /直接扫描托盘码,只显示当前操作的托盘/)
+ assert.match(work, /currentPalletDetails/)
+ assert.match(work, /解除封托/)
+ assert.match(work, / readFile(new URL(path, import.meta.url), 'utf8')
+const page = await read('../pages/dropship-palletization/dropship-palletization.vue')
+const api = await read('../api/pda-dropship.js')
+test('pending cartons bind to pregenerated pallets with stable retry identity', async () => {
+ const { stableOperationId } = await import('../utils/idempotency.js')
+ const payload = { warehouseId: '9007199254740993', palletNo: 'DP-260914-0001', taskVersion: 7 }
+ assert.equal(stableOperationId('dropship-bind', 'u-task', payload), stableOperationId('dropship-bind', 'u-task', { ...payload }))
+ assert.notEqual(stableOperationId('dropship-bind', 'u-task', payload), stableOperationId('dropship-bind', 'u-task', { ...payload, taskVersion: 8 }))
+ assert.match(api, /pallets\/bind-pending/)
+ assert.doesNotMatch(api, /create-and-bind/)
+ assert.match(page, /bindDropshipPendingCartons\(this.taskId, this.warehouseId, code, this.task.version\)/)
+})
+test('palletization uses the test workbench and canonical routes', async () => {
+ const pages = await read('../pages.json')
+ assert.match(pages, /pages\/dropship-tasks\/dropship-tasks/)
+ assert.match(pages, /pages\/dropship-palletization\/dropship-palletization/)
+ assert.doesNotMatch(pages, /dropship-palletizing/)
+ assert.match(await read('../pages/home/home.vue'), /'一件代发': '\/pages\/dropship-tasks\/dropship-tasks'/)
+})
+test('formal palletization preserves both scan orders, moves, sealing and safe unsealing', () => {
+ for (const symbol of ['resolveDropshipCarton', 'countDropshipCarton', 'bindDropshipPendingCartons', 'moveDropshipCarton', 'sealDropshipPallet', 'unsealDropshipPallet', 'planDropshipScan', 'restoreDropshipPalletizingDraft']) assert.ok(page.includes(symbol))
+ assert.match(page, /移回待绑托/)
+ assert.match(page, /palletSetupReady/)
+ assert.match(page, /pallet\?\.status === 'SEALED'/)
+ assert.match(page, /currentPalletDetails\?\.cartons/)
+ assert.match(page, / {
+ assert.match(page, /v-for="\(line, index\) in skuCounts"/)
+ assert.match(page, /quantity-stepper/)
+ assert.doesNotMatch(page, /printPallet|printAllPallets|打开当前托盘 A4 单|打开全部 A4 托盘单/)
+})
+test('count, move, seal and unseal use the formal contract with versioned retries', () => {
+ for (const path of ['cartons/count', 'pallets/bind-pending', '/move', '/seal', '/unseal']) assert.ok(api.includes(path))
+ for (const field of ['skuLines', 'actualQuantity', 'discrepancyAction', 'discrepancyRemark']) assert.ok(page.includes(field))
+ assert.match(api, /expectedVersion: pallet.version/)
+ assert.match(api, /stableOperationId/)
+ assert.doesNotMatch(api, /actualSkuQuantity|create-and-bind/)
+})
+
+test('a counted carton moved back to pending can be confirmed into the active pallet without recounting', () => {
+ assert.match(page, /isResolvedPendingCarton\(\) \{ return this\.carton\?\.countStatus === 'COUNTED_UNBOUND' \}/)
+ assert.match(page, /canRebindResolvedCarton/)
+ assert.match(page, /确认归入当前托盘/)
+ assert.match(page, /moveDropshipCarton\(this\.taskId, this\.carton\.cartonId, payload\)/)
+ assert.match(page, /line\.actualQuantity \?\? line\.expectedQuantity/)
+})
diff --git a/tests/dropship-palletizing-state.test.mjs b/tests/dropship-palletizing-state.test.mjs
new file mode 100644
index 0000000..580c9b0
--- /dev/null
+++ b/tests/dropship-palletizing-state.test.mjs
@@ -0,0 +1,28 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const source = await readFile(new URL('../utils/dropship-palletizing-state.js', import.meta.url), 'utf8')
+const state = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`)
+
+test('pallet first selects the pallet and later carton resolution carries it', () => {
+ let scanState = state.createDropshipScanState()
+ let plan = state.planDropshipScan(scanState, 'dp-260908-0001')
+ assert.equal(plan.intent, state.DROPSHIP_SCAN_INTENT.SELECT_PALLET)
+ scanState = state.releaseDropshipScan(plan.state)
+ plan = state.planDropshipScan(scanState, 'box-001')
+ assert.deepEqual({ intent: plan.intent, boxCode: plan.boxCode, palletNo: plan.palletNo }, { intent: state.DROPSHIP_SCAN_INTENT.RESOLVE_CARTON, boxCode: 'BOX-001', palletNo: 'DP-260908-0001' })
+})
+
+test('boxes first requests batch binding when a pallet is scanned with pending cartons', () => {
+ const plan = state.planDropshipScan(state.createDropshipScanState(), 'DP-260908-0002', { unboundCartons: 3 })
+ assert.equal(plan.intent, state.DROPSHIP_SCAN_INTENT.BIND_PENDING)
+ assert.equal(plan.palletNo, 'DP-260908-0002')
+})
+
+test('offline, empty, and immediate repeated scans are blocked before API intent', () => {
+ assert.equal(state.planDropshipScan(state.createDropshipScanState({ online: false }), 'BOX-001').intent, state.DROPSHIP_SCAN_INTENT.BLOCKED)
+ assert.equal(state.planDropshipScan(state.createDropshipScanState(), ' ').intent, state.DROPSHIP_SCAN_INTENT.INVALID)
+ const first = state.planDropshipScan(state.createDropshipScanState(), 'BOX-001')
+ assert.equal(state.planDropshipScan(first.state, 'BOX-001').intent, state.DROPSHIP_SCAN_INTENT.DUPLICATE)
+})
diff --git a/tests/evidence-layout.test.mjs b/tests/evidence-layout.test.mjs
new file mode 100644
index 0000000..65e8ba8
--- /dev/null
+++ b/tests/evidence-layout.test.mjs
@@ -0,0 +1,21 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
+
+test('empty evidence slots stay compact and completed slots expand with photos', async () => {
+ const [page, styles] = await Promise.all([
+ read('../pages/picking/picking.vue'),
+ read('../styles/pda.scss')
+ ])
+
+ assert.match(page, /每类至少 1 张,拍照后自动上传,可继续追加/)
+ assert.match(page, /v-for="\(slot, slotIndex\) in evidenceSlots"/)
+ assert.match(page, /class="evidence-step"[\s\S]*?slotIndex \+ 1/)
+ assert.match(page, /evidenceEntries\(slot\.type\)\.length \? '✓'/)
+ assert.doesNotMatch(page, /class="evidence-empty-hint"/)
+ assert.match(styles, /\.evidence-card-header \{[^}]*min-height: 44px/)
+ assert.match(styles, /\.evidence-add-btn \{[^}]*min-height: 42px/)
+ assert.match(styles, /\.evidence-photo-grid \{[^}]*grid-template-columns/)
+})
diff --git a/tests/evidence-upload-queue.test.mjs b/tests/evidence-upload-queue.test.mjs
new file mode 100644
index 0000000..4f92f3b
--- /dev/null
+++ b/tests/evidence-upload-queue.test.mjs
@@ -0,0 +1,58 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+const { createEvidenceUploadQueue } = await import(new URL('../utils/evidence-upload-queue.js', import.meta.url).href)
+
+test('evidence queue exposes upload progress and successful durable result', async () => {
+ const changes = []
+ const queue = createEvidenceUploadQueue({
+ upload: async (item, progress) => {
+ progress(45)
+ return { fileId: 81, fileName: `${item.evidenceType}.jpg` }
+ },
+ onChange: (items) => changes.push(items)
+ })
+
+ queue.add({ id: 'empty-1', inboundId: 12, evidenceType: 'PHOTO_STEP_1', localPath: 'tmp/empty.jpg' })
+ const result = await queue.flush()
+
+ assert.equal(result[0].status, 'SUCCEEDED')
+ assert.equal(result[0].progress, 100)
+ assert.equal(result[0].result.fileId, 81)
+ assert.ok(changes.some((items) => items[0].status === 'UPLOADING' && items[0].progress === 45))
+})
+
+test('failed evidence remains observable and can be retried without a duplicate entry', async () => {
+ let attempts = 0
+ const queue = createEvidenceUploadQueue({
+ upload: async () => {
+ attempts += 1
+ if (attempts === 1) throw new Error('网络中断')
+ return { fileId: 82 }
+ }
+ })
+
+ const entry = queue.add({ id: 'half-1', inboundId: 12, evidenceType: 'PHOTO_STEP_2', localPath: 'tmp/half.jpg' })
+ await queue.flush()
+ assert.equal(queue.latest('PHOTO_STEP_2').status, 'FAILED')
+ assert.equal(queue.latest('PHOTO_STEP_2').error, '网络中断')
+
+ assert.equal(queue.retry(entry.id), true)
+ await queue.flush()
+ assert.equal(queue.latest('PHOTO_STEP_2').status, 'SUCCEEDED')
+ assert.equal(queue.latest('PHOTO_STEP_2').attempts, 2)
+ assert.equal(queue.snapshot().length, 1)
+})
+
+test('same evidence type appends and explicit void preserves the old photo', async () => {
+ const queue = createEvidenceUploadQueue({ upload: async () => { throw new Error('失败') } })
+ queue.add({ id: 'old', inboundId: 12, evidenceType: 'SEAL', localPath: 'tmp/old.jpg' })
+ await queue.flush()
+ queue.add({ id: 'new', inboundId: 12, evidenceType: 'SEAL', localPath: 'tmp/new.jpg' })
+
+ assert.equal(queue.snapshot().find((item) => item.id === 'old').status, 'FAILED')
+ assert.equal(queue.latest('SEAL').id, 'new')
+ assert.equal(queue.voidEntry('old', '照片模糊', 'worker-1'), true)
+ assert.equal(queue.snapshot().find((item) => item.id === 'old').status, 'VOIDED')
+ assert.equal(queue.snapshot().find((item) => item.id === 'old').voidReason, '照片模糊')
+})
diff --git a/tests/h5-button-reset.test.mjs b/tests/h5-button-reset.test.mjs
new file mode 100644
index 0000000..0ad2cfe
--- /dev/null
+++ b/tests/h5-button-reset.test.mjs
@@ -0,0 +1,10 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const styles = await readFile(new URL('../styles/pda.scss', import.meta.url), 'utf8')
+
+test('H5 uni-button removes the framework pseudo-element border globally', () => {
+ assert.match(styles, /button::after,\s*uni-button::after\s*\{[^}]*border:\s*0 !important;/)
+ assert.match(styles, /button:focus-visible,[\s\S]*?outline:\s*3px solid/)
+})
diff --git a/tests/idempotency.test.mjs b/tests/idempotency.test.mjs
new file mode 100644
index 0000000..873f6b4
--- /dev/null
+++ b/tests/idempotency.test.mjs
@@ -0,0 +1,35 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+const { stableOperationId } = await import(new URL('../utils/idempotency.js', import.meta.url).href)
+const { stableTaskIdentityKey, stableTaskOperationIdentity } = await import(new URL('../utils/task-identity.js', import.meta.url).href)
+
+test('PDA operation id is stable ASCII and within the backend 128 character limit', () => {
+ const payload = { itemStates: Object.fromEntries(Array.from({ length: 500 }, (_, index) => [`明细-${index}`, 'PICKED'])), reason: '中文异常原因'.repeat(100) }
+ const first = stableOperationId('submit', 'operator-1-task-2-container-3', payload)
+ const replay = stableOperationId('submit', 'operator-1-task-2-container-3', payload)
+ assert.equal(first, replay)
+ assert.ok(first.length <= 128)
+ assert.match(first, /^[\x20-\x7E]+$/)
+})
+
+test('PDA operation id changes when the draft changes', () => {
+ const first = stableOperationId('submit', 'operator-task-container', { item: 'PICKED' })
+ const second = stableOperationId('submit', 'operator-task-container', { item: 'SKIPPED' })
+ assert.notEqual(first, second)
+})
+
+test('real container number changes do not move the draft or idempotency identity', () => {
+ const before = { operatorId: 7, taskId: 11, loadingPlanId: 13, containerBookingId: 17, currentContainerNo: 'REAL-001' }
+ const after = { ...before, currentContainerNo: 'REAL-002' }
+ const payload = { item: 'PICKED' }
+ assert.equal(stableTaskIdentityKey(before), stableTaskIdentityKey(after))
+ assert.equal(stableOperationId('submit', stableTaskOperationIdentity(before), payload), stableOperationId('submit', stableTaskOperationIdentity(after), payload))
+})
+
+test('container booking mismatch is rejected by stable task binding', () => {
+ const original = { operatorId: 7, taskId: 11, loadingPlanId: 13, containerBookingId: 17 }
+ const differentBooking = { ...original, containerBookingId: 18 }
+ assert.equal(stableTaskIdentityKey(original), stableTaskIdentityKey(differentBooking))
+ assert.notEqual(stableTaskOperationIdentity(original), stableTaskOperationIdentity(differentBooking))
+})
diff --git a/tests/loading-completion-state.test.mjs b/tests/loading-completion-state.test.mjs
new file mode 100644
index 0000000..096764e
--- /dev/null
+++ b/tests/loading-completion-state.test.mjs
@@ -0,0 +1,61 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import {
+ COMPLETION_PHASE,
+ completionPresentation,
+ refreshCompletionAttempt,
+ resolveCompletionPhase,
+ shouldReconcileCompletion
+} from '../utils/loading-completion-state.js'
+
+test('accepted completion is processing and blocks every repeated mutation', () => {
+ for (const facts of [
+ { taskStatus: 'COMPLETING' },
+ { commandStatus: 'PROCESSING' },
+ { taskStatus: 'COMPLETING', commandStatus: 'PROCESSING' }
+ ]) {
+ const phase = resolveCompletionPhase(facts)
+ assert.equal(phase, COMPLETION_PHASE.PROCESSING)
+ assert.deepEqual(completionPresentation(phase), { label: '完成处理中', action: '', blocked: true })
+ }
+})
+
+test('submission and uncertain-result reconciliation block duplicate completion immediately', () => {
+ assert.equal(resolveCompletionPhase({ submitting: true }), COMPLETION_PHASE.SUBMITTING)
+ assert.equal(resolveCompletionPhase({ reconciling: true }), COMPLETION_PHASE.RECONCILING)
+ assert.equal(completionPresentation(COMPLETION_PHASE.SUBMITTING).blocked, true)
+ assert.equal(completionPresentation(COMPLETION_PHASE.RECONCILING).blocked, true)
+})
+
+test('completion failure exposes a readable stage and retry entry', () => {
+ const phase = resolveCompletionPhase({ taskStatus: 'COMPLETION_FAILED' })
+ assert.equal(phase, COMPLETION_PHASE.FAILED)
+ assert.deepEqual(completionPresentation(phase, '费用结算'), {
+ label: '完成失败:费用结算', action: '重试完成', blocked: true, retryable: true
+ })
+})
+
+test('retry reuses the original operation id and request-hashed task version', () => {
+ const attempt = refreshCompletionAttempt(
+ { operationId: 'complete-task-8-v3', expectedTaskVersion: 3, commandId: 'cmd-old' },
+ { taskVersion: 7, commandId: 'cmd-latest', commandStatus: 'COMPLETION_FAILED' }
+ )
+ assert.equal(attempt.operationId, 'complete-task-8-v3')
+ assert.equal(attempt.expectedTaskVersion, 3)
+ assert.equal(attempt.commandId, 'cmd-latest')
+ assert.throws(() => refreshCompletionAttempt({}, { taskVersion: 7 }), /缺少原完成操作标识/)
+ assert.throws(() => refreshCompletionAttempt({ operationId: 'stable-id' }), /缺少服务端最新任务版本/)
+})
+
+test('network and timeout failures reconcile before any retry while business conflicts stay intact', () => {
+ assert.equal(shouldReconcileCompletion({ network: true }), true)
+ assert.equal(shouldReconcileCompletion({ timeout: true }), true)
+ assert.equal(shouldReconcileCompletion({ code: 'REQUEST_TIMEOUT' }), true)
+ assert.equal(shouldReconcileCompletion({ code: 'A0409', apiErrorMeta: { requestId: 'req-1' } }), false)
+})
+
+test('completed facts win over stale processing facts', () => {
+ const phase = resolveCompletionPhase({ taskStatus: 'COMPLETED', commandStatus: 'PROCESSING' })
+ assert.equal(phase, COMPLETION_PHASE.COMPLETED)
+ assert.equal(completionPresentation(phase).label, '装柜已完成')
+})
diff --git a/tests/online-session.test.mjs b/tests/online-session.test.mjs
new file mode 100644
index 0000000..7d6cd8e
--- /dev/null
+++ b/tests/online-session.test.mjs
@@ -0,0 +1,26 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
+
+test('remote PDA mutations use the shared online gate', async () => {
+ const [api, http, network, page] = await Promise.all([
+ read('../api/pda-loading.js'), read('../api/http.js'), read('../utils/network.js'), read('../pages/picking/picking.vue')
+ ])
+ assert.match(api, /const request = apiRequest/)
+ assert.match(http, /async function runAuthenticated\(execute\) \{[\s\S]*?requireOnline\(\)/)
+ assert.match(api, /uploadEvidenceFile[\s\S]*?requireOnline\('当前网络不可用,无法上传装柜照片'\)/)
+ assert.match(network, /uni\.onNetworkStatusChange/)
+ assert.match(network, /result\.networkType !== 'none'/)
+ assert.match(page, /当前网络不可用,请联网后再拍照上传/)
+})
+
+test('remote refresh keeps draft actions but does not restore stale server item snapshots', async () => {
+ const page = await read('../pages/picking/picking.vue')
+ const restore = page.match(/restoreRemoteDraft\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(restore, /itemIds/)
+ assert.match(restore, /restored\.itemStates/)
+ assert.doesNotMatch(restore, /this\.boxes = restored\.boxes/)
+ assert.match(page, /refreshTask\(\)[\s\S]*?persistSession\(\)[\s\S]*?syncRemoteTask\(\)/)
+})
diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs
index 85d27f0..f525ef9 100644
--- a/tests/page-contract.test.mjs
+++ b/tests/page-contract.test.mjs
@@ -10,6 +10,7 @@ test('page configuration starts at login and registers independent PDA routes',
assert.equal(pages.pages[0].style.navigationStyle, 'custom')
assert.deepEqual(pages.pages.map((page) => page.path), [
'pages/login/login', 'pages/home/home', 'pages/module/module', 'pages/operation/operation',
+ 'pages/tasks/tasks', 'pages/dropship-tasks/dropship-tasks', 'pages/dropship-palletization/dropship-palletization',
'pages/history/history', 'pages/profile/profile', 'pages/scanner-test/scanner-test', 'pages/picking/picking'
])
})
@@ -17,32 +18,89 @@ test('page configuration starts at login and registers independent PDA routes',
test('application uses the Vue 3 uni-app entry and no unused Android permissions', async () => {
const [manifest, main] = await Promise.all([read('../manifest.json'), read('../main.js')])
assert.match(manifest, /"vueVersion"\s*:\s*"3"/)
+ assert.match(manifest, /"uniStatistics"\s*:\s*\{\s*"enable"\s*:\s*false\s*\}/)
assert.match(main, /createSSRApp/)
assert.doesNotMatch(main, /new\s+Vue/)
assert.doesNotMatch(manifest, /android\.permission\.(VIBRATE|ACCESS_NETWORK_STATE)/)
})
-test('login is isolated and relaunches to the modular workbench', async () => {
- const login = await read('../pages/login/login.vue')
- for (const text of ['柏悦物流 · PDA作业端', '快捷登录(演示账号)', '工号 / 用户名', '作业仓库']) assert.match(login, new RegExp(text))
- assert.match(login, /signIn\(account, this\.form\.warehouse\)/)
+test('login uses the real auth endpoint and relaunches to the modular workbench', async () => {
+ const [login, auth, session, runtime, vite, manifest] = await Promise.all([
+ read('../pages/login/login.vue'), read('../api/auth.js'), read('../stores/session.js'), read('../config/runtime.js'), read('../vite.config.js'), read('../manifest.json')
+ ])
+ for (const text of ['柏悦物流 · PDA作业端', '工号 / 用户名', '登录中']) assert.match(login, new RegExp(text))
+ assert.doesNotMatch(login, /DEMO_ACCOUNTS|快捷登录|作业仓库/)
+ assert.match(auth, /\/auth\/login/)
+ assert.match(auth, /authRequest\('\/auth\/login', \{ loginId, password \}\)/)
+ assert.doesNotMatch(auth, /authRequest\('\/auth\/login', \{ username, password \}\)/)
+ assert.match(login, /loginRequest\(userId, this\.form\.password\)/)
+ assert.match(login, /signInWithTokens\(tokens\)/)
+ assert.match(login, /form: \{ userId: '', password: '' \}/)
+ assert.doesNotMatch(login, /platform_admin|ChangeMe123!/)
+ assert.match(login, /记住账号/)
+ assert.match(login, /loadLoginPreference\(\)/)
+ assert.match(login, /saveLoginPreference\(this\.rememberAccount, userId\)/)
+ assert.match(session, /pda-auth-session/)
+ assert.match(session, /pda-login-account-preference/)
+ assert.match(session, /claims\.app_code !== 'PLATFORM'/)
+ assert.match(runtime, /import\.meta\.env\.VITE_API_BASE_URL/)
+ assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL \|\| '\/gateway-api'/)
+ assert.doesNotMatch(runtime, /typeof\s+import\.meta/)
+ assert.match(vite, /API_PROXY_TARGET/)
+ assert.match(vite, /process\.env\.UNI_INPUT_DIR \|\| process\.cwd\(\)/)
+ assert.match(vite, /'\/gateway-api'[\s\S]*?target: proxyTarget[\s\S]*?changeOrigin: true[\s\S]*?replace\(\/\^\\\/gateway-api\/[\s\S]*?'\/api'/)
+ assert.match(vite, /removeHeader\('origin'\)/)
+ assert.match(vite, /removeHeader\('referer'\)/)
+ assert.match(vite, /proxy\.on\('error'/)
+ assert.doesNotMatch(manifest, /"proxy"\s*:|pathRewrite|disableHostCheck/)
assert.match(login, /uni\.reLaunch\(\{ url: '\/pages\/home\/home' \}\)/)
})
+test('picking task list uses remote loading orders and never falls back to demo tasks', async () => {
+ const [tasks, home, modulePage] = await Promise.all([read('../pages/tasks/tasks.vue'), read('../pages/home/home.vue'), read('../pages/module/module.vue')])
+ assert.match(tasks, /listLoadingOrders/)
+ assert.match(tasks, /当前网络不可用,请联网后刷新/)
+ assert.match(tasks, /进入并开始拣货/)
+ assert.match(tasks, /待平台补录集装箱号/)
+ assert.match(tasks, /请先在平台预装详情补录真实集装箱号/)
+ assert.match(tasks, /AWAITING_REVIEW[\s\S]*?待上传证据\/确认装柜/)
+ assert.match(tasks, /task\.workflowStatus === 'AWAITING_REVIEW' \? '继续装柜取证'/)
+ assert.match(tasks, /\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETION_FAILED'\]\.includes\(task\.workflowStatus\)/)
+ assert.match(tasks, /\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'PROCESSING', 'COMPLETION_FAILED'\]\.includes\(task\.workflowStatus\)/)
+ assert.match(tasks, /\['AWAITING_REVIEW', 'COMPLETION_FAILED'\]\.includes\(task\.workflowStatus\) \|\| \(task\.assignmentVersion !== null/)
+ assert.match(tasks, /暂无待处理的拣货任务/)
+ assert.match(tasks, /class="task-code">\{\{ containerReference\(task\) \}\}/)
+ assert.doesNotMatch(tasks, /task-identifiers|businessReference\(task\)/)
+ assert.match(tasks, /button v-for="task in tasks"[\s\S]*?class="task-card"[\s\S]*?@tap="enterTask\(task\)"/)
+ assert.match(tasks, /records\.filter\(\(task\) => this\.isTaskVisible\(task\)\)/)
+ assert.match(tasks, /isTaskReady\(task\)[\s\S]*?task\.recordStatus === 'ACTIVE'[\s\S]*?!task\.mergedIntoContainerBookingId/)
+ assert.match(tasks, /records\.filter\(\(task\) => this\.isMergedTask\(task\)\)\.forEach\(\(task\) => this\.clearMergedTaskDraft\(task\)\)/)
+ assert.match(tasks, /clearMergedTaskDraft\(task\)[\s\S]*?clearPickingDraft\(\{ operatorId, taskId: task\.taskId, loadingPlanId: task\.loadingPlanId, containerBookingId: task\.containerBookingId/)
+ assert.match(tasks, /task\.taskId[\s\S]*?task\.loadingPlanId[\s\S]*?task\.containerBookingId[\s\S]*?task\.currentContainerNo[\s\S]*?task\.version[\s\S]*?task\.assignmentVersion/)
+ assert.doesNotMatch(tasks, /task\.taskStatus === 'LOADING'/)
+ assert.doesNotMatch(tasks, /demo-data|DEMO_/)
+ assert.match(home, /routes\[operation.name\]/)
+ assert.match(modulePage, /routes\[operation.name\]/)
+})
+
test('workbench, module, history and profile are independent sources', async () => {
const [home, module, history, profile] = await Promise.all([
read('../pages/home/home.vue'), read('../pages/module/module.vue'), read('../pages/history/history.vue'), read('../pages/profile/profile.vue')
])
- for (const text of ['首页', '库内作业', 'home-operation-grid', 'home-operation-primary', 'getPickingOverview', 'uni-icons']) assert.match(home, new RegExp(text))
+ for (const text of ['首页', '库内作业', 'home-operation-grid', 'home-operation-primary', '开始作业', 'uni-icons']) assert.match(home, new RegExp(text))
+ assert.doesNotMatch(home, /getPickingOverview|pendingBoxCount|箱待拣/)
assert.doesNotMatch(home, /BottomNav|status-strip|task-card/)
- for (const text of ['业务模块', '业务功能', '第一版本仅开放']) assert.match(module, new RegExp(text))
+ for (const text of ['业务模块', '业务功能', '一件代发', '出库拣货']) assert.match(module, new RegExp(text))
assert.match(home, /:disabled="!operation\.available"/)
assert.match(home, /home-operation-disabled/)
- assert.match(home, /openOperation\(operation\) \{ if \(operation\.available\) uni\.navigateTo/)
- assert.doesNotMatch(home, /pages\/operation\/operation\?module=/)
+ assert.match(home, /home-operation-meta home-operation-pending/)
+ assert.match(home, /home-operation-meta home-operation-unavailable/)
+ assert.match(home, /openOperation\(operation\)[\s\S]*?if \(!operation\.available\) return[\s\S]*?'一件代发': '\/pages\/dropship-tasks\/dropship-tasks'[\s\S]*?'出库拣货': '\/pages\/tasks\/tasks'[\s\S]*?'库位查询'/)
+ assert.match(home, /pages\/operation\/operation\?module=query&name=/)
assert.match(module, /:disabled="!operation\.available"/)
assert.match(module, /operation-item-disabled/)
- assert.doesNotMatch(module, /pages\/operation\/operation\?module=/)
+ assert.match(module, /openOperation\(operation\)[\s\S]*?if \(!operation\.available\) return[\s\S]*?'一件代发': '\/pages\/dropship-tasks\/dropship-tasks'[\s\S]*?'出库拣货': '\/pages\/tasks\/tasks'/)
+ assert.match(module, /pages\/operation\/operation\?module=\$\{encodeURIComponent\(this\.moduleKey\)\}&name=/)
assert.match(history, /作业记录/)
assert.match(profile, /个人中心/)
assert.match(profile, /signOut\(\)/)
@@ -50,10 +108,25 @@ test('workbench, module, history and profile are independent sources', async ()
assert.doesNotMatch([home, module, history, profile].join('\n'), /BottomNav/)
})
-test('demo model contains all four modules and picking belongs to outbound', async () => {
+test('home exposes only the two confirmed operations and keeps hidden definitions', async () => {
const data = await read('../data/demo-data.js')
- for (const text of ['入库', '出库', '库存', '查询', '集装箱拣货']) assert.match(data, new RegExp(text))
- assert.match(data, /key: 'outbound'[\s\S]*?name: '集装箱拣货'[\s\S]*?available: true/)
+ const { MODULES } = await import('../data/demo-data.js')
+ for (const text of ['入库', '出库', '库存', '查询', '出库拣货']) assert.match(data, new RegExp(text))
+ assert.match(data, /key: 'inbound'[\s\S]*?name: '一件代发'[\s\S]*?available: true, visible: true/)
+ assert.match(data, /key: 'outbound'[\s\S]*?name: '出库拣货'[\s\S]*?available: true, visible: true/)
+ assert.match(await read('../pages/home/home.vue'), /operations: module\.operations\.filter\(\(operation\) => operation\.visible\)/)
+ assert.deepEqual(MODULES.flatMap((module) => module.operations
+ .filter((operation) => operation.visible)
+ .map((operation) => `${module.key}:${operation.name}`)), [
+ 'inbound:一件代发',
+ 'outbound:出库拣货'
+ ])
+ const locationQuery = MODULES.find((module) => module.key === 'query')?.operations
+ .find((operation) => operation.name === '库位查询')
+ assert.ok(locationQuery)
+ assert.ok(!locationQuery.visible)
+ assert.ok(!locationQuery.available)
+ assert.ok(MODULES.flatMap((module) => module.operations).some((operation) => operation.name === '到货收货' && !operation.visible))
for (const boxNo of ['CBX-20260709-001', 'CBX-20260709-002', 'CBX-20260709-003']) assert.match(data, new RegExp(boxNo))
})
@@ -75,7 +148,7 @@ test('non-picking operations remain complete static task pages', async () => {
test('picking page keeps scan, draft and risky transition contracts', async () => {
const [page, scanner] = await Promise.all([read('../pages/picking/picking.vue'), read('../utils/scan-broadcast.js')])
- for (const text of ['确认拣出', '部分拣货', '跳拣', '提交拣货完成', '下一箱', '最近扫码']) assert.match(page, new RegExp(text))
+ for (const text of ['确认拣出', '部分拣货', '跳拣', '提交拣货完成', '去拍照', '最近扫码']) assert.match(page, new RegExp(text))
assert.match(page, /saveCurrentBoxDraft\(\)[\s\S]*?this\.boxDrafts =/)
assert.match(page, /loadBoxState\(index\)[\s\S]*?const draft = this\.boxDrafts\[index\]/)
assert.match(page, /requestBoxSwitch\(index\)[\s\S]*?this\.openOverlay\('switchBoxConfirm'\)/)
@@ -83,15 +156,20 @@ test('picking page keeps scan, draft and risky transition contracts', async () =
assert.match(page, /submitStatusHint\(\)[\s\S]*?提交时需选择原因/)
assert.match(page, /onBackPress\(\)/)
assert.doesNotMatch(page, /scanFocused|restoreScanFocus|hideKeyboard|@confirm="handleScan"/)
- assert.match(page, /= 0 && processScanEnd > processScanStart)
+ assert.doesNotMatch(page.slice(processScanStart, processScanEnd), /this\.scanValue = ''/)
assert.match(scanner, /android\.intent\.action\.SCANRESULT/)
assert.match(scanner, /SCAN_BROADCAST_EXTRA = 'value'/)
assert.match(scanner, /registerReceiver\(receiver, filter\)/)
@@ -101,6 +179,17 @@ test('picking page keeps scan, draft and risky transition contracts', async () =
assert.match(page, /leavePicking\(\)[\s\S]*?navigateBackOr\('\/pages\/home\/home'\)/)
})
+test('picking cabinet selector synchronizes all ready server tasks by stable task id', async () => {
+ const page = await read('../pages/picking/picking.vue')
+ assert.match(page, /listLoadingOrders/)
+ assert.match(page, /async onShow\(\)[\s\S]*?await this\.refreshAvailableBoxes\(\)[\s\S]*?await this\.syncRemoteTask\(\)/)
+ assert.match(page, /async refreshAvailableBoxes\(\)[\s\S]*?listLoadingOrders\(\{ page: 1, size: 50, warehouseId: session\.currentUser\?\.warehouseId \}\)/)
+ assert.match(page, /isSelectableTask\(task\)[\s\S]*?recordStatus === 'ACTIVE'[\s\S]*?\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW'\]/)
+ assert.match(page, /existingByTaskId[\s\S]*?recordByTaskId[\s\S]*?orderedTaskIds[\s\S]*?this\.boxes = nextBoxes/)
+ assert.match(page, /selectedTaskId[\s\S]*?findIndex\(\(box\) => String\(box\.taskId \|\| ''\) === selectedTaskId\)/)
+ assert.match(page, /selectBox\(index\)[\s\S]*?this\.remoteTaskId = this\.currentBox\.taskId \|\| null[\s\S]*?this\.resetEvidenceState\(\)[\s\S]*?this\.syncRemoteTask\(\)/)
+})
+
test('navigation returns to the real source and prevents duplicate utility pages', async () => {
const [navigation, header, modulePage, operation, picking] = await Promise.all([
read('../utils/navigation.js'), read('../components/PortalHeader.vue'), read('../pages/module/module.vue'),
@@ -125,6 +214,7 @@ test('home consumes Android Back while its utility menu is open', async () => {
read('../pages/home/home.vue'), read('../pages/profile/profile.vue'), read('../components/PortalHeader.vue')
])
assert.match(home, /@menu-change="menuOpen = \$event"/)
+ assert.doesNotMatch(home, /:show-profile="false"/)
assert.match(home, /onBackPress\(\)[\s\S]*?if \(!this\.menuOpen\) return false[\s\S]*?this\.\$refs\.portalHeader\.closeMenu\(\)[\s\S]*?return true/)
assert.match(profile, /:show-profile="false"/)
assert.match(profile, /openLogout\(\) \{ this\.logoutOpen = true; this\.\$refs\.logoutPopup\.open\(\) \}/)
@@ -142,15 +232,17 @@ test('business dialogs use uni-ui popup components consistently', async () => {
assert.match(popupHeader, /aria-label="`关闭\$\{title\}`"/)
assert.match(popupHeader, /@tap="\$emit\('close'\)"/)
assert.match(header, /作业记录<|>个人中心)
assert.doesNotMatch(header, /uni\.showActionSheet/)
assert.match(profile, /]*:is-mask-click="true"[^>]*@change="handleOverlayChange\\('${name}', \\$event\\)"`))
}
- assert.equal((picking.match(/ {
@@ -190,11 +282,11 @@ test('reusable scan target input separates field selection from page-level broad
assert.match(component, / {
+test('picking session persists per task identity and clears on logout', async () => {
const [store, session, page] = await Promise.all([read('../stores/picking.js'), read('../stores/session.js'), read('../pages/picking/picking.vue')])
assert.match(store, /getPickingSession/)
assert.match(store, /savePickingSession/)
@@ -204,19 +296,38 @@ test('picking session is memory-only, survives navigation and clears on logout',
})
test('shared visual contracts preserve accessible PDA targets', async () => {
- const [app, styles, picking] = await Promise.all([read('../App.vue'), read('../styles/pda.scss'), read('../pages/picking/picking.vue')])
+ const [app, styles, picking, tasks] = await Promise.all([read('../App.vue'), read('../styles/pda.scss'), read('../pages/picking/picking.vue'), read('../pages/tasks/tasks.vue')])
assert.match(app, /@import '@\/styles\/pda\.scss'/)
assert.doesNotMatch(styles, /<\/?style\b/)
for (const className of ['pick-btn', 'partial-quick-btn']) assert.match(styles, new RegExp(`\\.${className}\\s*\\{[\\s\\S]*?height:\\s*48px;`))
+ assert.match(styles, /\.pick-item-actions\s*\{[^}]*grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\);[^}]*width:\s*100%;/)
+ assert.match(styles, /\.pick-btn\s*\{[^}]*width:\s*100%;[^}]*margin:\s*0;[^}]*align-items:\s*center;[^}]*justify-content:\s*center;/)
+ assert.match(styles, /\.pick-btn::after\s*\{[^}]*border:\s*0;/)
+ assert.match(styles, /\.processed-actions\s*\{[^}]*grid-template-columns:\s*1fr;/)
+ assert.match(styles, /\.pick-btn-undo\s*\{[^}]*border-color:\s*#efb7b7;[^}]*color:\s*#b42323;[^}]*background:\s*#fff5f5;/)
assert.doesNotMatch(styles, /\.scan-btn\s*\{/)
assert.match(styles, /\.modal-cancel-btn,[\s\S]*?height:\s*48px;/)
assert.match(styles, /\.modal-sheet\s*\{[\s\S]*?padding:\s*20px 18px 18px;/)
assert.doesNotMatch(styles, /\.modal-sheet\s*\{[\s\S]*?padding:[^;]*env\(/)
assert.match(styles, /\.submit-decision-modal\s*\{[^}]*padding:\s*18px 16px 16px;/)
+ assert.match(styles, /\.evidence-card\s*\{[^}]*margin-bottom:\s*8px;/)
+ assert.match(styles, /\.evidence-card:last-child\s*\{[^}]*margin-bottom:\s*0;/)
+ assert.match(tasks, /\.task-card\s*\{[^}]*margin:\s*0 0 12px;/)
+ assert.match(tasks, /\.task-card:last-child\s*\{[^}]*margin-bottom:\s*0;/)
assert.match(styles, /\.work-content\s*\{[^}]*padding:\s*1px 0 104px;/)
+ assert.match(picking, /class="box-select-summary"/)
+ assert.match(styles, /\.box-select-summary\s*\{[^}]*min-width:\s*0;[^}]*overflow-wrap:\s*anywhere;[^}]*white-space:\s*normal;/)
+ assert.doesNotMatch(styles, /\.box-select-summary\s*\{[^}]*(text-overflow:\s*ellipsis|white-space:\s*nowrap)/)
+ assert.match(styles, /\.box-dropdown\s*\{[^}]*top:\s*calc\(100% \+ 5px\);/)
assert.match(styles, /\.submit-section\s*\{[\s\S]*?bottom:\s*8px;[\s\S]*?width:\s*calc\(100% - 20px\);/)
assert.match(styles, /\.submit-btn\s*\{[\s\S]*?flex:\s*1;[\s\S]*?height:\s*56px;/)
- assert.match(styles, /\.home-group-mark\s*\{[^}]*margin-right:\s*10px;/)
+ assert.match(styles, /\.home-group-mark\s*\{[^}]*margin-right:\s*7px;/)
+ assert.match(styles, /\.home-operation-grid\s*\{[^}]*grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\);/)
+ assert.match(styles, /\.home-operation\s*\{[^}]*width:\s*100%;[^}]*min-height:\s*78px;[^}]*align-self:\s*stretch;/)
+ assert.match(styles, /\.home-operation-name\s*\{[^}]*min-height:\s*30px;[^}]*white-space:\s*normal;/)
+ assert.doesNotMatch(styles, /\.home-operation-name\s*\{[^}]*(text-overflow:\s*ellipsis|-webkit-line-clamp|white-space:\s*nowrap)/)
+ assert.match(styles, /\.home-operation-meta\s*\{[^}]*border-radius:\s*7px;[^}]*white-space:\s*nowrap;/)
+ assert.match(styles, /@media \(max-width:\s*340px\)\s*\{[\s\S]*?\.home-operation-grid\s*\{[^}]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\);/)
assert.match(styles, /\.header-menu-item\s*\{[^}]*width:\s*calc\(100% - 32px\);[^}]*margin:\s*0 16px;/)
assert.match(styles, /\.header-menu-close\s*\{[^}]*width:\s*calc\(100% - 32px\);/)
assert.match(picking, /aria-modal="true"/)
diff --git a/tests/partial-layout.test.mjs b/tests/partial-layout.test.mjs
new file mode 100644
index 0000000..87672ff
--- /dev/null
+++ b/tests/partial-layout.test.mjs
@@ -0,0 +1,25 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
+
+test('partial picking uses a compact quantity panel with four equal quick choices', async () => {
+ const [page, styles] = await Promise.all([
+ read('../pages/picking/picking.vue'),
+ read('../styles/pda.scss')
+ ])
+
+ assert.match(page, /partial-info-copy[\s\S]*?partial-info-qty-number/)
+ assert.match(page, /partial-input-label">实际拣出数量[\s\S]*?partial-input-control/)
+ assert.match(page, /placeholder="请输入数量"/)
+ assert.match(page, /placeholder-class="partial-input-placeholder"/)
+ assert.match(page, /partial-quick-btn"[\s\S]*?:class="\{ selected: String\(partialInput\) === String\(option\.value\) \}"/)
+ assert.match(page, /partial-quick-ratio[\s\S]*?partial-quick-value/)
+ assert.match(styles, /\.partial-quick-row \{[^}]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\)[^}]*align-items: stretch/)
+ assert.match(styles, /\.partial-quick-btn \{[^}]*width: 100%;[^}]*height: 48px;[^}]*margin: 0;/)
+ assert.match(styles, /\.partial-input-control \{[^}]*height: 50px;[^}]*border: 2px solid \$primary/)
+ assert.match(styles, /\.partial-input-placeholder \{[^}]*font-size: 14px;[^}]*font-weight: 400;/)
+ assert.match(styles, /\.partial-modal \.modal-actions \{[^}]*margin-top: 12px/)
+ assert.match(styles, /\.partial-modal \.modal-cancel-btn, \.partial-modal \.modal-confirm-btn \{[^}]*width: 100%;[^}]*margin: 0;/)
+})
diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs
new file mode 100644
index 0000000..b6a71a9
--- /dev/null
+++ b/tests/pda-loading-api.test.mjs
@@ -0,0 +1,203 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const source = await readFile(new URL('../api/pda-loading.js', import.meta.url), 'utf8')
+const http = await readFile(new URL('../api/http.js', import.meta.url), 'utf8')
+const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
+const styles = await readFile(new URL('../styles/pda.scss', import.meta.url), 'utf8')
+
+test('sealed pallets use a single authoritative pallet command and prohibit partial picking', () => {
+ assert.match(source, /pallets\/pick/)
+ assert.match(page, /submitImmediateItemAction\(item, 'PICK_PALLET', \{ palletNo: item.palletNo \}\)/)
+ assert.match(page, /整托货物不能跳拣/)
+ assert.match(page, /整托货物不能部分拣货/)
+ assert.match(page, /expectedTaskVersion: Number\(this.pickingVersion\)/)
+})
+test('DP scan records success only after the atomic server command succeeds', async () => {
+ const method = page.match(/async pickScannedPallet\(item, palletNo\) \{[\s\S]*?\n\t\t\t\},/)?.[0]
+ assert.ok(method)
+ const behavior = new Function('ITEM_STATUS', 'return ({' + method + '})')({ PICKED: 'picked' })
+ const item = { id: '1', palletNo: 'DP-260914-0001', qty: 5 }
+ const calls = []
+ let succeeds = false
+ const context = { itemStates: {}, partialQuantities: {}, scannedBoxCodes: {}, async submitImmediateItemAction(...args) { calls.push(args); return succeeds }, recordScanResult() {}, showToast() {}, persistSession() {} }
+ assert.equal(await behavior.pickScannedPallet.call(context, item, item.palletNo), false)
+ assert.deepEqual(context.itemStates, {})
+ succeeds = true
+ assert.equal(await behavior.pickScannedPallet.call(context, item, item.palletNo), true)
+ assert.deepEqual(context.itemStates, { 1: 'picked' })
+ assert.deepEqual(calls[0], [item, 'PICK_PALLET', { palletNo: item.palletNo }])
+ assert.deepEqual(calls[0], calls[1])
+})
+
+test('PDA loading adapter uses only task-scoped cabinet evidence paths', () => {
+ for (const path of [
+ '/first-leg/pda-loading/tasks/pending'
+ ]) assert.match(source, new RegExp(path.replace(/[?]/g, '\\?')))
+ assert.match(source, /\/first-leg\/pda-loading\/warehouses/)
+ assert.match(source, /query\.push\(`warehouseId=\$\{encodeURIComponent\(warehouseId\)\}`\)/)
+ assert.match(source, /first-leg\/pda-loading\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence/)
+ assert.match(source, /'Idempotency-Key'/)
+ assert.match(source, /PHOTO_STEP_1[\s\S]*PHOTO_STEP_4[\s\S]*SEAL/)
+ for (const label of ['空仓照片', '半仓照片', '满仓照片', '锁仓照片', '带锁码照']) assert.match(source, new RegExp(label))
+ assert.match(source, /apiUpload\(/)
+ assert.match(http, /uni\.uploadFile/)
+ assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\?\$\{query\.join\('&'\)\}/)
+ assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/void/)
+ assert.doesNotMatch(source, /inbounds\/\$\{encodeURIComponent\(inboundId\)\}\/loading\/evidence/)
+ assert.doesNotMatch(source, /loading\/evidence\/pda\?tenantId=/)
+ assert.match(source, /export function completeContainerPickingTask\(taskId, payload = \{\}\)/)
+ assert.match(source, /first-leg\/pda-loading\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/complete/)
+ assert.match(source, /expectedTaskVersion/)
+})
+
+test('PDA adapter does not treat non-200 response bodies or network failures as success', () => {
+ assert.match(http, /if \(code !== '200'\)/)
+ assert.match(http, /next\.network = true/)
+ assert.match(source, /throw new Error\('缺少装柜任务标识,无法上传装柜照片/)
+})
+
+test('C02 identifiers and separate picking/inbound versions are normalized explicitly', () => {
+ assert.match(source, /inboundId: row\.inboundId/)
+ assert.match(source, /inboundVersion: row\.inboundVersion/)
+ assert.match(source, /pickingVersion: row\.pickingVersion \?\? row\.loading\?\.pickingVersion/)
+ assert.match(source, /pickingVersion: task\.version \?\? task\.pickingVersion/)
+ assert.match(page, /version: Number\(this\.pickingVersion\)/)
+ assert.match(page, /ITEM_STATUS, classifyScan, createItemStates, getPickSummary, isSealedPalletItem, submitStates/)
+ assert.match(page, /taskItemId: item\.id \?\? item\.itemId/)
+ assert.match(page, /submit\.batch/)
+ assert.match(page, /item\.itemStatus === 'PICKED'/)
+ assert.match(page, /item\.itemStatus === 'PARTIAL'/)
+ assert.match(page, /!pickingCompleted && !summary\.canSubmit/)
+ assert.doesNotMatch(page, /loadingVersion/)
+ assert.match(page, /状态尚未同步,请刷新重试/)
+})
+
+test('PDA normalizes the frozen active and merged container identity fields', () => {
+ for (const field of ['sourceType', 'recordStatus', 'mergedIntoContainerBookingId']) {
+ assert.match(source, new RegExp(`${field}: row\\.${field} \\?\\? row\\.container\\?\\.${field}`))
+ assert.match(source, new RegExp(`${field}: task\\.${field} \\?\\? task\\.container\\?\\.${field}`))
+ }
+})
+
+test('picking page exposes five evidence slots, retry and remote completion states', () => {
+ for (const text of ['PHOTO_STEP_1', 'PHOTO_STEP_4', 'SEAL', '网络失败', '确认装柜完成']) {
+ assert.match(`${source}\n${page}`, new RegExp(text))
+ }
+ assert.match(page, /createEvidenceUploadQueue/)
+ assert.match(page, /retryEvidence/)
+ assert.match(page, /uploadEvidenceFile/)
+ assert.match(page, /submitting \|\| \(!pickingCompleted && !summary\.canSubmit\)/)
+ assert.doesNotMatch(page, /completePicking\(/)
+ assert.match(page, /sourceType: \['camera'\]/)
+ assert.doesNotMatch(page, /sourceType: \['camera', 'album'\]/)
+ assert.match(page, /evidenceEntries\(slot\.type\)/)
+ assert.match(page, /deleteEvidencePhoto\(/)
+ assert.match(page, /class="evidence-delete-btn" aria-label="删除照片"[\s\S]*?>×<\/button>/)
+ assert.doesNotMatch(page, />作废<\/button>/)
+ assert.match(styles, /\.evidence-delete-btn\s*\{[^}]*width:\s*40px;[^}]*background:\s*transparent;/)
+ assert.match(page, /evidenceComplete/)
+})
+
+test('immediate task responses replace a stale normalized picking version', () => {
+ const normalizeSource = source.match(/export function normalizePickingTask\(task = \{\}\) \{[\s\S]*?\n\}/)?.[0]
+ assert.ok(normalizeSource)
+ const normalizePickingTask = new Function(`${normalizeSource.replace('export function', 'function')}; return normalizePickingTask`)()
+ const previous = normalizePickingTask({ id: 'task-1', version: 3 })
+ assert.equal(previous.pickingVersion, 3)
+ const mergedMutationResponse = normalizePickingTask({ ...previous, version: 4 })
+ assert.equal(mergedMutationResponse.pickingVersion, 4)
+})
+
+test('PDA evidence binding normalizes string ids from the task-scoped DTO', () => {
+ assert.match(page, /evidenceItems\(result\)[\s\S]*?id: String\(item\.evidenceId \?\? item\.id/)
+ assert.match(page, /fileId: String\(item\.fileId/)
+ assert.match(page, /applyEvidenceResponse\(result, type, localPath, identity\)[\s\S]*?this\.evidenceFiles = remoteItems[\s\S]*?\.reduce/)
+ assert.match(page, /cacheEvidencePreview\(saved\.fileId, localPath/)
+})
+
+test('PDA downloads authenticated task-scoped evidence previews', () => {
+ assert.match(source, /export function getEvidencePreviewUrl\(taskId, evidenceId, warehouseId\)/)
+ assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content\?warehouseId=/)
+ assert.match(source, /export function downloadEvidencePreview\(previewUrl\)/)
+ assert.match(source, /apiDownload\(\{ url \}\)/)
+ assert.match(http, /uni\.downloadFile\(/)
+ assert.match(http, /headers\.Authorization = `Bearer \$\{accessToken\}`/)
+ assert.match(source, /response\.tempFilePath/)
+ assert.match(source, /evidence\.preview\.response/)
+ assert.match(source, /\^image\\\//)
+ assert.match(source, /resolveEvidencePreviewUrl/)
+ assert.match(source, /const url = resolveEvidencePreviewUrl\(previewUrl, session\.apiBase/)
+})
+
+test('cabinet evidence never selects an inbound owner or builds a container route in the PDA', () => {
+ const evidenceAdapter = source.match(/export function getEvidence\(taskId, warehouseId\)[\s\S]*?export function completeContainerPickingTask/)?.[0] || ''
+ for (const field of ['inboundId', 'tenantId', 'containerBookingId']) assert.doesNotMatch(evidenceAdapter, new RegExp(field))
+ assert.match(page, /captureEvidenceIdentity\(\) \{[\s\S]*?taskId:/)
+ assert.match(page, /uploadEvidenceFile\(item\.taskId, item\.warehouseId, item, item\.localPath/)
+ assert.match(page, /queue\.add\(\{ taskId: identity\.taskId, warehouseId: identity\.warehouseId, evidenceType: type, expectedVersion/)
+ assert.doesNotMatch(page, /evidenceIdentity\?\.inboundId|identity\.inboundId/)
+ assert.doesNotMatch(page, /currentBox\.warehouseId \|\| session\.currentUser/)
+ assert.match(source, /warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/)
+})
+
+test('PDA cabinet picking adapter claims online and submits one atomic batch', () => {
+ assert.match(source, /\/first-leg\/pda-loading\/tasks\/pending/)
+ assert.doesNotMatch(source, /query\.push\(`cabinetNo=/)
+ assert.doesNotMatch(source, /query\.push\(`loadingPlanId=/)
+ assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/claim/)
+ assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/items\/\$\{encodeURIComponent\(itemId\)\}\/\$\{path\}/)
+ assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/pallets\/pick/)
+ assert.match(source, /tasks\/\$\{encodeURIComponent\(task\.taskId\)\}\/batch/)
+ assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/inventory-snapshot\?warehouseId=/)
+ assert.match(source, /submitContainerPickingBatch/)
+ assert.match(source, /stableIdempotencyKey/)
+ assert.match(source, /Math\.imul\(hash, 16777619\)/)
+ assert.match(source, /slice\(0, 128\)/)
+ assert.match(page, /claimContainerPickingTask\(/)
+ assert.match(page, /submitImmediateItemAction\(item, 'PICK'/)
+ assert.match(page, /submitImmediateItemAction\(item, 'PARTIAL'/)
+ assert.match(page, /submitImmediateItemAction\(item, 'SKIP'/)
+ assert.match(page, /submitImmediateItemAction\(item, 'PICK_PALLET'/)
+ assert.match(page, /item\.itemStatus === 'PICKING' \|\| item\.itemStatus === 'PARTIAL'/)
+ assert.doesNotMatch(page, /@tap="undoItem\(item\.id\)"/)
+ assert.match(page, /submitContainerPickingBatch\(/)
+ assert.match(source, /palletNo: action\.type === 'PICK_PALLET' \? action\.palletNo : null/)
+ assert.match(page, /type: 'PICK_PALLET'[\s\S]*?palletNo: item\.palletNo/)
+ assert.match(page, /扫描托盘码[\s\S]*?托内任一箱码/)
+ assert.match(page, /确认整托拣出/)
+ assert.match(page, /async pickItem[\s\S]*?isSealedPalletItem\(item\)[\s\S]*?submitImmediateItemAction\(item, 'PICK_PALLET', \{ palletNo: item\.palletNo \}\)/)
+ assert.match(page, /或点击“确认整托拣出”后再提交/)
+ assert.match(page, /sanitizeUserMessage\(error\.message, '任务状态已变化,请刷新后重试'\)/)
+ assert.match(source, /data: \{ warehouseId, loadingPlanId: task\.loadingPlanId, containerBookingId: task\.containerBookingId, expectedVersion, inventorySnapshotToken, items \}/)
+ assert.doesNotMatch(source.match(/export async function submitContainerPickingBatch[\s\S]*?\n\}/)?.[0] || '', /releaseMode|locationIds|reservationIds/)
+ assert.doesNotMatch(source, /query\.push\(`containerNo=/)
+ assert.doesNotMatch(page, /getContainerPickingTask\([^\n]*containerNo:/)
+ assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\?warehouseId=/)
+ assert.match(source, /currentContainerNo/)
+ assert.match(source, /getPickingInventorySnapshot[\s\S]*?\.then\(normalizeInventorySnapshot\)/)
+ assert.doesNotMatch(source.match(/export async function submitContainerPickingBatch[\s\S]*?\n\}/)?.[0] || '', /containerNo/)
+ assert.doesNotMatch(page, /claim-[^`]*currentBox\.boxNo/)
+ assert.doesNotMatch(page.match(/stableOperationId\('submit'[\s\S]*?\n/)?.[0] || '', /boxNo|containerNo|currentContainerNo/)
+ assert.match(page, /await getPickingInventorySnapshot\([\s\S]*await submitContainerPickingBatch/)
+ assert.match(page, /库存已变化,请刷新后重试/)
+ assert.doesNotMatch(source.match(/export async function submitContainerPickingBatch[\s\S]*?\n\}/)?.[0] || '', /for \(/)
+ assert.match(page, /version: Number\(item\.version \|\| 0\)/)
+ assert.match(page, /refreshTask/)
+ assert.match(page, /remoteError && !pickingCompleted/)
+ assert.match(page, /库存快照未同步,请刷新任务后重试/)
+ assert.match(page, /拣货完成未提交,请查看错误提示/)
+ assert.match(page, /if \(remoteTask\.workflowStatus === 'UNASSIGNED'\)[\s\S]*claimContainerPickingTask/)
+ assert.doesNotMatch(page, /loadingVersion/)
+ assert.match(page, /taskRequestId/)
+ assert.match(page, /resetEvidenceState/)
+ assert.match(page, /availablePickQuantity/)
+ assert.match(page, /库存\/库位可用量不足/)
+})
+
+test('PDA final completion delegates automatic cabinet side effects to the task complete endpoint', () => {
+ const complete = source.match(/export function completeContainerPickingTask\(taskId, payload = \{\}\) \{[\s\S]*?\n\}/)?.[0] || ''
+ assert.match(complete, /\/first-leg\/pda-loading\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/complete/)
+ assert.doesNotMatch(complete, /拖车|trucking/i)
+})
diff --git a/tests/picking-compact-layout.test.mjs b/tests/picking-compact-layout.test.mjs
new file mode 100644
index 0000000..9c604de
--- /dev/null
+++ b/tests/picking-compact-layout.test.mjs
@@ -0,0 +1,21 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
+
+test('picking header omits persistent scanner help and keeps progress compact', async () => {
+ const [page, styles] = await Promise.all([
+ read('../pages/picking/picking.vue'),
+ read('../styles/pda.scss')
+ ])
+
+ assert.doesNotMatch(page, /支持硬件广播扫码,也可点击输入后按“完成”确认/)
+ assert.doesNotMatch(styles, /\.scan-hardware-tip/)
+ assert.match(page, /已处理[\s\S]*?summary\.processed[\s\S]*?summary\.total[\s\S]*?>柜次<[\s\S]*?box-tasks-count[\s\S]*?completedCount[\s\S]*?boxes\.length[\s\S]*?box-tasks-arrow/)
+ assert.match(styles, /\.progress-section \{[^}]*padding: 10px 12px/)
+ assert.match(styles, /\.progress-actions \{[^}]*align-items: center;[^}]*gap: 8px/)
+ assert.match(styles, /\.box-tasks-btn \{[^}]*height: 40px;[^}]*border: 0;[^}]*border-radius: 999px/)
+ assert.match(styles, /\.box-tasks-count \{[^}]*font-weight: 700;/)
+ assert.match(styles, /\.progress-bar-bg \{[^}]*height: 5px;[^}]*margin-top: 8px/)
+})
diff --git a/tests/picking-demo-readiness.test.mjs b/tests/picking-demo-readiness.test.mjs
new file mode 100644
index 0000000..d0dd27b
--- /dev/null
+++ b/tests/picking-demo-readiness.test.mjs
@@ -0,0 +1,76 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
+const [page, tasks, api, http, completion, scan] = await Promise.all([
+ read('../pages/picking/picking.vue'),
+ read('../pages/tasks/tasks.vue'),
+ read('../api/pda-loading.js'),
+ read('../api/http.js'),
+ read('../utils/loading-completion-state.js'),
+ read('../utils/scan-broadcast.js')
+])
+
+test('demo claim supports the PDA broadcast and normalized manual input without duplicate picks', () => {
+ assert.match(page, /remoteTask\.workflowStatus === 'UNASSIGNED'[\s\S]*?claimContainerPickingTask\(this\.remoteTaskId, this\.assignmentVersion, operationId\)/)
+ assert.match(page, /this\.remotePick\.workflowStatus !== 'IN_PROGRESS'[\s\S]*?任务领取尚未完成/)
+ assert.match(scan, /android\.intent\.action\.SCANRESULT/)
+ assert.match(scan, /SCAN_BROADCAST_EXTRA = 'value'/)
+ assert.match(scan, /getStringExtra\(SCAN_BROADCAST_EXTRA\)/)
+ assert.match(page, /handleHardwareScan\(value\)[\s\S]*?processScannedValue\(value, 'broadcast'\)/)
+ assert.match(page, /handleManualSubmit\(value\)[\s\S]*?trim\(\)\.toUpperCase\(\)[\s\S]*?processScannedValue\(normalizedValue, 'manual'\)/)
+ assert.match(page, /result\.type === 'processed'[\s\S]*?重复扫描[\s\S]*?return/)
+})
+
+test('demo batch freezes inventory and submits carton or whole-pallet actions under one stable key', () => {
+ const submit = page.match(/async performSubmit\(incompleteReason = ''\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ for (const action of ['PICK', 'SKIP', 'PARTIAL', 'PICK_PALLET']) assert.match(submit, new RegExp(`type: '${action}'`))
+ assert.match(submit, /palletNo: item\.palletNo/)
+ assert.match(submit, /const submitId = this\.submitId \|\| stableOperationId\('submit'/)
+ assert.match(submit, /this\.submitId = submitId/)
+ assert.match(submit, /const inventorySnapshot = await this\.refreshInventorySnapshot\(\)[\s\S]*?submitContainerPickingBatch/)
+ assert.match(api, /'Idempotency-Key': stableIdempotencyKey\(submitOperationId\)/)
+ assert.match(api, /inventorySnapshotToken, items/)
+ assert.match(page, /value >= this\.partialTargetItem\.qty[\s\S]*?部分拣货必须少于/)
+ assert.match(page, /value > available[\s\S]*?超过库存\/库位可用量/)
+ assert.match(page, /available < Number\(item\.qty \|\| 0\)[\s\S]*?库存\/库位可用量不足/)
+})
+
+test('demo session and network failures never turn into local success', () => {
+ assert.match(http, /requireOnline\(\)/)
+ assert.match(http, /let refreshPromise = null/)
+ assert.match(http, /session\.token && session\.token !== accessToken \? session\.token : await refreshAccessToken\(\)/)
+ assert.match(http, /if \(code !== '200'\)[\s\S]*?error\.requestId[\s\S]*?error\.traceId[\s\S]*?throw error/)
+ const submit = page.match(/async performSubmit\(incompleteReason = ''\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ const failedReturn = submit.indexOf("this.showToast('拣货完成未提交,请查看错误提示', 'error')")
+ const localFinalize = submit.indexOf('const result = submitStates')
+ assert.ok(failedReturn >= 0 && localFinalize > failedReturn, 'backend failure must return before local completion')
+ assert.match(submit, /网络失败,拣货完成未提交,请重试/)
+})
+
+test('demo evidence requires all five server-backed types and keeps seal upload explicit', () => {
+ for (const type of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) {
+ assert.match(api, new RegExp(`'${type}'`))
+ }
+ assert.match(page, /this\.evidenceSlots\.every\(\(slot\) => this\.validEvidenceEntries\(slot\.type\)\.length > 0\)/)
+ assert.match(page, /请填写封签号,保存后上传/)
+ assert.match(page, /sealSaving \? '上传中…' : '保存并上传'/)
+ assert.match(page, /await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/)
+ assert.match(api, /if \(code !== '200'\)[\s\S]*?throw error/)
+})
+
+test('demo completion reuses its command, restores processing, and refreshes the list on return', () => {
+ assert.match(page, /restoreLoadingCompletionAttempt\(this\.draftIdentity\(\)\)/)
+ assert.match(page, /const operationId = savedAttempt\?\.operationId \|\| stableOperationId\('complete'/)
+ assert.match(page, /saveLoadingCompletionAttempt\(this\.draftIdentity\(\), completionAttempt\)[\s\S]*?completeContainerPickingTask/)
+ assert.match(completion, /PROCESSING_STATUSES = new Set\(\['COMPLETING', 'PROCESSING'\]\)/)
+ assert.match(completion, /statuses\.some\(\(status\) => PROCESSING_STATUSES\.has\(status\)\)/)
+ assert.match(page, /phase === COMPLETION_PHASE\.PROCESSING[\s\S]*?后台处理中[\s\S]*?navigateBackOr\('\/pages\/tasks\/tasks'\)/)
+ assert.match(page, /shouldReconcileCompletion\(error\)[\s\S]*?提交结果待确认[\s\S]*?navigateBackOr\('\/pages\/tasks\/tasks'\)/)
+ assert.match(tasks, /onShow\(\)[\s\S]*?this\.loadTasks\(\)/)
+ assert.match(tasks, /status === 'PROCESSING'[\s\S]*?完成处理中/)
+ assert.match(tasks, /isTaskReady\(task\)[\s\S]*?\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETION_FAILED'\]/)
+ assert.match(tasks, /isTaskVisible\(task\)[\s\S]*?\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'PROCESSING', 'COMPLETION_FAILED'\]/)
+ assert.doesNotMatch(tasks, /isTaskVisible\(task\)[\s\S]*?'COMPLETED'/)
+})
diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs
new file mode 100644
index 0000000..2bf851b
--- /dev/null
+++ b/tests/picking-evidence-stage.test.mjs
@@ -0,0 +1,278 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
+
+test('picking and photo workspaces are independently selectable', () => {
+ assert.match(page, /v-if="activeStage === 'evidence'" class="loading-evidence-section"/)
+ assert.match(page, /selectStage\(stage\) \{[\s\S]*?this\.activeStage = stage/)
+ assert.doesNotMatch(page, /:disabled="!loadingStageReady"/)
+ assert.match(page, /isPickingCompleteTask\(task\)[\s\S]*?AWAITING_REVIEW[\s\S]*?COMPLETED/)
+ assert.match(page, /pickingCompleted\(\)[\s\S]*?this\.remoteTaskId \|\| this\.currentBox\.taskId[\s\S]*?if \(!this\.remotePick\) return false[\s\S]*?hasServerPending[\s\S]*?isPickingCompleteTask\(this\.remotePick\) && !hasServerPending/)
+ assert.doesNotMatch(page, /任务增量更新后|evidence-stage-hint/)
+})
+
+test('a server task never inherits a completed marker from the previously opened local cabinet', () => {
+ assert.match(page, /pickingCompleted\(\) \{[\s\S]*?this\.remoteTaskId \|\| this\.currentBox\.taskId[\s\S]*?if \(!this\.remotePick\) return false[\s\S]*?hasServerPending/)
+ assert.match(page, /if \(options\.taskId\) \{[\s\S]*?this\.completedBoxes = \{\}[\s\S]*?this\.boxes = \[/)
+ assert.match(page, /:disabled="submitting \|\| \(!pickingCompleted && !summary\.canSubmit\)"/)
+ assert.match(page, /submitButtonText\(\)[\s\S]*?return this\.pickingCompleted \? '继续装柜取证'/)
+ assert.match(page, /submitStatusHint\(\)[\s\S]*?if \(this\.pickingCompleted\) return '拣货已完成,请继续装柜取证'/)
+ const submit = page.match(/async submitPicking\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.doesNotMatch(submit, /completedBoxes/)
+ assert.match(page, /v-else-if="pickingOperationReady" class="pick-item-actions processed-actions"/)
+ assert.doesNotMatch(page, /v-else-if="!(?:pickingCompleted|completedBoxes\[currentBoxIndex\])"/)
+})
+
+test('picking completion leads to current cabinet evidence instead of an all-complete summary', () => {
+ assert.match(page, /装柜照片已拍 \{\{ evidenceCapturedTypeCount \}\}\/\{\{ evidenceSlots\.length \}\} 类/)
+ assert.match(page, /evidenceNextButtonText\(\)[\s\S]*?'查看装柜取证'[\s\S]*?'去拍照'/)
+ assert.match(page, /continueToEvidence\(\)[\s\S]*?this\.activeStage = 'evidence'[\s\S]*?this\.scrollToTop\(\)/)
+ assert.doesNotMatch(page, /查看全部完成|allCompletePopup|nextBox\(\)/)
+})
+
+test('loading evidence is requested by task id as soon as the task is claimed', () => {
+ assert.match(page, /this\.evidenceIdentity = this\.captureEvidenceIdentity\(\)[\s\S]*?await this\.loadLoadingEvidence\(\)/)
+ const loader = page.match(/async loadLoadingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(loader, /if \(!this\.evidenceIdentity\?\.taskId/)
+ assert.doesNotMatch(loader, /if \(!this\.loadingStageReady/)
+ assert.match(loader, /await getEvidence\(/)
+})
+
+test('operators receive actionable refresh guidance without internal version wording', () => {
+ assert.match(page, /状态尚未同步,请刷新重试/)
+ assert.doesNotMatch(page, /缺少拣货版本号|提供装柜版本号/)
+ const complete = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(complete, /if \(!this\.requireEvidenceStage\(\)\) return/)
+ assert.match(complete, /completeContainerPickingTask\(this\.remoteTaskId/)
+ assert.match(complete, /expectedTaskVersion/)
+ assert.doesNotMatch(complete, /completeLoading\(inboundId/)
+})
+
+test('AWAITING_REVIEW uses the canonical task version for final completion', () => {
+ assert.match(page, /loadingStageReady\(\)[\s\S]*?this\.pickingCompleted[\s\S]*?this\.evidenceIdentity\.taskId[\s\S]*?this\.evidenceIdentity\.warehouseId[\s\S]*?this\.pickingVersion !== null[\s\S]*?Number\.isFinite/)
+ assert.match(page, /拣货已完成,装柜状态尚未同步,请刷新重试/)
+ assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?requireEvidenceStage/)
+ assert.match(page, /requireEvidenceMutation\(\)[\s\S]*?requireEvidenceCapture/)
+})
+
+test('dynamic pending items do not block switching between picking and evidence', () => {
+ assert.match(page, /class="stage-switch" role="tablist"/)
+ assert.match(page, /@tap="selectStage\('picking'\)"/)
+ assert.match(page, /@tap="selectStage\('evidence'\)"/)
+ assert.doesNotMatch(page, /stage === 'evidence' && !this\.loadingStageReady/)
+ assert.doesNotMatch(page, /请先完成新增待拣明细/)
+ assert.match(page, /activeStage === 'picking'/)
+ assert.match(page, /:disabled="submitting \|\| !loadingStageReady \|\| pickingVersion === null/)
+})
+
+test('incremental refresh merges new pending items without overwriting submitted server results', () => {
+ const restore = page.match(/restoreRemoteDraft\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(restore, /const serverStates = \{ \.\.\.this\.itemStates \}/)
+ assert.match(restore, /serverStates\[id\] !== ITEM_STATUS\.PENDING/)
+ assert.match(restore, /status === ITEM_STATUS\.PARTIAL[\s\S]*?partial >= Number\(item\?\.qty/)
+ assert.match(restore, /this\.remotePick && !isPickingCompleteTask\(this\.remotePick\)[\s\S]*?delete completedBoxes\[this\.currentBoxIndex\]/)
+ assert.match(page, /const previousTaskVersion = this\.pickingVersion/)
+ assert.match(page, /const previousItemIds = new Set/)
+ assert.match(page, /previousEvidenceState[\s\S]*?isSameEvidenceIdentity\(previousEvidenceState\.identity, this\.evidenceIdentity\)[\s\S]*?this\.evidenceFiles = \{ \.\.\.this\.evidenceFiles, \.\.\.previousEvidenceState\.files \}/)
+ assert.match(page, /if \(!this\.pickingCompleted\)[\s\S]*?delete completedBoxes\[this\.currentBoxIndex\]/)
+ assert.doesNotMatch(page, /delete completedBoxes\[this\.currentBoxIndex\][\s\S]{0,160}?this\.activeStage = 'picking'/)
+ assert.match(page, /refresh\.incremental[\s\S]*?addedItemIds/)
+})
+
+test('incremental batch submits only current server-pending items and refreshes stale inventory conflicts', () => {
+ const submit = page.match(/async performSubmit\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(submit, /item\.itemStatus && !\['PENDING', 'PICKING'\]\.includes\(item\.itemStatus\)\) return null/)
+ assert.match(submit, /const versionConflict = this\.isTaskConflict\(error\)/)
+ assert.match(submit, /await this\.refreshAfterTaskConflict\(conflictMessage\)/)
+ assert.match(submit, /const inventorySnapshot = await this\.refreshInventorySnapshot\(\)/)
+ assert.match(page, /refreshAfterTaskConflict\(message\)[\s\S]*?this\.submitId = ''[\s\S]*?await this\.syncRemoteTask\(\)[\s\S]*?await this\.refreshInventorySnapshot\(\)/)
+ assert.match(page, /refreshInventorySnapshot\(\)[\s\S]*?getPickingInventorySnapshot\(this\.remoteTaskId, warehouseId\)[\s\S]*?String\(snapshot\.version\) !== String\(this\.pickingVersion\)[\s\S]*?error\.code = 'A0409'/)
+ assert.match(page, /this\.pickingVersion = this\.remotePick\.pickingVersion \?\? this\.remotePick\.version/)
+})
+
+test('completed detail overrides stale local state and an empty batch refreshes into evidence instead of submitting', () => {
+ assert.match(page, /if \(item\.itemStatus === 'PICKED'\) serverStates\[item\.id\] = ITEM_STATUS\.PICKED/)
+ assert.match(page, /if \(!this\.pickingCompleted\) this\.restoreRemoteDraft\(\)/)
+ assert.match(page, /await this\.loadLoadingEvidence\(\)[\s\S]*?if \(this\.loadingStageReady\) this\.activeStage = 'evidence'/)
+ const submit = page.match(/async submitPicking\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(submit, /if \(this\.pickingCompleted\)[\s\S]*?await this\.syncRemoteTask\(\)[\s\S]*?this\.activeStage = 'evidence'[\s\S]*?return/)
+ const perform = page.match(/async performSubmit\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(perform, /if \(!actions\.length\) \{[\s\S]*?submit\.empty-refresh[\s\S]*?await this\.syncRemoteTask\(\)[\s\S]*?return/)
+ assert.ok(perform.indexOf('if (!actions.length)') < perform.indexOf('submitContainerPickingBatch('))
+})
+
+test('successful batch submission automatically refreshes canonical item status before completion gates', () => {
+ const perform = page.match(/async performSubmit\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ const submitIndex = perform.indexOf('await submitContainerPickingBatch(')
+ const refreshIndex = perform.indexOf('await this.syncRemoteTask()', submitIndex)
+ const gateIndex = perform.indexOf('if (!this.pickingCompleted)', refreshIndex)
+ assert.ok(submitIndex >= 0 && refreshIndex > submitIndex, 'canonical detail should refresh after the accepted batch')
+ assert.ok(gateIndex > refreshIndex, 'completion should be evaluated only after the automatic refresh')
+ assert.match(perform, /拣货已提交,正在刷新最新状态/)
+ assert.match(perform, /拣货已提交,但最新任务状态尚未同步,请点击刷新任务/)
+ assert.match(perform, /if \(!this\.pickingCompleted\)[\s\S]*?return/)
+})
+
+test('final completion preserves backend gate errors and refreshes after an atomic A0409', () => {
+ const complete = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(complete, /this\.isTaskConflict\(error\)[\s\S]*?await this\.refreshAfterTaskConflict\(sanitizeUserMessage\(error\.message, '订舱、装柜关联或证据状态已变化,请刷新后重试'\)\)/)
+ assert.doesNotMatch(complete, /拖车|trucking/i)
+})
+
+test('seal photo upload waits for an explicit seal-number save without waiting for picking completion', () => {
+ const chooser = page.match(/chooseEvidence\(slot\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(chooser, /slot\.type === 'SEAL'[\s\S]*?照片已拍摄,请输入封签号并保存上传[\s\S]*?return/)
+ assert.match(page, /async saveSealNo\(\)[\s\S]*?await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/)
+ const saver = page.match(/async saveSealNo\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.doesNotMatch(saver, /if \(!this\.loadingStageReady\)/)
+ assert.match(page, /sealReadyToSave\(\)[\s\S]*?hasPendingLocalEvidence\('SEAL'\)/)
+ assert.match(page, /slot\.type === 'SEAL' \? '请填写封签号,保存后上传' : '照片待自动上传'/)
+ assert.match(page, /带锁码照上传成功,封签号已保存/)
+})
+
+test('cabinet evidence identity is task-scoped and never selects an inbound owner', () => {
+ const capture = page.match(/captureEvidenceIdentity\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ const same = page.match(/isSameEvidenceIdentity\(left, right\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(capture, /taskId: this\.remoteTaskId \|\| this\.currentBox\.taskId/)
+ assert.match(capture, /warehouseId: this\.currentBox\.warehouseId/)
+ assert.doesNotMatch(capture, /inboundId|loadingPlanId|containerBookingId/)
+ assert.match(same, /left\?\.taskId[\s\S]*?right\?\.taskId/)
+ assert.doesNotMatch(same, /inboundId|loadingPlanId|containerBookingId/)
+ assert.match(page, /String\(detailTaskId \|\| ''\) !== String\(requestedTaskId\)/)
+ assert.match(page, /!task\.loadingPlanId \|\| !task\.containerBookingId/)
+ assert.doesNotMatch(page, /const evidenceItem|String\(task\.inboundId\)/)
+ assert.match(page, /this\.evidenceIdentity = this\.captureEvidenceIdentity\(\)/)
+})
+
+test('merged or non-active cabinet details are rejected before claim and clear stale local state', () => {
+ const sync = page.match(/async syncRemoteTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(sync, /if \(this\.isMergedContainerRecord\(task\)\)[\s\S]*?this\.clearMergedTask\(task, previousIdentity\)[\s\S]*?return[\s\S]*?task\.recordStatus !== 'ACTIVE'[\s\S]*?claimContainerPickingTask/)
+ const clear = page.match(/clearMergedTask\(task, previousIdentity\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(clear, /taskId: previousIdentity\?\.taskId \?\? task\?\.taskId/)
+ assert.match(clear, /loadingPlanId: previousIdentity\?\.loadingPlanId \?\? task\?\.loadingPlanId/)
+ assert.match(clear, /containerBookingId: previousIdentity\?\.containerBookingId \?\? task\?\.containerBookingId/)
+ assert.match(clear, /clearPickingDraft\(staleIdentity\)/)
+ assert.match(clear, /this\.evidenceDrafts = Object\.fromEntries/)
+ assert.match(clear, /this\.evidencePreviewCache = Object\.fromEntries/)
+ for (const reset of ['remoteLoading = false', 'submitting = false', 'remoteTaskId = null', 'scanHistory = []', 'currentBox.containerBookingId = null', 'currentBox.currentContainerNo = null', 'resetEvidenceState()']) assert.match(clear, new RegExp(reset.replace(/[.()[\]]/g, '\\$&')))
+})
+
+test('photos upload, bind, reload and void before picking completion', () => {
+ assert.match(page, /requireEvidenceStage\(\)[\s\S]*?if \(!this\.pickingCompleted\)[\s\S]*?请先提交拣货完成[\s\S]*?if \(!this\.loadingStageReady\)/)
+ const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(queue, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, path, identity\)/)
+ assert.doesNotMatch(queue, /if \(!this\.loadingStageReady\)/)
+ assert.match(page, /chooseEvidence\(slot\)[\s\S]*?this\.queueEvidenceUpload\(slot\.type, path\)/)
+ const retry = page.match(/async retryEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(retry, /if \(!this\.requireEvidenceCapture\(\)\) return/)
+ assert.match(retry, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, item\.localPath, identity\)/)
+ assert.doesNotMatch(retry, /if \(!this\.loadingStageReady\)/)
+ assert.match(page, /async loadLoadingEvidence\(\) \{[\s\S]*?!this\.evidenceIdentity\?\.taskId[\s\S]*?!this\.evidenceIdentity\?\.warehouseId[\s\S]*?getEvidence\(identity\.taskId, identity\.warehouseId\)/)
+ assert.match(page, /await this\.loadLoadingEvidence\(\)[\s\S]*?if \(this\.loadingStageReady\) this\.activeStage = 'evidence'/)
+ assert.match(page, /async deleteEvidencePhoto\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceMutation\(\)\) return/)
+ assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceStage\(\)\) return/)
+ assert.doesNotMatch(page, /照片已上传,拣货完成后自动保存/)
+})
+
+test('refresh preserves local photos and flushes them as soon as evidence identity is ready', () => {
+ assert.match(page, /this\.rememberEvidenceDraft\(\)/)
+ assert.match(page, /this\.restoreEvidenceDraft\(\)/)
+ assert.match(page, /async flushPendingEvidence\(\)[\s\S]*?queueEvidenceUpload/)
+ assert.match(page, /const localFiles = Object\.fromEntries/)
+})
+
+test('evidence photos render thumbnails and support full-screen preview', () => {
+ assert.match(page, /class="evidence-thumbnail" :src="evidencePreviewSource\(photo\)"/)
+ assert.match(page, /class="work-content" :class="\{ 'evidence-active': activeStage === 'evidence' \}"/)
+ assert.match(page, /class="evidence-photo-grid"/)
+ assert.match(page, /class="evidence-count"/)
+ assert.match(page, /@tap="previewEvidencePhoto\(photo, slot\.type\)"/)
+ assert.match(page, /evidencePreviewSource\(photo\)[\s\S]*?localPath[\s\S]*?fileUrl/)
+ assert.match(page, /previewEvidencePhoto\(photo, type\)[\s\S]*?uni\.previewImage\([\s\S]*?current[\s\S]*?urls/)
+ assert.match(page, /cacheEvidencePreview\(fileId, localPath/)
+ assert.match(page, /withCachedEvidencePreview\(photo[\s\S]*?evidencePreviewCache/)
+ assert.match(page, /item\.previewUrl \|\| getEvidencePreviewUrl\(identity\.taskId, item\.id, identity\.warehouseId\)/)
+ assert.match(page, /['"]list\.loaded['"]/)
+ assert.match(page, /loadEvidencePreviews\(contextId, identity\)[\s\S]*?downloadEvidencePreview\(item\.previewUrl\)/)
+ assert.match(page, /['"]preview\.downloaded['"]/)
+ assert.match(page, /已上传并保存/)
+})
+
+test('switching cabinets invalidates old sync and evidence upload state', () => {
+ const selector = page.match(/selectBox\(index\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(selector, /this\.taskRequestId \+= 1/)
+ assert.match(selector, /this\.pickingVersion = null/)
+ assert.match(selector, /this\.submitting = false/)
+ assert.match(selector, /this\.resetEvidenceState\(\)/)
+ assert.match(page, /!this\.isEvidenceContextActive\(contextId, identity\) \|\| queue !== this\.evidenceQueue/)
+ const loader = page.match(/async loadLoadingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.ok((loader.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length >= 2)
+})
+
+test('camera callbacks cannot append evidence after switching cabinets', () => {
+ const chooser = page.match(/chooseEvidence\(slot\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(chooser, /const contextId = this\.evidenceContextId/)
+ assert.match(chooser, /const identity = \{ \.\.\.this\.evidenceIdentity \}/)
+ assert.equal((chooser.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length, 2)
+ assert.match(chooser, /isEvidenceContextActive\(contextId, identity\)[\s\S]*?this\.evidenceFiles =/)
+})
+
+test('returning from the camera does not refresh and invalidate the active evidence context', () => {
+ assert.match(page, /cameraPicking: false/)
+ assert.match(page, /onShow\(\) \{[\s\S]*?if \(this\.cameraPicking\)[\s\S]*?choose\.resume[\s\S]*?return[\s\S]*?this\.syncRemoteTask\(\)/)
+ const chooser = page.match(/chooseEvidence\(slot\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(chooser, /this\.cameraPicking = true/)
+ assert.match(chooser, /success: \(result\) => \{[\s\S]*?this\.cameraPicking = false/)
+ assert.match(chooser, /fail: \(error\) => \{[\s\S]*?this\.cameraPicking = false/)
+})
+
+test('returning from an image preview does not refresh the picking task', () => {
+ assert.match(page, /evidencePreviewOpening: false, evidencePreviewReturning: false/)
+ assert.match(page, /onShow\(\) \{[\s\S]*?if \(this\.evidencePreviewReturning\)[\s\S]*?preview\.resume[\s\S]*?return[\s\S]*?this\.syncRemoteTask\(\)/)
+ assert.match(page, /onHide\(\) \{[\s\S]*?if \(this\.evidencePreviewOpening\)[\s\S]*?this\.evidencePreviewReturning = true/)
+ const preview = page.match(/previewEvidencePhoto\(photo, type\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(preview, /this\.evidencePreviewOpening = true/)
+ assert.match(preview, /uni\.previewImage\(\{ current, urls, fail:/)
+})
+
+test('evidence save, void and loading completion ignore stale cabinet responses', () => {
+ for (const [method, awaitedCall] of [
+ ['deleteEvidencePhoto', 'voidEvidence'],
+ ['completeLoadingTask', 'completeContainerPickingTask']
+ ]) {
+ const body = page.match(new RegExp(`async ${method}\\([^)]*\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] || ''
+ assert.match(body, /const contextId = this\.evidenceContextId/, `${method} should capture the evidence context`)
+ assert.match(body, /const identity = \{ \.\.\.this\.evidenceIdentity \}/, `${method} should capture stable evidence identity`)
+ assert.match(body, new RegExp(`await ${awaitedCall}\\([\\s\\S]*?if \\(!this\\.isEvidenceContextActive\\(contextId, identity\\)\\) return`), `${method} should guard the success writeback`)
+ assert.ok((body.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length >= 2, `${method} should guard success and error writebacks`)
+ }
+ const complete = page.match(/async completeLoadingTask\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(complete, /finally \{[\s\S]*?isEvidenceContextActive\(contextId, identity\)[\s\S]*?this\.submitting = false/)
+})
+
+test('completed server tasks restore evidence stage without being claimed again', () => {
+ assert.match(page, /const remoteTask = normalizePickingTask\(task\)/)
+ assert.match(page, /remoteTask\.workflowStatus !== 'AWAITING_REVIEW' && this\.assignmentVersion === null/)
+ assert.match(page, /if \(remoteTask\.workflowStatus === 'UNASSIGNED'\) \{[\s\S]*?claimContainerPickingTask/)
+ assert.match(page, /else if \(!isPickingCompleteTask\(remoteTask\)\)/)
+ assert.match(page, /await this\.loadLoadingEvidence\(\)[\s\S]*?if \(this\.loadingStageReady\) this\.activeStage = 'evidence'/)
+})
+
+test('picking mutations stay closed until the canonical task has been claimed', () => {
+ assert.match(page, /pickingOperationReady\(\)[\s\S]*?workflowStatus === 'IN_PROGRESS'[\s\S]*?remoteTaskId[\s\S]*?loadingPlanId[\s\S]*?containerBookingId/)
+ assert.match(page, /requirePickingOperation\(\)[\s\S]*?任务尚未领取完成/)
+ for (const method of ['processScannedValue', 'pickItem', 'openSkip', 'openPartial', 'submitPicking']) {
+ const body = page.match(new RegExp(`${method}\\([^)]*\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] || ''
+ assert.match(body, /if \(!this\.requirePickingOperation\(\)\) return/, `${method} should wait for claim`)
+ }
+})
+
+test('completion shows a Chinese server failure with a request correlation id', () => {
+ assert.match(page, /remoteFailureMessage\(error, fallback = '操作失败'\)/)
+ assert.match(page, /formatApiError\(error, fallback\)/)
+ assert.match(page, /import \{ containerReference, formatApiError, sanitizeUserMessage \}/)
+ assert.match(page, /requestIdFromServer: error\.requestId/)
+ assert.match(page, /this\.remoteFailureMessage\(error, '装柜完成失败'\)/)
+})
diff --git a/tests/picking-location-display.test.mjs b/tests/picking-location-display.test.mjs
new file mode 100644
index 0000000..982cfc5
--- /dev/null
+++ b/tests/picking-location-display.test.mjs
@@ -0,0 +1,13 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
+
+test('picking card prioritizes location and inbound code without cargo or SKU text', () => {
+ assert.match(page, /库位 \{\{ item\.location \|\| '—' \}\}<\/text>/)
+ assert.match(page, /return `入仓码 \$\{item\.inboundCode \|\| '—'\}\$\{reason\}`/)
+ assert.match(page, /summarizeInventoryResources\(inventoryResources\)/)
+ assert.match(page, /location: item\.location \|\| item\.locationCode \|\| inventorySummary\.location/)
+ assert.doesNotMatch(page.match(/itemDescription\(item\) \{[\s\S]*?\n\t\t\t\}/)?.[0] || '', /item\.desc|cargoName|sku/i)
+})
diff --git a/tests/picking-refresh-route.test.mjs b/tests/picking-refresh-route.test.mjs
new file mode 100644
index 0000000..128999d
--- /dev/null
+++ b/tests/picking-refresh-route.test.mjs
@@ -0,0 +1,123 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
+
+test('opening another task clears prior scan display without deleting scoped draft or submission identity', async () => {
+ const page = await read('../pages/picking/picking.vue')
+ const load = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
+ const record = page.match(/recordScanResult\([^\n]+\) \{[\s\S]*?\r?\n\t\t\t\},/)?.[0]
+ assert.ok(load && record)
+ const hooks = new Function('requireSession', 'session', `return ({${load}${record}})`)(() => true, { currentUser: { id: '7' } })
+ const savedDraft = { 0: { scannedBoxCodes: { 'DP-OLD': 'old-item' }, itemStates: { 'old-item': 'picked' } } }
+ const context = { scanValue: 'DP-OLD', scanHistory: [{ id: 'old', boxCode: 'DP-OLD', title: '整托已记入草稿' }],
+ boxDrafts: savedDraft, submitId: 'existing-operation', itemStates: { 'old-item': 'picked' } }
+ hooks.onLoad.call(context, { taskId: 'new-task', warehouseId: 'warehouse' })
+ assert.equal(context.scanValue, '')
+ assert.deepEqual(context.scanHistory, [])
+ assert.equal(context.remoteTaskId, 'new-task')
+ assert.equal(context.boxDrafts, savedDraft)
+ assert.equal(context.submitId, 'existing-operation')
+ assert.deepEqual(context.itemStates, { 'old-item': 'picked' })
+ hooks.recordScanResult.call(context, 'success', '整托已记入草稿', 'DP-NEW', '整托 1 箱,提交后保存')
+ assert.equal(context.scanHistory.length, 1)
+ assert.equal(context.scanHistory[0].boxCode, 'DP-NEW')
+ // A same-task route reopen also clears input; canonical scoped restoration still owns draft history.
+ hooks.onLoad.call(context, { taskId: 'new-task', warehouseId: 'warehouse' })
+ assert.deepEqual(context.scanHistory, [])
+ assert.equal(context.boxDrafts, savedDraft)
+ const restore = page.match(/restoreRemoteDraft\(\) \{[\s\S]*?\r?\n\t\t\t\},/)?.[0] || ''
+ assert.match(restore, /restorePickingSession\(this\.draftIdentity\(\)\)/)
+ assert.match(restore, /this\.scanHistory = this\.normalizeScanHistory\(restored\.scanHistory\)/)
+})
+
+test('duplicate onShow shares one load chain while background return refreshes again', async () => {
+ const page = await read('../pages/picking/picking.vue')
+ const show = page.match(/async onShow\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
+ const hide = page.match(/onHide\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
+ assert.ok(show && hide)
+ const hooks = new Function('requireSession', 'session', `return ({${show}${hide}})`)(() => true, { currentUser: { id: '7' } })
+ const calls = []
+ let release
+ const pending = new Promise((resolve) => { release = resolve })
+ const context = { startHardwareScanner() {}, stopHardwareScanner() {}, async refreshAvailableBoxes() { calls.push('pending'); await pending }, async syncRemoteTask() { calls.push('detail+evidence') } }
+ const first = hooks.onShow.call(context)
+ const duplicate = hooks.onShow.call(context)
+ assert.deepEqual(calls, ['pending'])
+ release()
+ await Promise.all([first, duplicate])
+ assert.deepEqual(calls, ['pending', 'detail+evidence'])
+ await hooks.onShow.call(context)
+ assert.equal(calls.length, 2)
+ hooks.onHide.call(context)
+ await hooks.onShow.call(context)
+ assert.deepEqual(calls, ['pending', 'detail+evidence', 'pending', 'detail+evidence'])
+ const onLoad = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
+ assert.doesNotMatch(onLoad, /refreshAvailableBoxes|syncRemoteTask/)
+})
+
+test('camera and evidence preview resume still avoid unnecessary refreshes', async () => {
+ const page = await read('../pages/picking/picking.vue')
+ const show = page.match(/async onShow\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
+ const hooks = new Function('requireSession', 'session', `return ({${show}})`)(() => true, { currentUser: {} })
+ for (const flag of ['cameraPicking', 'evidencePreviewReturning']) {
+ const context = { [flag]: true, startHardwareScanner() {}, debugEvidence() {}, refreshAvailableBoxes() { throw new Error('unexpected refresh') } }
+ await hooks.onShow.call(context)
+ assert.equal(context._pageVisible, true)
+ }
+})
+
+test('direct H5 refresh restores the page user before rendering picking content', async () => {
+ const page = await read('../pages/picking/picking.vue')
+
+ assert.match(page, //)
+ assert.match(page, /onLoad\(options = \{\}\) \{[\s\S]*?if \(!requireSession\(\)\) return[\s\S]*?this\.currentUser = session\.currentUser/)
+ assert.match(page, /async onShow\(\) \{[\s\S]*?if \(!requireSession\(\)\) return[\s\S]*?this\.currentUser = session\.currentUser/)
+})
+
+test('picking route carries only stable task and warehouse identity', async () => {
+ const [tasks, page] = await Promise.all([
+ read('../pages/tasks/tasks.vue'),
+ read('../pages/picking/picking.vue')
+ ])
+ const navigation = tasks.match(/uni\.navigateTo\(\{ url: `\/pages\/picking\/picking\?[^`]+` \}\)/)?.[0] || ''
+ const onLoad = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\r?\n\t\t\},\r?\n\t\tasync onShow/)?.[0] || ''
+
+ assert.match(navigation, /\?taskId=\$\{encodeURIComponent\(task\.taskId\)\}&warehouseId=\$\{encodeURIComponent\(task\.warehouseId\)\}/)
+ assert.doesNotMatch(navigation, /cabinetNo|currentContainerNo|orderNo|inboundId|tenantId/)
+ assert.match(onLoad, /taskId: String\(options\.taskId\)/)
+ assert.match(onLoad, /warehouseId: options\.warehouseId \? String\(options\.warehouseId\) : null/)
+ assert.doesNotMatch(onLoad, /options\.(cabinetNo|currentContainerNo|orderNo|inboundId|inboundVersion|tenantId)/)
+})
+
+test('target task detail keeps 19 digit identifiers as strings from route through request', async () => {
+ const [adapter, tasks, page] = await Promise.all([
+ read('../api/pda-loading.js'),
+ read('../pages/tasks/tasks.vue'),
+ read('../pages/picking/picking.vue')
+ ])
+ const taskId = '2092889048731414530'
+ const warehouseId = '193000000000200002'
+ assert.equal(`/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(warehouseId)}`,
+ '/first-leg/pda-loading/tasks/2092889048731414530?warehouseId=193000000000200002')
+ assert.match(adapter, /tasks\/\$\{encodeURIComponent\(taskId\)\}\?warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/)
+ assert.match(tasks, /taskId=\$\{encodeURIComponent\(task\.taskId\)\}&warehouseId=\$\{encodeURIComponent\(task\.warehouseId\)\}/)
+ assert.match(page, /taskId: String\(options\.taskId\)/)
+ assert.match(page, /warehouseId: options\.warehouseId \? String\(options\.warehouseId\) : null/)
+ assert.doesNotMatch(page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\n\t\t\},/)?.[0] || '', /Number\((?:options\.)?(?:taskId|warehouseId)/)
+})
+
+test('task detail restores before and after claim and exposes S0500 metadata', async () => {
+ const [http, page] = await Promise.all([read('../api/http.js'), read('../pages/picking/picking.vue')])
+ const sync = page.match(/async syncRemoteTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(sync, /remoteTask\.workflowStatus === 'UNASSIGNED'[\s\S]*?claimContainerPickingTask/)
+ assert.match(sync, /remoteTask\.workflowStatus === 'IN_PROGRESS'[\s\S]*?assigneeId/)
+ assert.match(sync, /normalizePickingTask\(\{ \.\.\.task, \.\.\.\(claimed \|\| \{\}\) \}\)/)
+ assert.match(http, /error\.code = body\.code \|\| response\.statusCode/)
+ assert.match(http, /error\.requestId = body\.requestId/)
+ assert.match(http, /error\.traceId = body\.traceId/)
+ assert.match(page, /formatApiError\(error, fallback\)/)
+ assert.match(page, /@\/utils\/user-facing\.js/)
+ assert.match(sync, /this\.remoteFailureMessage\(error, '任务同步失败'\)/)
+})
diff --git a/tests/picking-state.test.mjs b/tests/picking-state.test.mjs
index 7d376a0..8d8347c 100644
--- a/tests/picking-state.test.mjs
+++ b/tests/picking-state.test.mjs
@@ -7,6 +7,35 @@ const state = await import(`data:text/javascript;base64,${Buffer.from(source).to
const items = [{ id: 1 }, { id: 2 }, { id: 3 }]
+test('inventory resources expose distinct locations and total available cartons', () => {
+ assert.deepEqual(state.summarizeInventoryResources([
+ { locationCode: 'A0', availablePackageCount: 4 },
+ { locationCode: 'A0', availablePackageCount: 2 },
+ { locationCode: 'B-03', availablePackageCount: 1 },
+ ]), { location: 'A0、B-03', availablePackageCount: 7 })
+ assert.deepEqual(state.summarizeInventoryResources(null), { location: '', availablePackageCount: null })
+})
+
+test('sealed pallet matches its DP code and resolves member cartons as an atomic pallet', () => {
+ const pallet = { id: '1', unitType: 'SEALED_PALLET', indivisible: true, palletNo: 'DP-260909-0143', boxCodes: ['IN#2-1', 'IN#2-2'], qty: 2 }
+ assert.equal(state.classifyScan([pallet], {}, 'IN#2-1').type, 'pallet')
+ assert.equal(state.classifyScan([pallet], {}, pallet.palletNo).type, 'pallet')
+ assert.equal(state.classifyScan([pallet], { 1: state.ITEM_STATUS.PICKED }, pallet.palletNo).type, 'processed')
+})
+
+test('only the authoritative sealed-pallet unit type selects pallet behavior', () => {
+ assert.equal(state.isSealedPalletItem({ unitType: 'SEALED_PALLET', indivisible: true }), true)
+ assert.equal(state.isSealedPalletItem({ unitType: 'PALLET', indivisible: true }), false)
+ assert.equal(state.isSealedPalletItem({ unitType: 'PACKAGE', indivisible: true }), false)
+})
+
+test('mixed task keeps package scanning separate from sealed pallet scanning', () => {
+ const mixed = [{ id: '1', unitType: 'SEALED_PALLET', palletNo: 'DP-260909-0001', boxCodes: ['BOX-1'] }, { id: '2', unitType: 'PACKAGE', boxCodes: ['BOX-2'] }]
+ assert.equal(state.classifyScan(mixed, {}, 'BOX-2').item.id, '2')
+ assert.equal(state.classifyScan(mixed, {}, 'DP-260909-0001').item.id, '1')
+ assert.equal(state.classifyScan(mixed, {}, 'BOX-1').type, 'pallet')
+})
+
test('createItemStates initializes every item as pending', () => {
assert.deepEqual(state.createItemStates(items), {
1: state.ITEM_STATUS.PENDING,
@@ -69,26 +98,54 @@ test('submitStates turns only pending items into skipped', () => {
})
})
-test('classifyScan distinguishes pending, repeated, and unknown SKU codes', () => {
- const scanItems = [{ id: 1, sku: 'EL-38201' }, { id: 2, sku: 'EL-38202' }]
+test('classifyScan distinguishes pending, repeated, and unknown carton codes', () => {
+ const scanItems = [{ id: 1, boxCodes: ['BOX-001'] }, { id: 2, boxCodes: ['BOX-002', 'BOX-003'] }]
assert.deepEqual(state.classifyScan(scanItems, {
1: state.ITEM_STATUS.PICKED,
2: state.ITEM_STATUS.PENDING,
- }, 'EL-38202'), {
+ }, 'BOX-002'), {
type: 'pending',
item: scanItems[1],
})
assert.deepEqual(state.classifyScan(scanItems, {
1: state.ITEM_STATUS.PARTIAL,
2: state.ITEM_STATUS.PENDING,
- }, 'EL-38201'), {
+ }, 'BOX-001', { 'BOX-001': 1 }), {
type: 'processed',
item: scanItems[0],
status: state.ITEM_STATUS.PARTIAL,
})
- assert.deepEqual(state.classifyScan(scanItems, {}, 'NO-SUCH-SKU'), {
+ assert.deepEqual(state.classifyScan(scanItems, {}, 'NO-SUCH-BOX'), {
type: 'unknown',
item: null,
})
})
+
+test('sealed pallet resolves either its DP code or any member carton as one whole-pallet action', () => {
+ const pallet = {
+ id: 9,
+ unitType: 'SEALED_PALLET',
+ palletId: '2098348162978738179',
+ palletNo: 'DP-260911-0047',
+ indivisible: true,
+ boxCodes: ['BOX-001', 'BOX-002'],
+ }
+
+ assert.deepEqual(state.classifyScan([pallet], {}, 'DP-260911-0047'), {
+ type: 'pallet',
+ item: pallet,
+ })
+ assert.deepEqual(state.classifyScan([pallet], {}, 'BOX-001'), {
+ type: 'pallet',
+ item: pallet,
+ matchedBy: 'carton',
+ })
+ assert.deepEqual(state.classifyScan([pallet], {
+ 9: state.ITEM_STATUS.PICKED,
+ }, 'DP-260911-0047', { 'DP-260911-0047': 9 }), {
+ type: 'processed',
+ item: pallet,
+ status: state.ITEM_STATUS.PICKED,
+ })
+})
diff --git a/tests/picking-store.test.mjs b/tests/picking-store.test.mjs
new file mode 100644
index 0000000..2d08a26
--- /dev/null
+++ b/tests/picking-store.test.mjs
@@ -0,0 +1,39 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const source = await readFile(new URL('../stores/picking.js', import.meta.url), 'utf8')
+const taskIdentitySource = await readFile(new URL('../utils/task-identity.js', import.meta.url), 'utf8')
+
+test('picking drafts are persisted under stable operator, task and loading plan identity', () => {
+ assert.match(source, /pda-picking-draft/)
+ assert.match(source, /operatorId/)
+ assert.match(source, /taskId/)
+ assert.match(source, /loadingPlanId/)
+ assert.match(source, /containerBookingId/)
+ assert.match(source, /uni\.setStorageSync\(key/)
+ assert.match(source, /uni\.getStorageSync\(key/)
+ assert.match(source, /uni\.removeStorageSync\(key/)
+})
+
+test('draft restore rejects a different operator or task', () => {
+ assert.match(source, /saved\.operatorId !== expected\.operatorId/)
+ assert.match(source, /saved\.taskId !== expected\.taskId/)
+ assert.match(source, /saved\.loadingPlanId !== expected\.loadingPlanId/)
+ assert.match(source, /saved\.containerBookingId !== expected\.containerBookingId/)
+ assert.doesNotMatch(taskIdentitySource, /containerNo|currentContainerNo|boxNo/)
+})
+
+test('mutable real container number is not part of draft identity or restore validation', () => {
+ const restoreGuard = source.match(/const expected = stableTaskIdentity[\s\S]*?return null/)?.[0] || ''
+ assert.doesNotMatch(taskIdentitySource, /containerNo|currentContainerNo|boxNo/)
+ assert.doesNotMatch(restoreGuard, /containerNo|currentContainerNo|boxNo/)
+})
+
+test('completion retry persists the exact accepted operation id and request version', () => {
+ assert.match(source, /pda-loading-completion/)
+ assert.match(source, /saveLoadingCompletionAttempt/)
+ assert.match(source, /restoreLoadingCompletionAttempt/)
+ assert.match(source, /operationId: String\(attempt\.operationId\)/)
+ assert.match(source, /expectedTaskVersion: Number\(attempt\.expectedTaskVersion\)/)
+})
diff --git a/tests/production-ui.test.mjs b/tests/production-ui.test.mjs
new file mode 100644
index 0000000..b2f8919
--- /dev/null
+++ b/tests/production-ui.test.mjs
@@ -0,0 +1,25 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
+
+test('picking task list exposes only operator-facing production information', async () => {
+ const [tasks, adapter] = await Promise.all([
+ read('../pages/tasks/tasks.vue'),
+ read('../api/pda-loading.js')
+ ])
+
+ assert.match(tasks, /共 \$\{tasks\.length\} 个待处理任务/)
+ assert.match(tasks, /\{\{ containerReference\(task\) \}\}/)
+ assert.match(tasks, /displayBusinessValue\(task\.warehouseName, '未关联'\)/)
+ assert.doesNotMatch(tasks, /接口返回|当前接口|apiEnvironment|rawTaskCount|账号:|未命名订单|缺少仓库信息|服务端就绪校验|console\.info/)
+ assert.doesNotMatch(adapter, /业务单号待同步/)
+})
+
+test('production login contains no built-in account or password', async () => {
+ const login = await read('../pages/login/login.vue')
+
+ assert.match(login, /form: \{ userId: '', password: '' \}/)
+ assert.doesNotMatch(login, /platform_admin|ChangeMe123!/)
+})
diff --git a/tests/seal-save-idempotency.test.mjs b/tests/seal-save-idempotency.test.mjs
new file mode 100644
index 0000000..1dba9ae
--- /dev/null
+++ b/tests/seal-save-idempotency.test.mjs
@@ -0,0 +1,22 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
+
+test('saving a seal is single-flight and submits the pending photo through the atomic evidence command', () => {
+ assert.match(page, /:disabled="sealSaving \|\| !sealReadyToSave"/)
+ assert.match(page, /sealSaving \? '上传中…' : '保存并上传'/)
+ assert.match(page, /async saveSealNo\(\) \{[\s\S]*?if \(this\.sealSaving\) return/)
+ assert.match(page, /find\(\(item\) => item\.localPath && !item\.id\)/)
+ assert.match(page, /await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/)
+ assert.match(page, /stableOperationId\('evidence-upload',[\s\S]*?evidenceType: type,[\s\S]*?expectedVersion,[\s\S]*?localPath: path,[\s\S]*?sealNo: sealNo \|\| ''/)
+ assert.doesNotMatch(page, /seal-no-[^\n]*Date\.now\(\)/)
+ assert.match(page, /this\.sealSaving = true[\s\S]*?finally \{[\s\S]*?this\.sealSaving = false/)
+})
+
+test('server-returned evidence is deduplicated by type and file id', () => {
+ assert.match(page, /const seen = new Set\(\)/)
+ assert.match(page, /item\.evidenceType[\s\S]*?item\.fileId/)
+ assert.match(page, /if \(seen\.has\(key\)\) return false/)
+})
diff --git a/tests/user-facing-identifiers.test.mjs b/tests/user-facing-identifiers.test.mjs
new file mode 100644
index 0000000..f5281b3
--- /dev/null
+++ b/tests/user-facing-identifiers.test.mjs
@@ -0,0 +1,49 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { containerReference, displayBusinessValue, formatApiError, sanitizeUserMessage } from '../utils/user-facing.js'
+
+const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
+
+test('PDA task labels prefer the container number and only fall back to the cabinet number', () => {
+ assert.equal(displayBusinessValue(null), '—')
+ assert.equal(displayBusinessValue('', '未关联'), '未关联')
+ assert.equal(containerReference({ currentContainerNo: 'TSTU0000760', cabinetNo: 'CAB-260830-0008', orderNo: 'FL-1' }), '集装箱号 TSTU0000760')
+ assert.equal(containerReference({ currentContainerNo: '', boxNo: 'TSTU0000708', cabinetNo: 'CAB-260829-0037' }), '集装箱号 TSTU0000708')
+ assert.equal(containerReference({ cabinetNo: 'CAB-260830-0008', taskId: '2092889048731414530' }), '柜次号 CAB-260830-0008')
+ assert.equal(containerReference({ boxNo: '同步中', cabinetNo: 'CAB-260830-0008' }), '柜次号 CAB-260830-0008')
+ assert.equal(containerReference({ taskId: '2092889048731414530', orderId: '2092889048731414531' }), '集装箱号 —')
+})
+
+test('operator errors hide internal ids but retain request and trace evidence', () => {
+ const error = {
+ message: 'taskId: 2092889048731414530 对应 loadingPlanId=2092889048731414531 处理失败',
+ code: 'S0500',
+ requestId: 'dbde0d81-7798-4d22-a12e-1731d7ec9bb1',
+ traceId: '5b0ba4f3ed5236b34997dd82162a5138'
+ }
+ const message = formatApiError(error, '任务处理失败')
+ assert.doesNotMatch(message, /209288904873141453[01]|taskId|loadingPlanId/)
+ assert.match(message, /错误码:S0500/)
+ assert.match(message, /requestId:dbde0d81/)
+ assert.match(message, /traceId:5b0ba4f3/)
+ assert.equal(sanitizeUserMessage('operation 2092889048731414530 failed'), 'operation 内部标识已隐藏 failed')
+})
+
+test('priority PDA templates do not interpolate internal ids', async () => {
+ const pages = await Promise.all([
+ read('../pages/login/login.vue'),
+ read('../pages/tasks/tasks.vue'),
+ read('../pages/picking/picking.vue'),
+ read('../pages/history/history.vue')
+ ])
+ for (const page of pages) {
+ const template = page.match(/[\s\S]*?<\/template>/)?.[0] || ''
+ assert.doesNotMatch(template, /\{\{[^}\n]*(?:taskId|inboundId|orderId|loadingPlanId|containerBookingId)[^}\n]*\}\}/)
+ assert.doesNotMatch(template, /订单号|入仓码|业务编号未关联/)
+ }
+ assert.match(pages[0], /formatApiError\(error, '登录失败'\)/)
+ assert.match(pages[1], /containerReference\(task\)/)
+ assert.match(pages[2], /containerReference\(currentBox\)/)
+ assert.match(pages[3], /containerReference\(entry\.box\)/)
+})
diff --git a/utils/api-url.js b/utils/api-url.js
new file mode 100644
index 0000000..38bbdbb
--- /dev/null
+++ b/utils/api-url.js
@@ -0,0 +1,15 @@
+export function resolveEvidencePreviewUrl(previewUrl, baseUrl) {
+ const rawUrl = String(previewUrl || '')
+ const normalizedBase = String(baseUrl || '').replace(/\/$/, '')
+ if (!rawUrl || /^https?:\/\//i.test(rawUrl)) return rawUrl
+ if (/^https?:\/\//i.test(normalizedBase)) {
+ const origin = normalizedBase.match(/^(https?:\/\/[^/]+)/i)?.[1] || ''
+ return `${origin}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}`
+ }
+ const platformPrefix = '/api/platform/v1'
+ if (rawUrl === platformPrefix || rawUrl.startsWith(`${platformPrefix}/`)) {
+ return `${normalizedBase}${rawUrl.slice(platformPrefix.length)}`
+ }
+ if (rawUrl.startsWith('/')) return rawUrl
+ return `${normalizedBase}/${rawUrl}`
+}
diff --git a/utils/app-update.js b/utils/app-update.js
new file mode 100644
index 0000000..41c4316
--- /dev/null
+++ b/utils/app-update.js
@@ -0,0 +1,195 @@
+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, update) {
+ 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, update)
+ 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
+ }
+}
diff --git a/utils/dropship-palletizing-state.js b/utils/dropship-palletizing-state.js
new file mode 100644
index 0000000..7b09a21
--- /dev/null
+++ b/utils/dropship-palletizing-state.js
@@ -0,0 +1,40 @@
+export const DROPSHIP_SCAN_INTENT = Object.freeze({
+ BLOCKED: 'BLOCKED',
+ INVALID: 'INVALID',
+ DUPLICATE: 'DUPLICATE',
+ RESOLVE_CARTON: 'RESOLVE_CARTON',
+ SELECT_PALLET: 'SELECT_PALLET',
+ BIND_PENDING: 'BIND_PENDING'
+})
+
+export const PALLET_NO_PATTERN = /^DP-[0-9]{6}-[0-9]{4}$/
+
+function normalize(value) {
+ return value == null ? '' : String(value).trim().toUpperCase()
+}
+
+export function createDropshipScanState(value = {}) {
+ return {
+ online: value.online !== false,
+ activePalletNo: normalize(value.activePalletNo),
+ pendingBoxCode: normalize(value.pendingBoxCode),
+ lastScanCode: normalize(value.lastScanCode)
+ }
+}
+
+export function planDropshipScan(currentState, rawCode, { unboundCartons = 0 } = {}) {
+ const state = createDropshipScanState(currentState)
+ const scanCode = normalize(rawCode)
+ if (!state.online) return { state, intent: DROPSHIP_SCAN_INTENT.BLOCKED, message: '当前网络不可用,不能执行正式扫码' }
+ if (!scanCode) return { state, intent: DROPSHIP_SCAN_INTENT.INVALID, message: '扫码内容不能为空' }
+ if (scanCode === state.lastScanCode) return { state, intent: DROPSHIP_SCAN_INTENT.DUPLICATE, message: '请勿重复扫描同一码' }
+ if (PALLET_NO_PATTERN.test(scanCode)) {
+ const next = { ...state, activePalletNo: scanCode, lastScanCode: scanCode }
+ return { state: next, intent: Number(unboundCartons) > 0 ? DROPSHIP_SCAN_INTENT.BIND_PENDING : DROPSHIP_SCAN_INTENT.SELECT_PALLET, palletNo: scanCode }
+ }
+ return { state: { ...state, pendingBoxCode: scanCode, lastScanCode: scanCode }, intent: DROPSHIP_SCAN_INTENT.RESOLVE_CARTON, boxCode: scanCode, palletNo: state.activePalletNo || null }
+}
+
+export function releaseDropshipScan(state, { clearBox = false } = {}) {
+ return { ...createDropshipScanState(state), pendingBoxCode: clearBox ? '' : state.pendingBoxCode, lastScanCode: '' }
+}
diff --git a/utils/evidence-upload-queue.js b/utils/evidence-upload-queue.js
new file mode 100644
index 0000000..46788c1
--- /dev/null
+++ b/utils/evidence-upload-queue.js
@@ -0,0 +1,42 @@
+/**
+ * Small, observable upload queue for PDA evidence photos.
+ * The queue deliberately keeps local paths only for the active session; callers
+ * decide when it is safe to persist/clear them (see PDA session policy).
+ */
+export function createEvidenceUploadQueue({ upload, onChange } = {}) {
+ const entries = []
+ const notify = () => onChange?.(entries.map((item) => ({ ...item })))
+ const snapshot = () => entries.map((item) => ({ ...item }))
+ const add = (payload) => {
+ if (!payload?.evidenceType || !payload?.localPath) throw new Error('缺少证据类型或本地照片')
+ // Evidence is append-only by type. Incorrect or duplicate photos are
+ // explicitly voided, preserving the local audit trail instead of replacing.
+ const entry = { id: payload.id || `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ...payload, status: 'PENDING', attempts: 0, progress: 0, error: '' }
+ entries.push(entry); notify(); return entry
+ }
+ const cancel = (id) => { const item = entries.find((entry) => entry.id === id); if (!item || item.status === 'UPLOADING') return false; item.status = 'CANCELLED'; notify(); return true }
+ const voidEntry = (id, reason, actor) => {
+ const item = entries.find((entry) => entry.id === id)
+ if (!item || item.status === 'UPLOADING' || item.status === 'VOIDED') return false
+ if (!String(reason || '').trim()) throw new Error('作废原因不能为空')
+ item.status = 'VOIDED'; item.voidReason = String(reason).trim(); item.voidedBy = actor || null; item.voidedAt = new Date().toISOString(); notify(); return true
+ }
+ const retry = (id) => { const item = entries.find((entry) => entry.id === id); if (!item || !['FAILED', 'CANCELLED'].includes(item.status)) return false; item.status = 'PENDING'; item.error = ''; notify(); return true }
+ async function flush() {
+ if (typeof upload !== 'function') throw new Error('未配置文件上传器')
+ for (const item of entries) {
+ if (item.status !== 'PENDING') continue
+ item.status = 'UPLOADING'; item.attempts += 1; item.error = ''; notify()
+ try {
+ const result = await upload(item, (progress) => { item.progress = progress; notify() })
+ item.status = 'SUCCEEDED'; item.progress = 100; item.result = result; notify()
+ } catch (error) {
+ item.status = 'FAILED'; item.error = error?.message || '上传失败'; item.network = Boolean(error?.network); notify()
+ }
+ }
+ return snapshot()
+ }
+ const latest = (evidenceType) => [...entries].reverse().find((item) => item.evidenceType === evidenceType && item.status !== 'VOIDED') || null
+ const active = (evidenceType) => entries.filter((item) => item.evidenceType === evidenceType && item.status === 'SUCCEEDED')
+ return { add, cancel, voidEntry, retry, flush, latest, active, snapshot }
+}
diff --git a/utils/idempotency.js b/utils/idempotency.js
new file mode 100644
index 0000000..161fada
--- /dev/null
+++ b/utils/idempotency.js
@@ -0,0 +1,19 @@
+function hash32(value, seed) {
+ let hash = seed >>> 0
+ for (let index = 0; index < value.length; index += 1) {
+ hash ^= value.charCodeAt(index)
+ hash = Math.imul(hash, 0x01000193) >>> 0
+ }
+ return hash.toString(16).padStart(8, '0')
+}
+
+export function shortStableHash(value) {
+ const input = String(value ?? '')
+ return `${hash32(input, 0x811c9dc5)}${hash32(input, 0x9e3779b9)}`
+}
+
+export function stableOperationId(scope, identity, payload) {
+ const safeScope = String(scope || 'operation').replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 24)
+ const safeIdentity = String(identity || 'unknown').replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 48)
+ return `${safeScope}-${safeIdentity}-${shortStableHash(JSON.stringify(payload ?? null))}`.slice(0, 120)
+}
diff --git a/utils/loading-completion-state.js b/utils/loading-completion-state.js
new file mode 100644
index 0000000..31a23cd
--- /dev/null
+++ b/utils/loading-completion-state.js
@@ -0,0 +1,63 @@
+export const COMPLETION_PHASE = Object.freeze({
+ READY: 'READY',
+ SUBMITTING: 'SUBMITTING',
+ RECONCILING: 'RECONCILING',
+ PROCESSING: 'PROCESSING',
+ FAILED: 'FAILED',
+ COMPLETED: 'COMPLETED'
+})
+
+const PROCESSING_STATUSES = new Set(['COMPLETING', 'PROCESSING'])
+const COMPLETED_STATUSES = new Set(['COMPLETED'])
+const FAILED_STATUSES = new Set(['COMPLETION_FAILED'])
+
+function normalizedStatus(value) {
+ return String(value || '').trim().toUpperCase()
+}
+
+/**
+ * Resolve already-normalized task/command facts into a UI phase. DTO field
+ * extraction deliberately stays outside this module until the backend schema
+ * is frozen.
+ */
+export function resolveCompletionPhase({ taskStatus, commandStatus, submitting = false, reconciling = false } = {}) {
+ if (reconciling) return COMPLETION_PHASE.RECONCILING
+ if (submitting) return COMPLETION_PHASE.SUBMITTING
+ const statuses = [taskStatus, commandStatus].map(normalizedStatus)
+ if (statuses.some((status) => COMPLETED_STATUSES.has(status))) return COMPLETION_PHASE.COMPLETED
+ if (statuses.some((status) => FAILED_STATUSES.has(status))) return COMPLETION_PHASE.FAILED
+ if (statuses.some((status) => PROCESSING_STATUSES.has(status))) return COMPLETION_PHASE.PROCESSING
+ return COMPLETION_PHASE.READY
+}
+
+export function completionPresentation(phase, failureStage = '') {
+ if (phase === COMPLETION_PHASE.SUBMITTING) return { label: '正在提交', action: '', blocked: true }
+ if (phase === COMPLETION_PHASE.RECONCILING) return { label: '正在确认提交结果', action: '', blocked: true }
+ if (phase === COMPLETION_PHASE.PROCESSING) return { label: '完成处理中', action: '', blocked: true }
+ if (phase === COMPLETION_PHASE.COMPLETED) return { label: '装柜已完成', action: '', blocked: true }
+ if (phase === COMPLETION_PHASE.FAILED) {
+ return { label: failureStage ? `完成失败:${failureStage}` : '完成失败', action: '重试完成', blocked: true, retryable: true }
+ }
+ return { label: '待确认装柜', action: '确认装柜完成', blocked: false }
+}
+
+export function shouldReconcileCompletion(error = {}) {
+ const code = normalizedStatus(error.code || error.errCode)
+ return Boolean(error.network || error.timeout || code === 'TIMEOUT' || code === 'REQUEST_TIMEOUT')
+}
+
+/**
+ * A retry keeps the original operationId and the exact accepted request facts.
+ * expectedTaskVersion participates in requestHash and must not be refreshed.
+ */
+export function refreshCompletionAttempt(previousAttempt = {}, latestFacts = {}) {
+ if (!previousAttempt.operationId) throw new Error('缺少原完成操作标识,不能安全重试')
+ const expectedTaskVersion = previousAttempt.expectedTaskVersion
+ if (expectedTaskVersion === null || expectedTaskVersion === undefined) throw new Error('缺少服务端最新任务版本,不能安全重试')
+ return {
+ ...previousAttempt,
+ ...latestFacts,
+ operationId: previousAttempt.operationId,
+ expectedTaskVersion: Number(expectedTaskVersion)
+ }
+}
diff --git a/utils/network.js b/utils/network.js
new file mode 100644
index 0000000..723914a
--- /dev/null
+++ b/utils/network.js
@@ -0,0 +1,46 @@
+let online = true
+let listening = false
+const listeners = new Set()
+
+function notify() {
+ for (const listener of listeners) listener(online)
+}
+
+function ensureListening() {
+ if (listening || typeof uni === 'undefined') return
+ listening = true
+ if (typeof uni.onNetworkStatusChange === 'function') {
+ uni.onNetworkStatusChange((result) => {
+ online = Boolean(result.isConnected)
+ notify()
+ })
+ }
+ if (typeof uni.getNetworkType === 'function') {
+ uni.getNetworkType({
+ success: (result) => {
+ online = result.networkType !== 'none'
+ notify()
+ }
+ })
+ }
+}
+
+export function isOnline() {
+ ensureListening()
+ return online
+}
+
+export function requireOnline(message = '当前网络不可用,请联网后刷新重试') {
+ if (!isOnline()) {
+ const error = new Error(message)
+ error.network = true
+ throw error
+ }
+}
+
+export function watchNetwork(listener) {
+ ensureListening()
+ listeners.add(listener)
+ listener(online)
+ return () => listeners.delete(listener)
+}
diff --git a/utils/picking-state.js b/utils/picking-state.js
index f554995..db267b6 100644
--- a/utils/picking-state.js
+++ b/utils/picking-state.js
@@ -5,6 +5,28 @@ export const ITEM_STATUS = Object.freeze({
SKIPPED: 'skipped'
})
+export function isSealedPalletItem(item) {
+ return item?.unitType === 'SEALED_PALLET'
+}
+
+export function summarizeInventoryResources(resources) {
+ const list = Array.isArray(resources) ? resources : []
+ const locationCodes = [...new Set(list
+ .map((resource) => String(resource?.locationCode || '').trim())
+ .filter(Boolean))]
+ const availableCounts = list
+ .map((resource) => resource?.availablePackageCount)
+ .filter((value) => value !== null && value !== undefined && value !== '')
+ .map(Number)
+ .filter(Number.isFinite)
+ return {
+ location: locationCodes.join('、'),
+ availablePackageCount: availableCounts.length
+ ? availableCounts.reduce((total, value) => total + Math.max(0, value), 0)
+ : null
+ }
+}
+
export function createItemStates(items) {
return items.reduce((states, item) => {
states[item.id] = ITEM_STATUS.PENDING
@@ -12,7 +34,7 @@ export function createItemStates(items) {
}, {})
}
-export function getPickSummary(items, states) {
+export function getPickSummary(items, states, partialQuantities = {}) {
const summary = {
total: items.length,
picked: 0,
@@ -23,7 +45,11 @@ export function getPickSummary(items, states) {
items.forEach((item) => {
const status = states[item.id] || ITEM_STATUS.PENDING
- summary[status] += 1
+ const quantity = Math.max(1, Number(item.qty || 1))
+ summary.total += quantity - 1
+ summary[status] += status === ITEM_STATUS.PARTIAL
+ ? Math.max(0, Number(partialQuantities[item.id] || 0))
+ : quantity
})
summary.processed = summary.picked + summary.partial + summary.skipped
@@ -56,12 +82,29 @@ export function submitStates(items, states) {
}
}
-export function classifyScan(items, states, scanCode) {
- const item = items.find((entry) => entry.sku === scanCode) || null
+export function classifyScan(items, states, scanCode, scannedBoxCodes = {}) {
+ const palletItem = items.find((entry) => isSealedPalletItem(entry) && entry.palletNo === scanCode) || null
+ if (palletItem) {
+ const status = states[palletItem.id] || ITEM_STATUS.PENDING
+ if (scannedBoxCodes[scanCode] || status === ITEM_STATUS.PICKED) {
+ return { type: 'processed', item: palletItem, status }
+ }
+ return { type: 'pallet', item: palletItem }
+ }
+
+ const item = items.find((entry) => (entry.boxCodes || []).includes(scanCode)) || null
if (!item) return { type: 'unknown', item: null }
+ if (isSealedPalletItem(item)) {
+ const status = states[item.id] || ITEM_STATUS.PENDING
+ if (scannedBoxCodes[scanCode] || status === ITEM_STATUS.PICKED) {
+ return { type: 'processed', item, status }
+ }
+ return { type: 'pallet', item, matchedBy: 'carton' }
+ }
+ if (scannedBoxCodes[scanCode]) return { type: 'processed', item, status: states[item.id] || ITEM_STATUS.PARTIAL }
const status = states[item.id] || ITEM_STATUS.PENDING
- if (status === ITEM_STATUS.PENDING) return { type: 'pending', item }
+ if (status !== ITEM_STATUS.PICKED && status !== ITEM_STATUS.SKIPPED) return { type: 'pending', item }
return { type: 'processed', item, status }
}
diff --git a/utils/task-identity.js b/utils/task-identity.js
new file mode 100644
index 0000000..3fa73c3
--- /dev/null
+++ b/utils/task-identity.js
@@ -0,0 +1,23 @@
+function text(value) {
+ return value == null ? '' : String(value)
+}
+
+export function stableTaskIdentity(identity = {}) {
+ return {
+ operatorId: text(identity.operatorId ?? identity.accountId ?? identity.userId),
+ taskId: text(identity.taskId ?? identity.inboundId),
+ loadingPlanId: text(identity.loadingPlanId),
+ containerBookingId: text(identity.containerBookingId)
+ }
+}
+
+export function stableTaskIdentityKey(identity = {}) {
+ const value = stableTaskIdentity(identity)
+ if (!value.operatorId || !value.taskId || !value.loadingPlanId) return ''
+ return [value.operatorId, value.taskId, value.loadingPlanId].map(encodeURIComponent).join(':')
+}
+
+export function stableTaskOperationIdentity(identity = {}) {
+ const value = stableTaskIdentity(identity)
+ return `${value.operatorId}-${value.taskId}-${value.loadingPlanId}-${value.containerBookingId}`
+}
diff --git a/utils/user-facing.js b/utils/user-facing.js
new file mode 100644
index 0000000..57304f1
--- /dev/null
+++ b/utils/user-facing.js
@@ -0,0 +1,39 @@
+const INTERNAL_ID_FIELD_PATTERN = /(?:taskId|inboundId|orderId|loadingPlanId|containerBookingId|cartonId|palletId|warehouseId|任务ID|入库单ID|订单ID|装柜计划ID|柜次ID|箱明细ID|托盘ID|仓库ID)\s*[:=:]?\s*[A-Za-z0-9_-]*/gi
+const LONG_INTERNAL_NUMBER_PATTERN = /(^|[^A-Za-z0-9])\d{18,20}(?=$|[^A-Za-z0-9])/g
+
+export function displayBusinessValue(value, missing = '—') {
+ const text = String(value ?? '').trim()
+ return text || missing
+}
+
+export function containerReference(source = {}) {
+ const containerNo = [source?.currentContainerNo, source?.containerNo, source?.boxNo]
+ .map((value) => String(value ?? '').trim())
+ .find((value) => value && value !== '同步中') || ''
+ if (containerNo) return `集装箱号 ${containerNo}`
+ const cabinetNo = String(source?.cabinetNo ?? '').trim()
+ return cabinetNo ? `柜次号 ${cabinetNo}` : '集装箱号 —'
+}
+
+export function sanitizeUserMessage(message, fallback = '操作失败') {
+ const raw = String(message ?? '').trim()
+ if (!raw) return fallback
+ if (/^internal server error$/i.test(raw)) return '服务端处理异常,请联系管理员'
+ const sanitized = raw
+ .replace(INTERNAL_ID_FIELD_PATTERN, '内部标识已隐藏')
+ .replace(LONG_INTERNAL_NUMBER_PATTERN, '$1内部标识已隐藏')
+ return sanitized || fallback
+}
+
+export function formatApiError(error, fallback = '操作失败') {
+ const message = sanitizeUserMessage(error?.message, fallback)
+ const code = String(error?.code ?? '').trim()
+ const requestId = String(error?.requestId ?? '').trim()
+ const traceId = String(error?.traceId ?? '').trim()
+ const evidence = [
+ code ? `错误码:${code}` : '',
+ requestId ? `requestId:${requestId}` : '',
+ traceId ? `traceId:${traceId}` : ''
+ ].filter(Boolean)
+ return evidence.length ? `${message}(${evidence.join(';')})` : message
+}
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..28797c4
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,40 @@
+import { defineConfig, loadEnv } from 'vite'
+import uni from '@dcloudio/vite-plugin-uni'
+
+function loggedProxy(options) {
+ return {
+ ...options,
+ configure(proxy) {
+ proxy.on('proxyReq', (proxyRequest, request) => {
+ proxyRequest.removeHeader('origin')
+ proxyRequest.removeHeader('referer')
+ console.info('[Domestic PDA H5 proxy]', `${request.method} ${request.url}`, '->', `${options.target}${proxyRequest.path}`)
+ })
+ proxy.on('proxyRes', (response, request) => {
+ console.info('[Domestic PDA H5 proxy response]', `${request.method} ${request.url}`, response.statusCode)
+ })
+ proxy.on('error', (error, request) => {
+ console.error('[Domestic PDA H5 proxy error]', `${request.method} ${request.url}`, error.message)
+ })
+ }
+ }
+}
+
+export default defineConfig(({ mode }) => {
+ const projectRoot = process.env.UNI_INPUT_DIR || process.cwd()
+ const env = loadEnv(mode, projectRoot, '')
+ const proxyTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
+
+ return {
+ plugins: [uni()],
+ server: {
+ proxy: {
+ '/gateway-api': loggedProxy({
+ target: proxyTarget,
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/gateway-api/, '/api')
+ })
+ }
+ }
+ }
+})