Release/test #1
@@ -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
|
||||
@@ -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 页面完成扫码输入、网络阻断/恢复、草稿恢复、批量提交、窄屏和在线更新验收;真机检查属于最后的交互验收。
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<script>
|
||||
export default {}
|
||||
import { checkForAppUpdate } from '@/utils/app-update.js'
|
||||
|
||||
export default {
|
||||
onLaunch() {
|
||||
// #ifdef APP-PLUS
|
||||
setTimeout(() => checkForAppUpdate(), 800)
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
当前静态原型范围:人员登录、模块化工作台、入库/出库/库存/查询入口、作业记录、个人中心,以及出库模块内的箱号选择、扫码输入、整单拣货、部分拣货、异常原因、撤销和完成提交。
|
||||
|
||||
登录后默认进入首页,不再直接进入拣货。首页采用入库、出库、库内作业、查询四个分组的三列宫格,不设置常驻底部导航;作业记录收纳在左上角功能菜单,个人中心从右上角人员入口进入。第一版本只有“出库 → 集装箱拣货”可点击并具备完整本地状态交互,其余业务入口统一置灰并禁用,保留在界面中用于确认后续信息架构。
|
||||
国内货柜履约装柜取证已具备接口适配基础:拣货页展示空仓、半仓、满仓、锁仓和带锁码照五类证据,经 File 服务上传取得真实 `fileId`,并展示待上传、上传中、失败、成功和重试状态。产品已确认 PDA 只能现场拍照,Admin 可人工上传;每类至少 1 张有效照片、同类型可多张追加,错误或重复照片只作废不硬删除。当前页面和上传队列仍按会话内单类型记录设计,尚未完整达到“多图追加+作废审计”契约。
|
||||
|
||||
登录后默认进入首页,不再直接进入拣货。首页当前只展示“入库 → 一件代发”和“出库 → 出库拣货”;“库位查询”等其余业务入口保留在数据、页面和路由中,通过 `visible` 隐藏,不从源码删除。首页左上角从 Warehouse 主档选择启用国内仓,首次未选择时自动打开;一件代发与出库拣货待办都按所选仓库从服务端查询。首页不再展示作业记录和个人中心入口,对应页面与路由仍保留。
|
||||
|
||||
## 页面与状态结构
|
||||
|
||||
@@ -17,13 +19,17 @@
|
||||
- `pages/history/history`:本次会话的作业记录;
|
||||
- `pages/profile/profile`:人员、班次、仓库信息及退出登录;
|
||||
- `pages/scanner-test/scanner-test`:Android 硬件扫描广播诊断页;
|
||||
- `pages/picking/picking`:集装箱拣货完整交互。
|
||||
- `pages/picking/picking`:出库拣货完整交互。
|
||||
|
||||
登录会话集中在 `stores/session.js`,拣货会话集中在 `stores/picking.js`;两者都是内存态,不写入设备。退出登录会同时清理当前人员和全部拣货草稿。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。
|
||||
一件代发清点组托只调用 Firstleg 的 `/first-leg/pda-dropship` 正式接口。正式入口沿用 `pages/dropship-tasks/dropship-tasks` 和 `pages/dropship-palletization/dropship-palletization`。箱码先经 `cartons/resolve` 只读识别,确认后才调用 `cartons/count`;先扫托盘时确认箱码直接绑定当前托盘,先扫多箱时箱码进入待绑托区,后扫系统托盘码通过 `pallets/bind-pending` 批量绑定。所有写操作要求在线并携带稳定幂等标识,冲突后刷新权威任务;封托后的托盘只读并作为后续分柜、装柜的不可拆单元。
|
||||
|
||||
拣货原型会在本次登录会话内按箱保存本地草稿和最近扫码结果。切换存在进度的箱号、带待拣项提交以及退出登录均需要明确确认;待拣项提交还必须选择未完成原因。上述状态不持久化到设备,退出登录后会主动清理,不能视为正式离线队列或服务端提交机制。
|
||||
登录会话集中在 `stores/session.js` 并持久化到设备,拣货会话集中在 `stores/picking.js`。认证请求由 `api/http.js` 统一处理:Access Token 临近过期时使用 Refresh Token 轮换;并发请求共享一次刷新;401 刷新成功后只重放一次;Refresh Token 明确失效时清理会话和作业草稿并返回登录页,网络或服务异常不会误清会话。产品契约要求未提交拣货草稿持久化到设备,并绑定作业员、任务和柜次,其他账号不能继承;成功提交或明确放弃后才清理。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。
|
||||
|
||||
拣货页面和本地交互以 `../../docs/悦/PDA拣货/pda-picking.html` 为依据。Android App 端通过统一适配层接收 `android.intent.action.SCANRESULT` 广播,并从字符串 Extra `value` 读取硬件扫描结果;拣货页的扫码框仅显示广播值和当前接收目标样式,不获取输入焦点或弹出软键盘,页面隐藏或销毁时会注销接收器。当前不接后端接口、不实现真实鉴权、不绑定厂商 SDK,也不持久化演示状态。
|
||||
拣货页会在设备端保留当前任务草稿和最近扫码结果,但正式作业以服务端事实为准:进入任务后由后端标记“拣货中”,每次有效箱码/托盘码扫描及“确认拣出、部分拣货、跳拣”操作立即写入服务端,后台智能装柜据此锁定已发生实物拣货的明细。底部提交只负责收尾当前仍未处理的明细并进入装柜取证;网络失败时页面不得先显示成功。允许部分拣货或跳拣,但每个未完成项必须填写异常原因,并挂起任务、阻断完成和 `LOADED`。
|
||||
|
||||
正式 PDA 作业必须联网。断网时不能开始正式拣货、提交、完成任务或推进 `LOADED`;网络恢复后由员工刷新并重试。提交使用对员工不可见的轻量幂等键和服务端版本:重复提交返回原结果,版本冲突提示刷新。项目不建设离线业务提交队列。
|
||||
|
||||
拣货页面和本地交互以 `../../docs/悦/PDA拣货/pda-picking.html` 为依据。PDA 采用 uni-app 开发并支持在线更新;Android App 端已按广播模式接入:Action=`android.intent.action.SCANRESULT`,字符串 Extra=`value`;拣货页的扫码框仅显示广播值和当前接收目标样式,不获取输入焦点或弹出软键盘,页面隐藏或销毁时会注销接收器。当前保留演示任务,同时已有国内货柜履约拣货、装柜证据和完成操作的远程接口适配;只有会话包含真实 Token、客户标识和入仓单标识时才会请求服务端,不会把缺少契约字段的本地操作模拟为远程成功。设备型号不构成开发、发布或验收门禁;正式鉴权和任务领取按服务端契约接入。
|
||||
|
||||
扫码输入统一使用 `components/ScanTargetInput.vue` 展示和选择目标,组件本身不注册广播。每个业务页面只在 `onShow` 注册一次广播、在 `onHide`/`onUnload` 注销,并通过页面内的 `activeScanField` 将广播值路由到当前选中的字段。多输入框页面应由用户点击切换蓝色“当前扫码”目标,避免多个输入框或隐藏页面同时消费同一次扫描。
|
||||
|
||||
@@ -46,12 +52,56 @@ playErrorSound()
|
||||
node --test tests/*.test.mjs
|
||||
```
|
||||
|
||||
当前环境验证使用 Node.js 22。测试同时约束 Vue 3 入口、页面契约和本地拣货状态规则。项目尚无 HBuilderX CLI、可核实的 Android 构建命令或自动化 UI 测试设施。
|
||||
当前环境验证使用 Node.js 22。测试同时约束 Vue 3 入口、页面契约和本地拣货状态规则,但不代替 H5、App 或真机交互验收。
|
||||
|
||||
## 仍需真机验证
|
||||
H5 已使用 HBuilderX 5.15 自带的 uni-app Vue 3 编译器验证。先在 `.env.local` 配置当前环境的 `VITE_API_BASE_URL`、`VITE_H5_API_BASE_URL` 和 `API_PROXY_TARGET`,再执行:
|
||||
|
||||
- Android PDA 的状态栏、导航栏和软键盘避让;
|
||||
- 硬件扫描头的输入方式、连续扫描和重复码反馈;
|
||||
- 低电量、前后台切换、断网重连和正式打包发布。
|
||||
```powershell
|
||||
& '<HBuilderX目录>\cli.exe' open
|
||||
& '<HBuilderX目录>\cli.exe' project open --path '<仓库绝对路径>\apps\pda-uniapp'
|
||||
& '<HBuilderX目录>\cli.exe' launch web --project '<仓库绝对路径>\apps\pda-uniapp' --browser Built
|
||||
```
|
||||
|
||||
一期需要确认是否与管理后台头程订单及仓库任务同步上线。
|
||||
命令会使用 HBuilderX 内置依赖启动 Vite H5 服务,实际端口以控制台输出为准。仓库仍未提供 `package.json`、锁文件或 npm 构建命令,也尚无可核实的 Android 构建命令和自动化 UI 测试设施;不得借用海外 PDA 的依赖版本替代。
|
||||
|
||||
## 已确认但尚待实现/验收
|
||||
|
||||
- 在目标 Android PDA 上继续验证设备草稿按作业员、任务和柜次隔离及恢复。
|
||||
- 在目标 Android PDA 上继续验证逐项即时提交、幂等重放、版本冲突刷新和异常挂起。
|
||||
- 将五类证据从单槽位模型改为多图追加,补错误记录作废及 Admin 人工上传。
|
||||
- 在目标 Android PDA 上验证状态栏、导航栏、软键盘避让、连续扫描、重复码、低电量和前后台切换。
|
||||
- 按既定 uni-app 在线更新安排完成正式发布,并在目标设备验证扫描、网络和前后台行为。
|
||||
|
||||
## 仍待产品确认
|
||||
|
||||
- 同一柜次是否允许多人并行拣货,以及任务拆分/合并口径。
|
||||
- 退出任务或换班时是否释放任务、由谁接管草稿。
|
||||
- 批量提交超时、业务错误和网络恢复后的具体提示与重试交互。
|
||||
- 谁触发最终 `LOADED`,是否需要 Admin 二次复核。
|
||||
- 哪些 Admin 角色可人工上传照片,以及照片可见范围和留存期限。
|
||||
- 锁码格式、长度和唯一性规则。
|
||||
|
||||
订单与客户需要确认是否与管理后台头程订单及仓库任务同步上线。
|
||||
|
||||
## App 在线更新
|
||||
|
||||
Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查国内 PDA 的独立更新通道,员工也可在“个人中心 → 检查更新”手动触发;H5 仅展示 App 操作提示,不执行更新。更新服务接收 `appid`、`platform`、`appVersion`、`appVersionCode`、`wgtVersion` 查询参数,无更新返回 `{ "update": false }`,有更新返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"update": true,
|
||||
"packageType": "apk",
|
||||
"versionName": "0.0.2",
|
||||
"versionCode": 2,
|
||||
"downloadUrl": "https://updates.example.com/pda/domestic/0.0.2.apk",
|
||||
"mandatory": false,
|
||||
"releaseNotes": "本次更新内容"
|
||||
}
|
||||
```
|
||||
|
||||
`packageType` 只允许 `apk` 或 `wgt`。`mandatory=true` 时员工不能跳过,并在下载或安装失败后提供重试;wgt 安装完成会立即重启,APK 交由 Android 安装器完成覆盖安装。正式发布要求:
|
||||
|
||||
1. 国内 PDA 每次整包发布同时递增 `manifest.json` 的 `versionName` 与整数 `versionCode`,使用国内独立 AppID、包名和签名打包 APK。
|
||||
2. wgt 发布至少递增 `versionName`,并使用与已安装原生包兼容的 uni-app 编译器生成;新增原生模块、权限或不兼容运行时能力时必须发布 APK,不能只发 wgt。
|
||||
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
|
||||
4. 国内和海外更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { API_ROOT } from '@/config/runtime.js'
|
||||
|
||||
function authRequest(path, data, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: `${API_ROOT}${path}`,
|
||||
method: 'POST',
|
||||
data,
|
||||
header: options.header,
|
||||
timeout: options.timeout || 15000,
|
||||
success(response) {
|
||||
const body = response.data || {}
|
||||
if (String(body.code ?? response.statusCode) !== '200') {
|
||||
const error = new Error(body.message || '认证服务请求失败')
|
||||
error.code = body.code || response.statusCode
|
||||
error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id']
|
||||
error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(body.data)
|
||||
},
|
||||
fail(error) {
|
||||
const next = new Error(error.errMsg || '网络连接失败')
|
||||
next.network = true
|
||||
reject(next)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function login(loginId, password) {
|
||||
return authRequest('/auth/login', { loginId, password })
|
||||
}
|
||||
|
||||
export function refresh(refreshToken) {
|
||||
return authRequest('/auth/refresh', { refreshToken })
|
||||
}
|
||||
|
||||
export function logout(refreshToken, accessToken) {
|
||||
return authRequest('/auth/logout', { refreshToken }, {
|
||||
header: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
|
||||
timeout: 5000
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { refresh as refreshRequest } from '@/api/auth.js'
|
||||
import { isAccessTokenExpiring, session, signOut, updateTokenPair } from '@/stores/session.js'
|
||||
import { requireOnline } from '@/utils/network.js'
|
||||
|
||||
let refreshPromise = null
|
||||
let redirectingToLogin = false
|
||||
|
||||
function responseCode(response) {
|
||||
return String(response?.data?.code ?? response?.statusCode ?? '')
|
||||
}
|
||||
|
||||
function isUnauthorized(responseOrError) {
|
||||
const statusCode = Number(responseOrError?.statusCode ?? responseOrError?.response?.statusCode ?? 0)
|
||||
const code = String(responseOrError?.code ?? responseCode(responseOrError))
|
||||
return statusCode === 401 || code === '401' || code === 'A0401'
|
||||
}
|
||||
|
||||
function sessionExpiredError(source) {
|
||||
const error = new Error('登录已过期,请重新登录')
|
||||
error.code = source?.code || 'A0401'
|
||||
error.statusCode = source?.statusCode || 401
|
||||
return error
|
||||
}
|
||||
|
||||
function redirectAfterSessionExpiry() {
|
||||
if (redirectingToLogin) return
|
||||
redirectingToLogin = true
|
||||
if (typeof uni !== 'undefined' && typeof uni.showToast === 'function') {
|
||||
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none', duration: 2200 })
|
||||
}
|
||||
if (typeof uni !== 'undefined' && typeof uni.reLaunch === 'function') {
|
||||
uni.reLaunch({
|
||||
url: '/pages/login/login',
|
||||
complete() { setTimeout(() => { redirectingToLogin = false }, 1000) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function expireSession(source, observedRefreshToken = session.refreshToken) {
|
||||
if (observedRefreshToken && session.refreshToken && observedRefreshToken !== session.refreshToken) return session.token
|
||||
const hadSession = Boolean(session.currentUser || session.token || session.refreshToken)
|
||||
signOut()
|
||||
if (hadSession) redirectAfterSessionExpiry()
|
||||
throw sessionExpiredError(source)
|
||||
}
|
||||
|
||||
function networkError(error, fallback = '网络连接失败') {
|
||||
const next = new Error(error?.errMsg || fallback)
|
||||
next.network = true
|
||||
return next
|
||||
}
|
||||
|
||||
function rawRequest(options, accessToken) {
|
||||
const baseUrl = session.apiBase || '/api/platform/v1'
|
||||
const headers = { ...(options.header || {}) }
|
||||
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||
if (session.requestId) headers['X-Request-Id'] = session.requestId
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
url: `${baseUrl}${options.url}`,
|
||||
header: headers,
|
||||
success: resolve,
|
||||
fail: (error) => reject(networkError(error))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
const observedRefreshToken = session.refreshToken
|
||||
if (!observedRefreshToken) return expireSession(null, observedRefreshToken)
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
if (session.refreshToken !== observedRefreshToken && session.token) return session.token
|
||||
try {
|
||||
const tokens = await refreshRequest(observedRefreshToken)
|
||||
if (session.refreshToken !== observedRefreshToken) {
|
||||
if (session.token) return session.token
|
||||
throw sessionExpiredError()
|
||||
}
|
||||
updateTokenPair(tokens)
|
||||
redirectingToLogin = false
|
||||
return session.token
|
||||
} catch (error) {
|
||||
if (isUnauthorized(error)) return expireSession(error, observedRefreshToken)
|
||||
throw error
|
||||
}
|
||||
})().finally(() => { refreshPromise = null })
|
||||
}
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
async function validAccessToken() {
|
||||
if (!session.token || isAccessTokenExpiring(session.token)) return refreshAccessToken()
|
||||
return session.token
|
||||
}
|
||||
|
||||
export function ensureAccessToken() {
|
||||
return validAccessToken()
|
||||
}
|
||||
|
||||
async function runAuthenticated(execute) {
|
||||
requireOnline()
|
||||
let accessToken = await validAccessToken()
|
||||
let response = await execute(accessToken)
|
||||
if (!isUnauthorized(response)) return response
|
||||
accessToken = session.token && session.token !== accessToken ? session.token : await refreshAccessToken()
|
||||
const activeRefreshToken = session.refreshToken
|
||||
response = await execute(accessToken)
|
||||
if (isUnauthorized(response)) expireSession(response, activeRefreshToken)
|
||||
return response
|
||||
}
|
||||
|
||||
export function apiRequest(options) {
|
||||
return runAuthenticated((accessToken) => rawRequest(options, accessToken)).then((response) => {
|
||||
const body = response.data || {}
|
||||
const code = responseCode(response)
|
||||
if (code !== '200') {
|
||||
const error = new Error(body.message || '服务端请求失败')
|
||||
error.code = body.code || response.statusCode
|
||||
error.statusCode = response.statusCode
|
||||
error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id']
|
||||
error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
|
||||
throw error
|
||||
}
|
||||
return body.data
|
||||
})
|
||||
}
|
||||
|
||||
export function apiDownload(options) {
|
||||
return runAuthenticated((accessToken) => new Promise((resolve, reject) => {
|
||||
const headers = { ...(options.header || {}) }
|
||||
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||
if (session.requestId) headers['X-Request-Id'] = session.requestId
|
||||
uni.downloadFile({ ...options, header: headers, success: resolve, fail: (error) => reject(networkError(error, '文件下载失败')) })
|
||||
}))
|
||||
}
|
||||
|
||||
export function apiUpload(options, onProgress) {
|
||||
return runAuthenticated((accessToken) => new Promise((resolve, reject) => {
|
||||
const headers = { ...(options.header || {}) }
|
||||
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||
if (session.requestId) headers['X-Request-Id'] = session.requestId
|
||||
const task = uni.uploadFile({ ...options, header: headers, success: resolve, fail: (error) => reject(networkError(error)) })
|
||||
if (task && typeof task.onProgressUpdate === 'function' && typeof onProgress === 'function') task.onProgressUpdate(({ progress }) => onProgress(progress))
|
||||
}))
|
||||
}
|
||||
|
||||
export { isUnauthorized }
|
||||
@@ -0,0 +1,70 @@
|
||||
import { apiRequest } from '@/api/http.js'
|
||||
import { stableOperationId } from '@/utils/idempotency.js'
|
||||
import { session } from '@/stores/session.js'
|
||||
|
||||
const ROOT = '/first-leg/pda-dropship'
|
||||
|
||||
function stableId(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function operationId(action, taskId, payload = {}) {
|
||||
return stableOperationId('dropship-' + action, String(session.currentUser?.id) + '-' + taskId, payload)
|
||||
}
|
||||
|
||||
export function listDropshipPalletizationTasks(keyword = '', warehouseId) {
|
||||
if (!warehouseId) throw new Error('请先选择作业仓库')
|
||||
const query = String(keyword || '').trim()
|
||||
const params = [`warehouseId=${encodeURIComponent(warehouseId)}`]
|
||||
if (query) params.push(`keyword=${encodeURIComponent(query)}`)
|
||||
return apiRequest({ url: `${ROOT}/tasks/pending?${params.join('&')}`, method: 'GET' })
|
||||
}
|
||||
|
||||
export function getDropshipPalletizationTask(taskId, warehouseId) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(warehouseId)}`, method: 'GET' })
|
||||
}
|
||||
|
||||
export function claimDropshipPalletizationTask(taskId, warehouseId) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/claim`, method: 'POST', data: { warehouseId: stableId(warehouseId) } })
|
||||
}
|
||||
|
||||
export function resolveDropshipCarton(taskId, warehouseId, boxCode) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/cartons/resolve?warehouseId=${encodeURIComponent(warehouseId)}&boxCode=${encodeURIComponent(String(boxCode || '').trim())}`, method: 'GET' })
|
||||
}
|
||||
|
||||
export function countDropshipCarton(taskId, payload) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/cartons/count`, method: 'POST', data: { ...payload, warehouseId: stableId(payload.warehouseId), operationId: payload.operationId || operationId('count', taskId, payload) } })
|
||||
}
|
||||
|
||||
export function bindDropshipPendingCartons(taskId, warehouseId, palletNo, taskVersion) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/bind-pending`, method: 'POST', data: { warehouseId: stableId(warehouseId), palletNo: String(palletNo || '').trim().toUpperCase(), operationId: operationId('bind', taskId, { warehouseId, palletNo, taskVersion }) } })
|
||||
}
|
||||
|
||||
export function sealDropshipPallet(taskId, warehouseId, pallet) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/${encodeURIComponent(pallet.palletId)}/seal`, method: 'POST', data: { warehouseId: stableId(warehouseId), expectedVersion: pallet.version, operationId: operationId('seal', taskId, { warehouseId, palletId: pallet.palletId, version: pallet.version }) } })
|
||||
}
|
||||
|
||||
export function unsealDropshipPallet(taskId, warehouseId, pallet) {
|
||||
return apiRequest({ url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/pallets/${encodeURIComponent(pallet.palletId)}/unseal`, method: 'POST', data: { warehouseId: stableId(warehouseId), expectedVersion: pallet.version, operationId: operationId('unseal', taskId, { warehouseId, palletId: pallet.palletId, version: pallet.version }) } })
|
||||
}
|
||||
|
||||
|
||||
function requireValue(value, message) {
|
||||
if (value === null || value === undefined || value === '') throw new Error(message)
|
||||
}
|
||||
|
||||
function operationHeader(operationId) {
|
||||
return operationId ? { 'Idempotency-Key': String(operationId) } : {}
|
||||
}
|
||||
|
||||
|
||||
export function moveDropshipCarton(taskId, cartonId, payload = {}) {
|
||||
requireValue(cartonId, '缺少箱明细标识')
|
||||
for (const [field, message] of [['warehouseId', '缺少作业仓库标识'], ['operationId', '缺少操作幂等标识']]) requireValue(payload[field], message)
|
||||
return apiRequest({
|
||||
url: `${ROOT}/tasks/${encodeURIComponent(taskId)}/cartons/${encodeURIComponent(cartonId)}/move`,
|
||||
method: 'POST',
|
||||
header: operationHeader(payload.operationId),
|
||||
data: { warehouseId: payload.warehouseId, targetPalletNo: payload.targetPalletNo || null, operationId: payload.operationId }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { session } from '@/stores/session.js'
|
||||
import { requireOnline } from '@/utils/network.js'
|
||||
import { apiDownload, apiRequest, apiUpload } from '@/api/http.js'
|
||||
import { resolveEvidencePreviewUrl } from '@/utils/api-url.js'
|
||||
|
||||
export { resolveEvidencePreviewUrl } from '@/utils/api-url.js'
|
||||
|
||||
const EVIDENCE_TYPES = Object.freeze([
|
||||
'PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'
|
||||
])
|
||||
|
||||
// Keep the confirmed business labels in the PDA while the server retains its
|
||||
// legacy evidence codes. Remove this mapping only after the backend contract is migrated.
|
||||
const EVIDENCE_SLOTS = Object.freeze([
|
||||
{ type: 'PHOTO_STEP_1', label: '空仓照片' },
|
||||
{ type: 'PHOTO_STEP_2', label: '半仓照片' },
|
||||
{ type: 'PHOTO_STEP_3', label: '满仓照片' },
|
||||
{ type: 'PHOTO_STEP_4', label: '锁仓照片' },
|
||||
{ type: 'SEAL', label: '带锁码照' }
|
||||
])
|
||||
|
||||
// Picking and cabinet evidence are both scoped by the canonical PDA task. The
|
||||
// task warehouse is always sent as an authorization boundary and never guessed.
|
||||
|
||||
function debugLog(event, payload = {}) {
|
||||
try { console.info(`[PDA] ${event}`, payload) } catch (_) { /* console may be unavailable on a device */ }
|
||||
}
|
||||
|
||||
const request = apiRequest
|
||||
|
||||
export function listPdaWarehouses() {
|
||||
return request({ url: '/first-leg/pda-loading/warehouses', method: 'GET' }).then((records) =>
|
||||
(Array.isArray(records) ? records : []).map((warehouse) => ({
|
||||
id: String(warehouse.id),
|
||||
warehouseCode: String(warehouse.warehouseCode || ''),
|
||||
warehouseName: String(warehouse.warehouseName || '')
|
||||
})))
|
||||
}
|
||||
|
||||
function stableIdempotencyKey(operationId, suffix = '') {
|
||||
const raw = `pda-${operationId}${suffix ? `-${suffix}` : ''}`
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < raw.length; index += 1) {
|
||||
hash ^= raw.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
const prefix = raw.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 96)
|
||||
return `${prefix}-${(hash >>> 0).toString(36)}-${raw.length}`.slice(0, 128)
|
||||
}
|
||||
|
||||
export function isRemoteReady(inboundId) {
|
||||
return Boolean(session.token && (session.tenantId || inboundId))
|
||||
}
|
||||
|
||||
export function getContainerPickingTask(identity, warehouseId) {
|
||||
const selector = typeof identity === 'object' && identity !== null ? identity : {}
|
||||
const taskId = selector.taskId ?? selector.id
|
||||
if (!taskId) throw new Error('当前没有可进入的拣货任务,请返回任务列表刷新')
|
||||
if (!warehouseId) throw new Error('缺少作业仓库标识,无法请求现场任务')
|
||||
return request({
|
||||
url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
method: 'GET'
|
||||
}).then(normalizePickingTask)
|
||||
}
|
||||
|
||||
export function claimContainerPickingTask(taskId, expectedAssignmentVersion, operationId) {
|
||||
if (!taskId) throw new Error('缺少拣货任务标识,无法开始作业')
|
||||
return request({
|
||||
url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/claim`,
|
||||
method: 'POST',
|
||||
header: { 'Idempotency-Key': stableIdempotencyKey(operationId || `claim-${taskId}`) },
|
||||
data: { expectedAssignmentVersion, operationId: operationId || `pda-claim-${taskId}` }
|
||||
})
|
||||
}
|
||||
|
||||
export function getPickingInventorySnapshot(taskId, warehouseId) {
|
||||
if (!taskId) throw new Error('缺少拣货任务标识,无法查询实时库存')
|
||||
if (!warehouseId) throw new Error('缺少作业仓库标识,无法查询实时库存')
|
||||
return request({
|
||||
url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
method: 'GET'
|
||||
}).then(normalizeInventorySnapshot)
|
||||
}
|
||||
|
||||
function itemAction(taskId, itemId, path, payload, operationId) {
|
||||
// Concrete routes include /items/${encodeURIComponent(itemId)}/pick, /items/${encodeURIComponent(itemId)}/partial and /items/${encodeURIComponent(itemId)}/skip.
|
||||
if (!taskId || itemId == null) throw new Error('缺少拣货任务或明细标识,无法提交')
|
||||
return request({
|
||||
url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/items/${encodeURIComponent(itemId)}/${path}`,
|
||||
method: 'POST',
|
||||
header: { 'Idempotency-Key': stableIdempotencyKey(operationId || `${path}-${taskId}-${itemId}`) },
|
||||
data: payload
|
||||
})
|
||||
}
|
||||
|
||||
export function pickContainerItem(taskId, itemId, payload = {}) {
|
||||
return itemAction(taskId, itemId, 'pick', payload, payload.operationId)
|
||||
}
|
||||
|
||||
export function pickContainerPallet(taskId, payload = {}) {
|
||||
if (!taskId || !payload.palletNo) throw new Error('缺少拣货任务或托盘码,无法提交')
|
||||
return request({
|
||||
url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/pallets/pick`,
|
||||
method: 'POST',
|
||||
header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId || `pick-pallet-${taskId}-${payload.palletNo}`) },
|
||||
data: payload
|
||||
})
|
||||
}
|
||||
|
||||
export function partialContainerItem(taskId, itemId, payload = {}) {
|
||||
return itemAction(taskId, itemId, 'partial', payload, payload.operationId)
|
||||
}
|
||||
|
||||
export function skipContainerItem(taskId, itemId, payload = {}) {
|
||||
return itemAction(taskId, itemId, 'skip', payload, payload.operationId)
|
||||
}
|
||||
|
||||
export async function submitContainerPickingBatch(task, actions, { warehouseId, operationId, inventorySnapshotToken } = {}) {
|
||||
if (!task?.taskId) throw new Error('缺少拣货任务标识,无法提交')
|
||||
if (!task.loadingPlanId || !task.containerBookingId) throw new Error('缺少稳定柜次标识,无法整批提交')
|
||||
const submitOperationId = operationId || `submit-${task.taskId}-${task.loadingPlanId}-${task.containerBookingId}-${task.version ?? task.pickingVersion ?? ''}`
|
||||
const expectedVersion = task.version ?? task.pickingVersion
|
||||
if (expectedVersion == null) throw new Error('缺少拣货任务版本,无法整批提交')
|
||||
if (!warehouseId) throw new Error('缺少作业仓库标识,无法整批提交')
|
||||
if (!inventorySnapshotToken) throw new Error('缺少实时库存快照,请刷新后重试')
|
||||
const items = (actions || []).map((action) => ({
|
||||
itemId: action.itemId,
|
||||
action: action.type,
|
||||
palletNo: action.type === 'PICK_PALLET' ? action.palletNo : null,
|
||||
pickedPackageCount: action.type === 'PICK' ? action.pickedPackageCount : null,
|
||||
finalPickedPackageCount: action.type === 'PARTIAL' ? action.finalPickedPackageCount : null,
|
||||
version: action.version,
|
||||
reason: action.type === 'SKIP' ? action.reason : null,
|
||||
palletNo: action.type === 'PICK_PALLET' ? action.palletNo : null
|
||||
}))
|
||||
return request({
|
||||
url: `/first-leg/pda-loading/tasks/${encodeURIComponent(task.taskId)}/batch`,
|
||||
method: 'POST',
|
||||
header: { 'Idempotency-Key': stableIdempotencyKey(submitOperationId) },
|
||||
data: { warehouseId, loadingPlanId: task.loadingPlanId, containerBookingId: task.containerBookingId, expectedVersion, inventorySnapshotToken, items }
|
||||
})
|
||||
}
|
||||
|
||||
export function listLoadingOrders({ page = 1, size = 50, keyword = '', warehouseId } = {}) {
|
||||
if (!warehouseId) throw new Error('请先选择作业仓库')
|
||||
const query = []
|
||||
query.push(`warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||
if (keyword) query.push(`keyword=${encodeURIComponent(keyword)}`)
|
||||
const suffix = query.length ? `?${query.join('&')}` : ''
|
||||
return request({ url: `/first-leg/pda-loading/tasks/pending${suffix}`, method: 'GET' }).then((data) => ({
|
||||
page, size, records: (Array.isArray(data) ? data : data?.records || []).map(normalizePdaOrder)
|
||||
}))
|
||||
}
|
||||
|
||||
export function normalizePdaOrder(row = {}) {
|
||||
row = row || {}
|
||||
return {
|
||||
...row,
|
||||
inboundId: row.inboundId ?? row.inbound?.id ?? null,
|
||||
inboundVersion: row.inboundVersion ?? row.inbound?.version ?? null,
|
||||
tenantId: row.tenantId ?? row.accountId ?? row.inbound?.tenantId ?? null,
|
||||
pickingVersion: row.pickingVersion ?? row.loading?.pickingVersion ?? null,
|
||||
taskId: row.taskId ?? row.pdaTaskId ?? row.loadingTaskId ?? row.loading?.taskId ?? null,
|
||||
loadingPlanId: row.loadingPlanId ?? row.loading?.loadingPlanId ?? row.loadingPlan?.id ?? null,
|
||||
containerBookingId: row.containerBookingId ?? row.loading?.containerBookingId ?? row.container?.id ?? null,
|
||||
sourceType: row.sourceType ?? row.container?.sourceType ?? null,
|
||||
recordStatus: row.recordStatus ?? row.container?.recordStatus ?? null,
|
||||
mergedIntoContainerBookingId: row.mergedIntoContainerBookingId ?? row.container?.mergedIntoContainerBookingId ?? null,
|
||||
cabinetNo: row.cabinetNo ?? row.loading?.cabinetNo ?? row.container?.cabinetNo ?? row.containerNo ?? '',
|
||||
currentContainerNo: row.currentContainerNo ?? row.containerNo ?? row.container?.containerNo ?? row.loadingPlan?.containerNo ?? row.loading?.containerNo ?? null,
|
||||
warehouseId: row.warehouseId ?? row.warehouse?.id ?? row.loadingPlan?.warehouseId ?? null,
|
||||
warehouseName: row.warehouseName ?? row.warehouse?.name ?? row.loadingPlan?.warehouseName ?? '',
|
||||
orderNo: row.orderNo ?? row.order?.orderNo ?? '',
|
||||
inboundCode: row.inboundCode ?? row.inbound?.inboundCode ?? '',
|
||||
displayNo: row.orderNo ?? row.order?.orderNo ?? row.inboundCode ?? row.inbound?.inboundCode ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePickingTask(task = {}) {
|
||||
task = task || {}
|
||||
return {
|
||||
...task,
|
||||
taskId: task.taskId ?? task.id ?? null,
|
||||
loadingPlanId: task.loadingPlanId ?? task.loading?.loadingPlanId ?? null,
|
||||
containerBookingId: task.containerBookingId ?? task.loading?.containerBookingId ?? null,
|
||||
sourceType: task.sourceType ?? task.container?.sourceType ?? null,
|
||||
recordStatus: task.recordStatus ?? task.container?.recordStatus ?? null,
|
||||
mergedIntoContainerBookingId: task.mergedIntoContainerBookingId ?? task.container?.mergedIntoContainerBookingId ?? null,
|
||||
cabinetNo: task.cabinetNo ?? task.loading?.cabinetNo ?? task.containerNo ?? '',
|
||||
currentContainerNo: task.currentContainerNo ?? task.containerNo ?? null,
|
||||
orderNo: task.orderNo ?? task.order?.orderNo ?? '',
|
||||
inboundCode: task.inboundCode ?? task.inbound?.inboundCode ?? '',
|
||||
warehouseName: task.warehouseName ?? task.warehouse?.name ?? '',
|
||||
// `version` is the canonical task version returned by the loading API.
|
||||
// Prefer it over a previously normalized alias when merging a mutation
|
||||
// response, otherwise the next immediate pallet action reuses a stale
|
||||
// expectedTaskVersion and is rejected as a concurrent update.
|
||||
pickingVersion: task.version ?? task.pickingVersion ?? null,
|
||||
inboundVersion: task.inboundVersion ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeInventorySnapshot(snapshot = {}) {
|
||||
return normalizePickingTask({ ...snapshot, version: snapshot.taskVersion ?? snapshot.version })
|
||||
}
|
||||
|
||||
export function getEvidence(taskId, warehouseId) {
|
||||
if (!taskId) throw new Error('缺少装柜任务标识,无法加载装柜证据')
|
||||
if (!warehouseId) throw new Error('缺少任务仓库标识,无法加载装柜证据')
|
||||
return request({
|
||||
url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
export function getEvidencePreviewUrl(taskId, evidenceId, warehouseId) {
|
||||
if (!taskId || !evidenceId || !warehouseId) return ''
|
||||
return `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/content?warehouseId=${encodeURIComponent(warehouseId)}`
|
||||
}
|
||||
|
||||
export function downloadEvidencePreview(previewUrl) {
|
||||
requireOnline('当前网络不可用,无法查看装柜照片')
|
||||
if (!previewUrl) throw new Error('装柜照片地址尚未同步')
|
||||
const url = resolveEvidencePreviewUrl(previewUrl, session.apiBase || '/api/platform/v1')
|
||||
debugLog('evidence.preview.start', { previewPath: String(previewUrl), resolvedUrl: url })
|
||||
return apiDownload({ url }).then((response) => {
|
||||
const responseHeaders = response.header || {}
|
||||
const contentType = responseHeaders['Content-Type'] || responseHeaders['content-type'] || ''
|
||||
const requestId = responseHeaders['X-Request-Id'] || responseHeaders['x-request-id'] || ''
|
||||
const traceId = responseHeaders['X-Trace-Id'] || responseHeaders['x-trace-id'] || ''
|
||||
debugLog('evidence.preview.response', { previewPath: String(previewUrl), statusCode: response.statusCode, contentType, hasTempFilePath: Boolean(response.tempFilePath), requestId, traceId })
|
||||
if (response.statusCode < 200 || response.statusCode >= 300 || !response.tempFilePath) {
|
||||
const error = new Error('装柜照片下载失败')
|
||||
error.statusCode = response.statusCode
|
||||
error.requestId = requestId
|
||||
error.traceId = traceId
|
||||
throw error
|
||||
}
|
||||
if (contentType && !/^image\//i.test(contentType) && !/^application\/octet-stream/i.test(contentType)) {
|
||||
const error = new Error('服务端未返回有效照片')
|
||||
error.statusCode = response.statusCode
|
||||
error.contentType = contentType
|
||||
error.requestId = requestId
|
||||
error.traceId = traceId
|
||||
throw error
|
||||
}
|
||||
return response.tempFilePath
|
||||
}).catch((error) => {
|
||||
debugLog('evidence.preview.error', { previewPath: String(previewUrl), message: error.message || error.errMsg || '' })
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
export function voidEvidence(taskId, evidenceId, warehouseId, payload = {}) {
|
||||
if (!taskId || !evidenceId) throw new Error('缺少装柜任务或证据标识,无法作废')
|
||||
if (!warehouseId) throw new Error('缺少任务仓库标识,无法作废装柜证据')
|
||||
if (!String(payload.reason || '').trim()) throw new Error('作废原因不能为空')
|
||||
return request({
|
||||
url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/void`,
|
||||
method: 'POST',
|
||||
data: { warehouseId, expectedVersion: payload.expectedVersion ?? 0, reason: String(payload.reason).trim() }
|
||||
})
|
||||
}
|
||||
|
||||
/** Upload a local photo to the file service and return its durable fileId. */
|
||||
export function uploadEvidenceFile(taskId, warehouseId, payload, localPath, onProgress) {
|
||||
requireOnline('当前网络不可用,无法上传装柜照片')
|
||||
if (!taskId) throw new Error('缺少装柜任务标识,无法上传装柜照片')
|
||||
if (!warehouseId) throw new Error('缺少任务仓库标识,无法上传装柜照片')
|
||||
const evidenceType = payload?.evidenceType
|
||||
if (!EVIDENCE_TYPES.includes(evidenceType)) throw new Error('不支持的装柜证据类型')
|
||||
if (!localPath) throw new Error('缺少待上传的照片')
|
||||
if (payload.expectedVersion == null) throw new Error('缺少装柜证据版本,无法上传装柜照片')
|
||||
if (evidenceType === 'SEAL' && !String(payload.sealNo || '').trim()) throw new Error('带锁码照必须填写封签号')
|
||||
const baseUrl = session.apiBase || '/api/platform/v1'
|
||||
debugLog('evidence.upload.start', { taskId, evidenceType, hasLocalPath: Boolean(localPath) })
|
||||
const query = [
|
||||
`warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||
`expectedVersion=${encodeURIComponent(payload.expectedVersion)}`,
|
||||
`evidenceType=${encodeURIComponent(evidenceType)}`
|
||||
]
|
||||
if (payload.sealNo) query.push(`sealNo=${encodeURIComponent(String(payload.sealNo).trim())}`)
|
||||
if (payload.remark) query.push(`remark=${encodeURIComponent(payload.remark)}`)
|
||||
return apiUpload({
|
||||
url: `${baseUrl}/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?${query.join('&')}`,
|
||||
filePath: localPath,
|
||||
name: 'file',
|
||||
header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId || `evidence-${taskId}-${evidenceType}-${payload.expectedVersion}`) }
|
||||
}, onProgress).then((response) => {
|
||||
let body = response.data
|
||||
try { body = typeof body === 'string' ? JSON.parse(body) : body } catch (_) { /* handled below */ }
|
||||
const code = String(body?.code ?? response.statusCode)
|
||||
debugLog('evidence.upload.response', { taskId, evidenceType, statusCode: response.statusCode, code, message: body?.message || '', requestId: body?.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'] || '', traceId: body?.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] || '', fileId: body?.data?.fileId || null })
|
||||
if (code !== '200') {
|
||||
const error = new Error(body?.message || '文件上传失败')
|
||||
error.code = body?.code || response.statusCode
|
||||
error.statusCode = response.statusCode
|
||||
error.requestId = body?.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'] || ''
|
||||
error.traceId = body?.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] || ''
|
||||
throw error
|
||||
}
|
||||
return body?.data || body
|
||||
}).catch((error) => { debugLog('evidence.upload.error', { taskId, evidenceType, message: error.message || error.errMsg || '' }); throw error })
|
||||
}
|
||||
|
||||
/** Complete the cabinet PDA task after the operator has captured all evidence. */
|
||||
export function completeContainerPickingTask(taskId, payload = {}) {
|
||||
if (!taskId) throw new Error('缺少拣货任务标识,无法确认装柜完成')
|
||||
if (payload.expectedTaskVersion == null) throw new Error('缺少拣货任务版本,无法确认装柜完成')
|
||||
if (!payload.operationId) throw new Error('缺少操作标识,无法确认装柜完成')
|
||||
return request({
|
||||
url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/complete`,
|
||||
method: 'POST',
|
||||
header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId) },
|
||||
data: payload
|
||||
})
|
||||
}
|
||||
|
||||
export { EVIDENCE_SLOTS, EVIDENCE_TYPES }
|
||||
@@ -2,7 +2,7 @@
|
||||
<view class="portal-header-shell">
|
||||
<view class="portal-header">
|
||||
<view class="portal-header-row">
|
||||
<button v-if="menu" class="portal-header-button" aria-label="打开功能菜单" @tap="openMenu"><uni-icons type="bars" color="#ffffff" size="26" /></button>
|
||||
<button v-if="menu" class="portal-header-button" :aria-label="user.warehouse ? `当前作业仓库 ${user.warehouse},点击切换` : '选择作业仓库'" @tap="openMenu"><uni-icons type="location" color="#ffffff" size="26" /></button>
|
||||
<button v-else class="portal-header-button" aria-label="返回上一页" @tap="goBack"><uni-icons type="back" color="#ffffff" size="26" /></button>
|
||||
<text class="portal-title">{{ title }}</text>
|
||||
<button v-if="showProfile" class="avatar-button" aria-label="打开个人中心" @tap="goProfile"><uni-icons type="person" color="#ffffff" size="27" /></button>
|
||||
@@ -12,9 +12,11 @@
|
||||
<uni-popup ref="menuPopup" type="bottom" background-color="#ffffff" border-radius="12px 12px 0 0" :is-mask-click="true" @change="handleMenuChange">
|
||||
<view class="header-menu-sheet">
|
||||
<view class="header-menu-content">
|
||||
<PopupHeader title="功能菜单" @close="closeMenu" />
|
||||
<button class="header-menu-item" @tap="goHistory"><uni-icons type="list" color="#334155" size="22" /><text>作业记录</text><uni-icons type="right" color="#94a3b8" size="18" /></button>
|
||||
<button class="header-menu-item" @tap="goProfile"><uni-icons type="person" color="#334155" size="22" /><text>个人中心</text><uni-icons type="right" color="#94a3b8" size="18" /></button>
|
||||
<PopupHeader title="选择作业仓库" @close="closeMenu" />
|
||||
<view v-if="warehouseLoading" class="header-menu-state">正在加载仓库…</view>
|
||||
<view v-else-if="warehouseError" class="header-menu-state header-menu-error">{{ warehouseError }}</view>
|
||||
<view v-else-if="!warehouses.length" class="header-menu-state">暂无可用国内仓</view>
|
||||
<block v-else><button v-for="warehouse in warehouses" :key="warehouse.id" class="header-menu-item warehouse-menu-item" :class="{ selected: String(warehouse.id) === String(selectedWarehouseId || '') }" @tap="chooseWarehouse(warehouse)"><uni-icons type="location-filled" :color="String(warehouse.id) === String(selectedWarehouseId || '') ? '#155eef' : '#64748b'" size="21" /><view><text>{{ warehouse.warehouseName }}</text><text class="warehouse-menu-code">{{ warehouse.warehouseCode }}</text></view><uni-icons :type="String(warehouse.id) === String(selectedWarehouseId || '') ? 'checkmarkempty' : 'right'" color="#155eef" size="18" /></button></block>
|
||||
<button class="header-menu-close" @tap="closeMenu">关闭</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -28,18 +30,22 @@
|
||||
|
||||
export default {
|
||||
components: { PopupHeader },
|
||||
emits: ['menu-change'],
|
||||
emits: ['menu-change', 'warehouse-select'],
|
||||
props: {
|
||||
title: { type: String, required: true },
|
||||
user: { type: Object, required: true },
|
||||
menu: Boolean,
|
||||
warehouses: { type: Array, default: () => [] },
|
||||
selectedWarehouseId: { type: [String, Number], default: '' },
|
||||
warehouseLoading: Boolean,
|
||||
warehouseError: { type: String, default: '' },
|
||||
showProfile: { type: Boolean, default: true },
|
||||
fallbackUrl: { type: String, default: '/pages/home/home' }
|
||||
},
|
||||
methods: {
|
||||
goBack() { navigateBackOr(this.fallbackUrl) },
|
||||
goProfile() { this.closeMenu(); navigateToUnique('/pages/profile/profile') },
|
||||
goHistory() { this.closeMenu(); navigateToUnique('/pages/history/history') },
|
||||
chooseWarehouse(warehouse) { this.$emit('warehouse-select', warehouse); this.closeMenu() },
|
||||
openMenu() { this.$refs.menuPopup.open() },
|
||||
closeMenu() { if (this.$refs.menuPopup) this.$refs.menuPopup.close() },
|
||||
handleMenuChange(event) { this.$emit('menu-change', Boolean(event.show)) }
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const configuredApiRoot = import.meta.env.VITE_API_BASE_URL || ''
|
||||
let selectedApiRoot = configuredApiRoot || '/api'
|
||||
|
||||
// H5 必须请求同源地址,由 Vite 开发服务器代理到国内 Gateway,避免浏览器 CORS。
|
||||
// 前缀不能使用 /api,否则会截获本项目 api/ 目录下的前端模块请求。
|
||||
// #ifdef H5
|
||||
selectedApiRoot = import.meta.env.VITE_H5_API_BASE_URL || '/gateway-api'
|
||||
// #endif
|
||||
|
||||
export const API_ROOT = String(selectedApiRoot).replace(/\/$/, '')
|
||||
export const PLATFORM_API_ROOT = `${API_ROOT}/platform/v1`
|
||||
@@ -8,12 +8,13 @@ export const DEMO_ACCOUNTS = [
|
||||
|
||||
export const MODULES = [
|
||||
{ key: 'inbound', name: '入库', icon: '入', description: '收货、上架与质检', meta: '3 个静态页面', operations: [
|
||||
{ name: '一件代发', icon: '托', description: '扫描已由电脑端打印并贴好的 DP 托盘码', available: true, visible: true },
|
||||
{ name: '到货收货', icon: '收', description: '扫描到货单并确认实收数量' },
|
||||
{ name: '商品上架', icon: '上', description: '按推荐库位完成商品上架' },
|
||||
{ name: '入库质检', icon: '检', description: '登记质检结果与异常信息' }
|
||||
] },
|
||||
{ key: 'outbound', name: '出库', icon: '出', description: '拣货、复核与装箱', meta: '3 个待办', operations: [
|
||||
{ name: '集装箱拣货', icon: '拣', description: '按箱号扫描并确认拣货', available: true },
|
||||
{ name: '出库拣货', icon: '拣', description: '按箱号扫描并确认拣货', available: true, visible: true },
|
||||
{ name: '出库复核', icon: '核', description: '复核拣货商品与出库数量' },
|
||||
{ name: '装箱交接', icon: '装', description: '完成装箱并登记交接信息' }
|
||||
] },
|
||||
@@ -40,7 +41,7 @@ export const OPERATION_PROTOTYPES = {
|
||||
'库存调整': { subtitle: '登记差异原因并提交审核', scanLabel: '调整单 / 商品码', scanPlaceholder: '扫描调整单或 SKU', prefix: 'ADJ', titles: ['盘亏差异调整', '破损库存状态调整'], meta: '需要组长复核', location: '异常区 E-01', quantity: '3 SKU · 差异 8 件', action: '查看处理' },
|
||||
'商品查询': { subtitle: '查询商品库存、批次与所在库位', scanLabel: 'SKU / 条码', scanPlaceholder: '扫描商品条码或输入 SKU', prefix: 'SKU', titles: ['EL-38201 电容 100μF/25V', 'ME-55010 M8 不锈钢螺栓'], meta: '可用库存', location: 'A-03-12', quantity: '可用 1,280 件', action: '查看详情' },
|
||||
'库位查询': { subtitle: '查看库位容量、状态与在库商品', scanLabel: '库位码', scanPlaceholder: '扫描或输入库位编码', prefix: 'LOC', titles: ['A-03-12 标准货架位', 'B-01-08 重型货架位'], meta: '库位状态:正常', location: 'A 区 03 通道', quantity: '占用 72% · 4 SKU', action: '查看详情' },
|
||||
'任务查询': { subtitle: '按业务单号查询现场执行进度', scanLabel: '任务号 / 业务单号', scanPlaceholder: '扫描或输入任务、订单编号', prefix: 'TSK', titles: ['SO-78542 集装箱拣货', 'ASN-260728-018 到货收货'], meta: '今日现场任务', location: '仓库 A', quantity: '进度 6 / 8', action: '查看进度' }
|
||||
'任务查询': { subtitle: '按业务单号查询现场执行进度', scanLabel: '任务号 / 业务单号', scanPlaceholder: '扫描或输入任务、订单编号', prefix: 'TSK', titles: ['SO-78542 出库拣货', 'ASN-260728-018 到货收货'], meta: '今日现场任务', location: '仓库 A', quantity: '进度 6 / 8', action: '查看进度' }
|
||||
}
|
||||
|
||||
export const DEMO_PICKING_ITEMS = Array.from({ length: 10 }, (_, index) => {
|
||||
|
||||
@@ -18,3 +18,5 @@
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name" : "pda-uniapp",
|
||||
"name" : "柏悦物流 PDA",
|
||||
"appid" : "__UNI__9BFFAF2",
|
||||
"description" : "",
|
||||
"description" : "柏悦物流 PDA 作业端",
|
||||
"versionName" : "0.0.1",
|
||||
"versionCode" : 1,
|
||||
"transformPx" : false,
|
||||
@@ -16,7 +16,13 @@
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
"modules" : {},
|
||||
"modules" : {
|
||||
"Barcode" : {},
|
||||
"Bluetooth" : {},
|
||||
"Camera" : {},
|
||||
"Geolocation" : {},
|
||||
"UIWebview" : {}
|
||||
},
|
||||
/* 模块配置 */
|
||||
"distribute" : {
|
||||
/* 应用发布信息 */
|
||||
@@ -24,9 +30,49 @@
|
||||
/* android打包配置 */
|
||||
"permissions" : []
|
||||
},
|
||||
"ios" : {},
|
||||
"ios" : {
|
||||
"dSYMs" : false
|
||||
},
|
||||
/* ios打包配置 */
|
||||
"sdkConfigs" : {}
|
||||
"sdkConfigs" : {
|
||||
"geolocation" : {
|
||||
"system" : {
|
||||
"__platform__" : [ "ios", "android" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
"icons" : {
|
||||
"android" : {
|
||||
"hdpi" : "static/app-icons/72x72.png",
|
||||
"xhdpi" : "static/app-icons/96x96.png",
|
||||
"xxhdpi" : "static/app-icons/144x144.png",
|
||||
"xxxhdpi" : "static/app-icons/192x192.png"
|
||||
},
|
||||
"ios" : {
|
||||
"appstore" : "static/app-icons/1024x1024.png",
|
||||
"ipad" : {
|
||||
"app" : "static/app-icons/76x76.png",
|
||||
"app@2x" : "static/app-icons/152x152.png",
|
||||
"notification" : "static/app-icons/20x20.png",
|
||||
"notification@2x" : "static/app-icons/40x40.png",
|
||||
"proapp@2x" : "static/app-icons/167x167.png",
|
||||
"settings" : "static/app-icons/29x29.png",
|
||||
"settings@2x" : "static/app-icons/58x58.png",
|
||||
"spotlight" : "static/app-icons/40x40.png",
|
||||
"spotlight@2x" : "static/app-icons/80x80.png"
|
||||
},
|
||||
"iphone" : {
|
||||
"app@2x" : "static/app-icons/120x120.png",
|
||||
"app@3x" : "static/app-icons/180x180.png",
|
||||
"notification@2x" : "static/app-icons/40x40.png",
|
||||
"notification@3x" : "static/app-icons/60x60.png",
|
||||
"settings@2x" : "static/app-icons/58x58.png",
|
||||
"settings@3x" : "static/app-icons/87x87.png",
|
||||
"spotlight@2x" : "static/app-icons/80x80.png",
|
||||
"spotlight@3x" : "static/app-icons/120x120.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
/* SDK配置 */
|
||||
@@ -40,5 +86,13 @@
|
||||
},
|
||||
"usingComponents" : true
|
||||
},
|
||||
"vueVersion" : "3"
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
"vueVersion" : "3",
|
||||
"h5" : {
|
||||
"devServer" : {
|
||||
"port" : 5175
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
{ "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "仓库工作台", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/module/module", "style": { "navigationStyle": "custom", "navigationBarTitleText": "业务模块", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "现场作业", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/tasks/tasks", "style": { "navigationStyle": "custom", "navigationBarTitleText": "拣货任务", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/dropship-tasks/dropship-tasks", "style": { "navigationStyle": "custom", "navigationBarTitleText": "一件代发", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/dropship-palletization/dropship-palletization", "style": { "navigationStyle": "custom", "navigationBarTitleText": "一件代发入库", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/history/history", "style": { "navigationStyle": "custom", "navigationBarTitleText": "作业记录", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/profile/profile", "style": { "navigationStyle": "custom", "navigationBarTitleText": "个人中心", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/scanner-test/scanner-test", "style": { "navigationStyle": "custom", "navigationBarTitleText": "广播扫码测试", "backgroundColor": "#F5F7FA" } },
|
||||
{ "path": "pages/picking/picking", "style": { "navigationStyle": "custom", "navigationBarTitleText": "集装箱拣货", "backgroundColor": "#F5F7FA" } }
|
||||
{ "path": "pages/picking/picking", "style": { "navigationStyle": "custom", "navigationBarTitleText": "出库拣货", "backgroundColor": "#F5F7FA" } }
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "white",
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell palletization-page">
|
||||
<PortalHeader title="一件代发入库" :user="user" :fallback-url="'/pages/dropship-tasks/dropship-tasks'" />
|
||||
<view class="content">
|
||||
<view v-if="error" class="message error" role="alert">{{ error }}</view>
|
||||
<view v-if="notice" class="message success" role="status">{{ notice }}</view>
|
||||
<view v-if="loading && !task" class="state">正在加载入库任务…</view>
|
||||
<template v-if="task">
|
||||
<view class="task-summary-card">
|
||||
<view class="summary-head"><text class="inbound-code">{{ task.inboundCode || '—' }}</text><text class="status">{{ statusLabel }}</text></view>
|
||||
<text class="warehouse-summary">{{ task.warehouseName || '仓库未关联' }}</text>
|
||||
<view class="compact-metrics">
|
||||
<text><strong>{{ task.totalCartons || 0 }}</strong>总箱</text>
|
||||
<text><strong>{{ task.countedCartons || 0 }}</strong>清点</text>
|
||||
<text><strong>{{ task.boundCartons || 0 }}</strong>归托</text>
|
||||
<text><strong>{{ task.unboundCartons || 0 }}</strong>待归</text>
|
||||
<text class="pallet-readiness" :class="{ invalid: !palletSetupReady }"><strong>{{ task.generatedPalletCount || 0 }}/{{ task.expectedPalletCount || 0 }}</strong>托盘</text>
|
||||
</view>
|
||||
<view v-if="!palletSetupReady" class="message error pallet-setup-error" role="alert">{{ palletSetupMessage }}</view>
|
||||
<button v-if="task.taskStatus === 'PENDING'" class="primary-button" :disabled="submitting" @tap="claimTask">领取并开始入库</button>
|
||||
</view>
|
||||
|
||||
<view v-if="task.taskStatus === 'CLAIMED'" class="section-card">
|
||||
<text class="section-title">1. 扫箱清点</text>
|
||||
<template v-if="palletSetupReady">
|
||||
<ScanTargetInput v-model="boxCode" name="boxCode" label="箱码" placeholder="扫描或输入箱唛箱码" :active="activeScan === 'boxCode'" @activate="activeScan = 'boxCode'" @submit="resolveCarton" />
|
||||
<view v-if="!carton" class="count-entry">
|
||||
<text>请扫描箱码</text>
|
||||
<button class="compact-action" :disabled="!canResolveCarton" @tap="resolveCarton()">读取 SKU</button>
|
||||
</view>
|
||||
<view v-if="carton" class="carton-card">
|
||||
<view v-for="(line, index) in skuCounts" :key="line.inboundDetailId" class="sku-count-row">
|
||||
<view class="sku-copy"><text class="sku-label">SKU {{ index + 1 }}</text><text class="sku-code">{{ line.skuCode || '—' }}</text></view>
|
||||
<view v-if="!isResolvedPendingCarton" class="quantity-stepper">
|
||||
<button :aria-label="`减少 ${line.skuCode} 清点数量`" @tap="adjustQuantity(index, -1)">−</button>
|
||||
<input v-model="line.actualQuantity" type="number" :aria-label="`${line.skuCode} 清点数量`" />
|
||||
<button :aria-label="`增加 ${line.skuCode} 清点数量`" @tap="adjustQuantity(index, 1)">+</button>
|
||||
</view>
|
||||
<text v-else class="counted-quantity">已清点 {{ line.actualQuantity }} 件</text>
|
||||
<view v-if="!isResolvedPendingCarton && lineDiffers(line)" class="field stacked difference-field"><text>差异说明</text><textarea v-model="line.discrepancyRemark" maxlength="500" placeholder="数量不一致时必填" /></view>
|
||||
</view>
|
||||
<text v-if="assignableCurrentPallet" class="pallet-hint">归入 {{ assignableCurrentPallet.palletNo }}</text>
|
||||
<text v-else-if="currentPallet?.status === 'SEALED'" class="pallet-hint blocked">当前托盘已封托,请先解除封托或扫描其他托盘</text>
|
||||
<text v-else-if="isResolvedPendingCarton" class="pallet-hint">本箱已清点,请先扫描目标托盘</text>
|
||||
<button class="primary-button" :disabled="!canSubmitCarton || submitting" @tap="confirmCarton">{{ cartonActionLabel }}</button>
|
||||
</view>
|
||||
<view class="pallet-scan-block">
|
||||
<text class="field-label">扫托盘 <text>可选</text></text>
|
||||
<ScanTargetInput v-model="palletCode" name="palletCode" label="托盘码" placeholder="扫描或输入已生成的 DP 托盘码" :active="activeScan === 'palletCode'" @activate="activeScan = 'palletCode'" @submit="selectPallet" />
|
||||
</view>
|
||||
</template>
|
||||
<view v-else class="count-unavailable">托盘码未准备完成,当前不能开始箱码清点。</view>
|
||||
</view>
|
||||
<view v-if="task.taskStatus === 'CLAIMED' && task.totalCartons > 0 && task.boundCartons === task.totalCartons" class="continue-strip">
|
||||
<text>本票已全部归托。托盘未装满时先继续装其他票,实物装满后再封托。</text>
|
||||
<button class="secondary-button" @tap="continueOtherTask">继续装其他入仓单</button>
|
||||
</view>
|
||||
|
||||
<view class="section-card">
|
||||
<view class="section-heading"><view><text class="section-title">2. 托盘操作</text><text class="section-desc">直接扫描托盘码,只显示当前操作的托盘;待绑托箱扫描目标托盘后批量归托</text></view><button class="text-button" :disabled="loading" @tap="loadTask">刷新</button></view>
|
||||
<view v-if="currentPallet" class="pallet-card">
|
||||
<view class="pallet-head"><text class="pallet-code">{{ currentPallet.palletNo }}</text><text class="pallet-status" :class="currentPallet.status === 'SEALED' ? 'sealed' : ''">{{ currentPallet.status === 'SEALED' ? '已封托' : '当前托盘' }}</text></view>
|
||||
<text class="pallet-meta">{{ currentPallet.cartonCount || 0 }} 箱 · {{ currentPallet.skuCount || 0 }} SKU · {{ currentPallet.weightKg || 0 }} kg · {{ currentPallet.volumeCbm || 0 }} m³</text>
|
||||
<view v-for="item in (currentPalletDetails?.cartons || [])" :key="item.cartonId" class="carton-row"><text>{{ item.boxCode }}</text><button v-if="currentPallet.status === 'OPEN'" class="text-button" :disabled="submitting" @tap="moveCarton(item, null)">移回待绑托</button></view>
|
||||
<button v-if="currentPallet.palletId && currentPallet.status === 'OPEN'" class="primary-button" :disabled="!currentPallet.cartonCount || submitting" @tap="sealPallet(currentPallet)">封托</button>
|
||||
<button v-if="currentPallet.palletId && currentPallet.status === 'SEALED'" class="secondary-button unseal-button" :disabled="submitting" @tap="openUnseal(currentPallet)">解除封托</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
<uni-popup ref="unsealPopup" type="center" :is-mask-click="true" @change="handleUnsealPopupChange">
|
||||
<view class="unseal-dialog" role="dialog" aria-modal="true" aria-label="确认解除封托">
|
||||
<PopupHeader title="确认解除封托?" tone="danger" @close="closeUnseal" />
|
||||
<text class="unseal-copy">托盘 {{ unsealTarget?.palletNo || '—' }} 将恢复为开放状态,可继续移箱、归托和重新封托。</text>
|
||||
<view class="unseal-actions"><button class="secondary-button" @tap="closeUnseal">取消</button><button class="danger-button" :disabled="submitting" @tap="confirmUnseal">确认解除</button></view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import PopupHeader from '@/components/PopupHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, getDropshipPalletizationTask, moveDropshipCarton, resolveDropshipCarton, sealDropshipPallet, unsealDropshipPallet } from '@/api/pda-dropship.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { formatApiError } from '@/utils/user-facing.js'
|
||||
import { stableOperationId } from '@/utils/idempotency.js'
|
||||
import { isOnline } from '@/utils/network.js'
|
||||
import { clearDropshipPalletizingDraft, restoreDropshipPalletizingDraft, saveDropshipPalletizingDraft } from '@/stores/dropship-palletizing.js'
|
||||
import { createDropshipScanState, planDropshipScan, DROPSHIP_SCAN_INTENT } from '@/utils/dropship-palletizing-state.js'
|
||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||
|
||||
export default {
|
||||
components: { PopupHeader, PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, taskId: '', warehouseId: '', task: null, palletCode: '', selectedPalletId: '', selectedPalletNo: '', boxCode: '', carton: null, skuCounts: [], activeScan: 'boxCode', loading: false, submitting: false, error: '', notice: '', unsealOpen: false, unsealTarget: null } },
|
||||
computed: {
|
||||
statusLabel() { return this.task?.taskStatus === 'COMPLETED' ? '已完成' : (this.task?.taskStatus === 'CLAIMED' ? '作业中' : '待领取') },
|
||||
palletSetupReady() { return this.task?.palletSetupReady === true },
|
||||
palletSetupMessage() { return this.task?.palletSetupMessage || `预计 ${this.task?.expectedPalletCount || 0} 托,当前仅生成 ${this.task?.generatedPalletCount || 0} 个托盘码,请联系后台处理后刷新` },
|
||||
canResolveCarton() { return Boolean(this.palletSetupReady && this.task?.taskStatus === 'CLAIMED' && String(this.boxCode || '').trim() && !this.loading && !this.submitting) },
|
||||
canCount() { return Boolean(this.carton?.canCount && this.skuCounts.length && this.skuCounts.every((line) => Number.isInteger(Number(line.actualQuantity)) && Number(line.actualQuantity) >= 0 && (!this.lineDiffers(line) || String(line.discrepancyRemark || '').trim()))) },
|
||||
isResolvedPendingCarton() { return this.carton?.countStatus === 'COUNTED_UNBOUND' },
|
||||
canRebindResolvedCarton() { return Boolean(this.isResolvedPendingCarton && this.carton?.cartonId && this.assignableCurrentPallet?.palletNo) },
|
||||
canSubmitCarton() { return this.currentPallet?.status === 'SEALED' ? false : (this.isResolvedPendingCarton ? this.canRebindResolvedCarton : this.canCount) },
|
||||
cartonActionLabel() { return this.isResolvedPendingCarton ? '确认归入当前托盘' : '确认清点' },
|
||||
currentPalletDetails() { return (this.task?.pallets || []).find((item) => item.palletNo === this.selectedPalletNo) || null },
|
||||
currentPallet() { return this.currentPalletDetails || (this.selectedPalletNo ? { palletNo: this.selectedPalletNo, status: 'OPEN' } : null) },
|
||||
assignableCurrentPallet() { return this.currentPallet?.status === 'OPEN' ? this.currentPallet : null }
|
||||
},
|
||||
onLoad(options) { this.taskId = String(options.taskId || ''); this.warehouseId = String(options.warehouseId || '') },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; const draft = restoreDropshipPalletizingDraft(this.draftIdentity()); this.selectedPalletNo = draft?.activePalletNo || ''; this.startScanner(); this.loadTask() },
|
||||
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
|
||||
onBackPress() { if (!this.unsealOpen) return false; this.closeUnseal(); return true },
|
||||
methods: {
|
||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: () => { this.error = '扫码头连接失败,可使用输入框继续作业' } }) },
|
||||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||||
handleScan(value) { if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return } const plan = planDropshipScan(createDropshipScanState({ online: isOnline() }), value, { unboundCartons: this.task?.unboundCartons }); if ([DROPSHIP_SCAN_INTENT.BLOCKED, DROPSHIP_SCAN_INTENT.INVALID].includes(plan.intent)) { this.error = plan.message; return } const code = plan.palletNo || plan.boxCode; if (plan.palletNo) { this.palletCode = code; this.selectPallet(code) } else { this.boxCode = code; this.resolveCarton() } },
|
||||
resetMessages() { this.error = ''; this.notice = '' },
|
||||
async loadTask() {
|
||||
if (!this.taskId || !this.warehouseId) { this.error = '缺少合托任务或仓库信息,请返回任务列表'; return }
|
||||
this.loading = true; this.resetMessages()
|
||||
try { this.task = await getDropshipPalletizationTask(this.taskId, this.warehouseId); if (this.task.taskStatus === 'COMPLETED') clearDropshipPalletizingDraft(this.draftIdentity()) }
|
||||
catch (error) { this.error = formatApiError(error, '一件代发入库任务加载失败') }
|
||||
finally { this.loading = false }
|
||||
},
|
||||
async claimTask() { await this.runMutation(() => claimDropshipPalletizationTask(this.taskId, this.warehouseId), '任务领取成功') },
|
||||
async resolveCarton(value) {
|
||||
if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return }
|
||||
const code = String(value || this.boxCode || '').trim().toUpperCase(); if (!code || this.loading) return
|
||||
this.boxCode = code; this.loading = true; this.resetMessages(); this.carton = null
|
||||
try { this.carton = await resolveDropshipCarton(this.taskId, this.warehouseId, code); this.skuCounts = (this.carton.skuLines || []).map((line) => ({ ...line, actualQuantity: String(line.actualQuantity ?? line.expectedQuantity ?? ''), discrepancyRemark: line.discrepancyRemark || '' })) }
|
||||
catch (error) { this.error = formatApiError(error, '箱码解析失败') }
|
||||
finally { this.loading = false }
|
||||
},
|
||||
async countCarton() {
|
||||
const skuLines = this.skuCounts.map((line) => ({ inboundDetailId: line.inboundDetailId, skuCode: line.skuCode, actualQuantity: Number(line.actualQuantity), discrepancyAction: this.lineDiffers(line) ? 'ACCEPT_VARIANCE' : 'MATCH', discrepancyRemark: this.lineDiffers(line) ? String(line.discrepancyRemark || '').trim() : null }))
|
||||
const result = await this.runMutation(() => countDropshipCarton(this.taskId, { warehouseId: this.warehouseId, boxCode: this.carton.boxCode, palletNo: this.assignableCurrentPallet?.palletNo || null, skuLines }), this.assignableCurrentPallet ? '本箱已清点并归托' : '本箱已完成入库清点')
|
||||
if (result) { this.boxCode = ''; this.carton = null; this.skuCounts = [] }
|
||||
},
|
||||
async confirmCarton() {
|
||||
if (this.isResolvedPendingCarton) { await this.rebindResolvedCarton(); return }
|
||||
await this.countCarton()
|
||||
},
|
||||
async rebindResolvedCarton() {
|
||||
if (!this.canRebindResolvedCarton) return
|
||||
const targetPalletNo = this.assignableCurrentPallet.palletNo
|
||||
const payload = { warehouseId: this.warehouseId, targetPalletNo }
|
||||
payload.operationId = stableOperationId('dropship-move', String(this.user?.id) + '-' + this.taskId, { cartonId: this.carton.cartonId, taskVersion: this.task.version, ...payload })
|
||||
const result = await this.runMutation(() => moveDropshipCarton(this.taskId, this.carton.cartonId, payload), `本箱已归入 ${targetPalletNo}`)
|
||||
if (result) { this.boxCode = ''; this.carton = null; this.skuCounts = [] }
|
||||
},
|
||||
lineDiffers(line) { return Number(line.actualQuantity) !== Number(line.expectedQuantity) },
|
||||
adjustQuantity(index, step) { const line = this.skuCounts[index]; if (line) line.actualQuantity = String(Math.max(0, Number(line.actualQuantity || 0) + step)) },
|
||||
async selectPallet(value) {
|
||||
if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return }
|
||||
const code = String(value || this.palletCode || '').trim().toUpperCase(); const pallet = (this.task?.pallets || []).find((item) => item.palletNo === code)
|
||||
this.palletCode = code; this.selectedPalletId = pallet ? String(pallet.palletId) : ''; this.selectedPalletNo = code; this.activeScan = 'boxCode'; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: code })
|
||||
if (pallet?.status === 'SEALED') { this.notice = `托盘 ${code} 已封托,可在托盘操作区解除封托`; return }
|
||||
if (Number(this.task?.unboundCartons || 0) > 0) await this.runMutation(() => bindDropshipPendingCartons(this.taskId, this.warehouseId, code, this.task.version), `待归托箱已绑定至 ${code}`)
|
||||
else this.notice = pallet ? `当前托盘:${code}` : `已选择仓库开放托盘 ${code},清点箱码时将校验并归托`
|
||||
},
|
||||
draftIdentity() { return { operatorId: this.user?.id, taskId: this.taskId, warehouseId: this.warehouseId } },
|
||||
async moveCarton(carton, targetPalletNo) {
|
||||
if (this.submitting || !isOnline() || !this.palletSetupReady) return
|
||||
const payload = { warehouseId: this.warehouseId, targetPalletNo: targetPalletNo || null }
|
||||
payload.operationId = stableOperationId('dropship-move', String(this.user?.id) + '-' + this.taskId, { cartonId: carton.cartonId, taskVersion: this.task.version, ...payload })
|
||||
await this.runMutation(() => moveDropshipCarton(this.taskId, carton.cartonId, payload), targetPalletNo ? '箱码已换托' : '箱码已移回待绑托区')
|
||||
},
|
||||
continueOtherTask() { uni.redirectTo({ url: `/pages/dropship-tasks/dropship-tasks?warehouseId=${encodeURIComponent(this.warehouseId)}` }) },
|
||||
async sealPallet(pallet) { await this.runMutation(() => sealDropshipPallet(this.taskId, this.warehouseId, pallet), `${pallet.palletNo} 已封托`) },
|
||||
openUnseal(pallet) { this.unsealTarget = pallet; this.unsealOpen = true; this.$refs.unsealPopup.open() },
|
||||
closeUnseal() { this.unsealOpen = false; this.$refs.unsealPopup?.close(); this.unsealTarget = null },
|
||||
handleUnsealPopupChange(event) { this.unsealOpen = Boolean(event.show); if (!event.show) this.unsealTarget = null },
|
||||
async confirmUnseal() {
|
||||
const pallet = this.unsealTarget; if (!pallet || this.submitting) return
|
||||
this.$refs.unsealPopup.close(); this.unsealOpen = false
|
||||
const result = await this.runMutation(() => unsealDropshipPallet(this.taskId, this.warehouseId, pallet), `${pallet.palletNo} 已解除封托`)
|
||||
this.unsealTarget = null
|
||||
if (result) { this.selectedPalletNo = pallet.palletNo; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: pallet.palletNo }) }
|
||||
},
|
||||
async runMutation(action, message) {
|
||||
if (this.submitting) return null
|
||||
if (!isOnline()) { this.error = '当前网络不可用,请联网后刷新'; return null }
|
||||
this.submitting = true; this.resetMessages()
|
||||
try {
|
||||
const result = await action()
|
||||
this.task = result?.taskId && Array.isArray(result?.pallets)
|
||||
? result
|
||||
: await getDropshipPalletizationTask(this.taskId, this.warehouseId)
|
||||
this.notice = message
|
||||
return result
|
||||
}
|
||||
catch (error) { this.error = formatApiError(error, '操作失败'); return null }
|
||||
finally { this.submitting = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 0 0 32px; background: #fff; }
|
||||
.state, .message { margin-bottom: 10px; padding: 12px; border-radius: 9px; color: #667085; font-size: 12px; line-height: 18px; text-align: center; background: #fff; }
|
||||
.message.error { color: #b42318; background: #fff1f0; }.message.success { color: #067647; background: #ecfdf3; }
|
||||
.task-summary-card, .section-card { margin: 0; padding: 11px 12px; border: 0; border-bottom: 6px solid #eef2f7; border-radius: 0; background: #fff; box-shadow: none; }
|
||||
.continue-strip { padding: 10px 12px 12px; border-bottom: 6px solid #eef2f7; color: #475467; font-size: 13px; line-height: 19px; background: #f8fbff; }.continue-strip .secondary-button { margin-top: 7px; }
|
||||
.task-summary-card { display: block; }
|
||||
.summary-head, .section-heading, .pallet-head, .carton-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.inbound-code { min-width: 0; color: #12233f; font-size: 17px; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.status, .pallet-status { flex-shrink: 0; padding: 4px 8px; border-radius: 99px; color: #155eef; font-size: 12px; background: #eff4ff; }.pallet-status.sealed { color: #067647; background: #ecfdf3; }
|
||||
.warehouse-summary { display: block; margin-top: 3px; color: #667085; font-size: 12px; }
|
||||
.compact-metrics { display: grid; grid-template-columns: repeat(5,1fr); margin-top: 8px; padding-top: 7px; border-top: 1px solid #edf1f5; }.compact-metrics > text { display: flex; align-items: center; justify-content: center; gap: 3px; color: #667085; font-size: 11px; white-space: nowrap; }.compact-metrics strong { color: #12233f; font-size: 15px; }.compact-metrics .pallet-readiness { color: #067647; }.compact-metrics .pallet-readiness.invalid,.compact-metrics .pallet-readiness.invalid strong { color: #b42318; }.pallet-setup-error { margin-top: 8px; margin-bottom: 0; text-align: left; }
|
||||
.section-title { display: block; color: #12233f; font-size: 18px; font-weight: 700; }.section-desc { display: block; margin: 2px 0 8px; color: #667085; font-size: 13px; line-height: 18px; }.section-heading > view { min-width: 0; }
|
||||
.primary-button, .secondary-button { box-sizing: border-box; width: 100%; height: 46px; margin-top: 10px; border-radius: 9px; font-size: 14px; font-weight: 700; line-height: 44px; }.primary-button { border: 0; color: #fff; background: #1a73e8; }.secondary-button { border: 1px solid #8ab8ef; color: #155eef; background: #f6f9ff; }.primary-button[disabled],.secondary-button[disabled] { opacity: .45; }
|
||||
.unseal-button { border-color: #fda29b; color: #b42318; background: #fff5f4; }.pallet-hint.blocked { color: #b42318; }
|
||||
.unseal-dialog { box-sizing: border-box; width: min(350px, calc(100vw - 32px)); overflow: hidden; border-radius: 12px; background: #fff; }.unseal-copy { display: block; padding: 18px 16px; color: #475467; font-size: 14px; line-height: 21px; }.unseal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 16px 16px; }.unseal-actions .secondary-button { margin-top: 0; }.danger-button { height: 46px; margin: 0; border: 0; border-radius: 9px; color: #fff; font-size: 14px; font-weight: 700; background: #d92d20; }.danger-button[disabled] { opacity: .45; }
|
||||
.count-entry { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; padding: 5px 7px; border: 0; border-radius: 0; color: #667085; font-size: 13px; background: #f6f9ff; }.compact-action { width: 86px; height: 34px; margin: 0; border: 0; border-radius: 6px; color: #fff; font-size: 13px; line-height: 34px; background: #1a73e8; }.compact-action[disabled] { opacity: .35; }.count-unavailable { padding: 12px 8px; color: #b42318; font-size: 13px; line-height: 18px; text-align: center; background: #fff1f0; }.pallet-scan-block { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e6edf5; }.field-label { display: block; margin-bottom: 5px; color: #344054; font-size: 15px; font-weight: 700; }.field-label text { margin-left: 3px; color: #98a2b3; font-size: 12px; font-weight: 400; }
|
||||
.carton-card, .pallet-card { margin-top: 8px; padding: 8px 0 0; border: 0; border-top: 1px solid #e3eaf2; border-radius: 0; background: transparent; }.sku-count-row { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #edf1f5; }.sku-copy { min-width: 0; }.sku-label { display: block; color: #667085; font-size: 11px; }.sku-code { display: block; margin-top: 2px; color: #12233f; font-size: 16px; font-weight: 700; overflow-wrap: anywhere; }.pallet-meta { display: block; margin-top: 5px; color: #667085; font-size: 14px; line-height: 20px; }
|
||||
.quantity-stepper { display: grid; grid-template-columns: 38px 56px 38px; overflow: hidden; border: 1px solid #b8c6d8; border-radius: 8px; background: #fff; }.quantity-stepper button,.quantity-stepper input { box-sizing: border-box; height: 40px; margin: 0; border: 0; border-radius: 0; text-align: center; background: #fff; }.quantity-stepper button { color: #155eef; font-size: 20px; line-height: 40px; }.quantity-stepper input { width: 56px; border-right: 1px solid #dce4ed; border-left: 1px solid #dce4ed; color: #12233f; font-size: 15px; }.difference-field { grid-column: 1 / -1; margin-top: 0; }.pallet-hint { display: block; margin-top: 10px; color: #155eef; font-size: 11px; text-align: center; }
|
||||
.counted-quantity { flex: 0 0 auto; color: #067647; font-size: 14px; font-weight: 700; }
|
||||
.field { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; color: #344054; font-size: 12px; }.field input { box-sizing: border-box; width: 120px; height: 42px; padding: 0 10px; border: 1px solid #cfd8e3; border-radius: 8px; background: #fff; }.field.stacked { align-items: stretch; flex-direction: column; gap: 6px; }.field textarea { box-sizing: border-box; width: 100%; height: 70px; padding: 9px; border: 1px solid #cfd8e3; border-radius: 8px; background: #fff; }
|
||||
.pallet-code { color: #12233f; font-size: 19px; font-weight: 700; overflow-wrap: anywhere; }.text-button { min-width: 58px; height: 40px; border: 0; color: #155eef; font-size: 14px; line-height: 40px; background: transparent; }.empty { padding: 18px 0 8px; color: #98a2b3; font-size: 14px; text-align: center; }
|
||||
.palletization-page :deep(.scan-target-value) { font-size: 17px; }
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell dropship-task-page">
|
||||
<PortalHeader title="一件代发" :user="user" :fallback-url="'/pages/home/home'" />
|
||||
<view class="content">
|
||||
<view class="search-card">
|
||||
<ScanTargetInput v-model="keyword" name="taskKeyword" label="入仓码查询" placeholder="扫描或输入入仓码" :active="true" @submit="loadTasks" />
|
||||
<button class="primary-button search-button" :disabled="loading" @tap="loadTasks">{{ loading ? '查询中…' : '查询任务' }}</button>
|
||||
</view>
|
||||
<view class="heading-row">
|
||||
<view><text class="heading">待入库任务</text><text class="summary">{{ loading ? '正在同步' : `共 ${tasks.length} 个任务` }}</text></view>
|
||||
<button class="refresh-button" :disabled="loading" @tap="refresh">刷新</button>
|
||||
</view>
|
||||
<view v-if="error" class="state error" role="alert">{{ error }}</view>
|
||||
<view v-else-if="!loading && !tasks.length" class="state">暂无待入库任务</view>
|
||||
<view class="task-list">
|
||||
<button v-for="task in tasks" :key="task.taskId" class="task-card" :aria-label="`进入一件代发入库任务,入仓码 ${task.inboundCode || '未关联'}`" @tap="openTask(task)">
|
||||
<view class="task-head"><text class="business-code">{{ task.inboundCode || '入仓码 —' }}</text><text class="status" :class="task.taskStatus === 'CLAIMED' ? 'claimed' : ''">{{ statusLabel(task.taskStatus) }}</text></view>
|
||||
<text class="customer">{{ task.customerName || '客户未关联' }}</text>
|
||||
<view class="metrics">
|
||||
<view><strong>{{ task.totalCartons || 0 }}</strong><text>总箱</text></view>
|
||||
<view><strong>{{ task.countedCartons || 0 }}</strong><text>已清点</text></view>
|
||||
<view><strong>{{ task.boundCartons || 0 }}</strong><text>已归托</text></view>
|
||||
<view><strong>{{ task.unboundCartons || 0 }}</strong><text>待归托</text></view>
|
||||
</view>
|
||||
<view class="task-foot"><text>仓库 {{ task.warehouseName || '—' }}</text><text class="enter">进入作业 ›</text></view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||
import { listDropshipPalletizationTasks } from '@/api/pda-dropship.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { formatApiError } from '@/utils/user-facing.js'
|
||||
|
||||
export default {
|
||||
components: { PortalHeader, ScanTargetInput },
|
||||
data() { return { user: session.currentUser, keyword: '', tasks: [], loading: false, error: '' } },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() },
|
||||
methods: {
|
||||
statusLabel(status) { return status === 'CLAIMED' ? '作业中' : '待领取' },
|
||||
refresh() { this.keyword = ''; this.loadTasks() },
|
||||
async loadTasks() {
|
||||
if (this.loading) return
|
||||
if (!this.user?.warehouseId) { this.error = '请返回首页选择作业仓库'; return }
|
||||
this.loading = true; this.error = ''
|
||||
try {
|
||||
const records = await listDropshipPalletizationTasks(this.keyword, this.user.warehouseId)
|
||||
this.tasks = Array.isArray(records) ? records : []
|
||||
}
|
||||
catch (error) { this.tasks = []; this.error = formatApiError(error, '一件代发入库任务加载失败') }
|
||||
finally { this.loading = false }
|
||||
},
|
||||
openTask(task) { uni.navigateTo({ url: `/pages/dropship-palletization/dropship-palletization?taskId=${encodeURIComponent(task.taskId)}&warehouseId=${encodeURIComponent(task.warehouseId)}` }) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 8px 10px 32px; }
|
||||
.search-card { display: flex; align-items: center; gap: 8px; padding: 0; border: 0; background: transparent; box-shadow: none; }
|
||||
.search-card :deep(.scan-target-input) { min-width: 0; height: 44px; flex: 1; border-radius: 6px; }
|
||||
.search-card :deep(.scan-target-value) { height: 42px; }
|
||||
.search-card :deep(.scan-target-icon) { top: 10px; }
|
||||
.search-card :deep(.scan-target-input-active .scan-target-icon) { top: 9px; }
|
||||
.search-card :deep(.scan-target-badge) { top: 10px; }
|
||||
.primary-button { height: 44px; border: 0; border-radius: 6px; color: #fff; font-size: 14px; font-weight: 700; line-height: 44px; background: #1a73e8; }
|
||||
.search-button { width: 92px; margin: 0; flex-shrink: 0; }
|
||||
.heading-row { display: flex; align-items: center; justify-content: space-between; margin: 10px 0 7px; }
|
||||
.heading-row > view { display: flex; align-items: baseline; gap: 8px; }
|
||||
.heading { color: #12233f; font-size: 17px; font-weight: 700; }
|
||||
.summary { color: #667085; font-size: 11px; }
|
||||
.refresh-button { min-width: 64px; height: 40px; border: 0; border-radius: 6px; color: #155eef; font-size: 13px; line-height: 40px; background: #eaf2ff; }
|
||||
.state { padding: 24px 12px; border-radius: 10px; color: #667085; font-size: 13px; text-align: center; background: #fff; }
|
||||
.state.error { color: #b42318; background: #fff1f0; }
|
||||
.task-list { overflow: hidden; border: 1px solid #dce6f1; border-radius: 7px; background: #fff; }
|
||||
.task-card { box-sizing: border-box; width: 100%; margin: 0; padding: 9px 11px 7px; border: 0; border-bottom: 1px solid #dce6f1; border-radius: 0; color: #12233f; text-align: left; background: #fff; box-shadow: none; }
|
||||
.task-card:last-child { border-bottom: 0; }
|
||||
.task-card:active { background: #f5f8fc; }
|
||||
.task-head, .task-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.business-code { min-width: 0; font-size: 16px; font-weight: 700; line-height: 22px; overflow-wrap: anywhere; }
|
||||
.status { flex-shrink: 0; padding: 2px 7px; border-radius: 4px; color: #9a5b13; font-size: 10px; line-height: 18px; background: #fff4e5; }
|
||||
.status.claimed { color: #155eef; background: #eff4ff; }
|
||||
.customer { display: block; margin-top: 1px; color: #667085; font-size: 11px; line-height: 17px; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); margin-top: 6px; padding: 6px 0; border-top: 1px solid #edf1f5; border-bottom: 1px solid #edf1f5; }
|
||||
.metrics view { display: flex; align-items: center; flex-direction: column; border-right: 1px solid #edf1f5; }
|
||||
.metrics view:last-child { border-right: 0; }
|
||||
.metrics strong { font-size: 16px; line-height: 22px; }
|
||||
.metrics text { color: #667085; font-size: 10px; line-height: 16px; }
|
||||
.task-foot { margin-top: 3px; color: #667085; font-size: 11px; line-height: 30px; }
|
||||
.enter { color: #155eef; font-size: 12px; font-weight: 700; }
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page"><PortalHeader title="作业记录" :user="user" /><view class="portal-content"><view class="summary-card"><view><text class="summary-number">{{ overview.completedCount }}</text><text class="summary-label">今日完成</text></view><view><text class="summary-number pending-color">{{ overview.pendingBoxCount }}</text><text class="summary-label">待处理</text></view></view><text class="group-title">最近作业</text><view v-if="completedEntries.length === 0" class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">暂无作业记录</text><text class="history-empty-desc">完成的现场任务会显示在这里</text></view><view v-else class="operation-list"><view v-for="entry in completedEntries" :key="entry.box.boxNo" class="history-item"><view class="history-check">✓</view><view class="operation-info"><text class="operation-name">集装箱拣货</text><text class="operation-desc">{{ entry.box.boxNo }} · {{ entry.box.orderNo }}</text></view><text class="history-status">已完成</text></view></view></view></view>
|
||||
<view v-if="user" class="page-shell portal-page"><PortalHeader title="作业记录" :user="user" /><view class="portal-content"><view class="summary-card"><view><text class="summary-number">{{ overview.completedCount }}</text><text class="summary-label">今日完成</text></view><view><text class="summary-number pending-color">{{ overview.pendingBoxCount }}</text><text class="summary-label">待处理</text></view></view><text class="group-title">最近作业</text><view v-if="completedEntries.length === 0" class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">暂无作业记录</text><text class="history-empty-desc">完成的现场任务会显示在这里</text></view><view v-else class="operation-list"><view v-for="entry in completedEntries" :key="entry.box.taskId || entry.index" class="history-item"><view class="history-check">✓</view><view class="operation-info"><text class="operation-name">出库拣货 · {{ containerReference(entry.box) }}</text></view><text class="history-status">已完成</text></view></view></view></view>
|
||||
</template>
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'; import { session, requireSession } from '@/stores/session.js'; import { getPickingOverview } from '@/stores/picking.js'
|
||||
export default { components: { PortalHeader }, data() { return { user: session.currentUser, overview: getPickingOverview() } }, computed: { completedEntries() { return this.overview.boxes.map((box, index) => ({ box, index })).filter((entry) => this.overview.completedBoxes[entry.index]) } }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() } }
|
||||
import PortalHeader from '@/components/PortalHeader.vue'; import { session, requireSession } from '@/stores/session.js'; import { getPickingOverview } from '@/stores/picking.js'; import { containerReference } from '@/utils/user-facing.js'
|
||||
export default { components: { PortalHeader }, data() { return { user: session.currentUser, overview: getPickingOverview() } }, computed: { completedEntries() { return this.overview.boxes.map((box, index) => ({ box, index })).filter((entry) => this.overview.completedBoxes[entry.index]) } }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() }, methods: { containerReference } }
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page home-page">
|
||||
<PortalHeader ref="portalHeader" title="首页" :user="user" menu @menu-change="menuOpen = $event" />
|
||||
<PortalHeader ref="portalHeader" :title="user.warehouse ? `首页 · ${user.warehouse}` : '首页'" :user="user" menu :warehouses="warehouses" :selected-warehouse-id="user.warehouseId" :warehouse-loading="warehouseLoading" :warehouse-error="warehouseError" @warehouse-select="changeWarehouse" @menu-change="menuOpen = $event" />
|
||||
<view class="home-menu">
|
||||
<view v-for="group in groups" :key="group.key" class="home-group" :class="{ 'home-group-disabled': !group.available }">
|
||||
<view class="home-group-title"><view class="home-group-mark" :class="`home-group-mark-${group.key}`"></view><text>{{ group.name }}</text></view>
|
||||
@@ -8,8 +8,8 @@
|
||||
<button v-for="operation in group.operations" :key="operation.name" class="home-operation" :class="{ 'home-operation-primary': operation.available, 'home-operation-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)">
|
||||
<view class="home-operation-icon" aria-hidden="true"><uni-icons :type="operation.iconType" :color="operation.available ? '#135ee8' : '#9aa6b2'" size="28" /></view>
|
||||
<text class="home-operation-name">{{ operation.name }}</text>
|
||||
<text v-if="operation.available" class="home-operation-pending">{{ overview.pendingBoxCount }} 箱待拣</text>
|
||||
<text v-else class="home-operation-unavailable">未开放</text>
|
||||
<text v-if="operation.available" class="home-operation-meta home-operation-pending">开始作业</text>
|
||||
<text v-else class="home-operation-meta home-operation-unavailable">未开放</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -20,12 +20,13 @@
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import { MODULES } from '@/data/demo-data.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { getPickingOverview } from '@/stores/picking.js'
|
||||
import { listPdaWarehouses } from '@/api/pda-loading.js'
|
||||
import { session, requireSession, selectWarehouse } from '@/stores/session.js'
|
||||
|
||||
const ICON_TYPES = {
|
||||
'一件代发清点组托': 'download',
|
||||
'到货收货': 'download', '商品上架': 'upload', '入库质检': 'checkbox',
|
||||
'集装箱拣货': 'cart', '出库复核': 'compose', '装箱交接': 'paperplane',
|
||||
'一件代发': 'map', '出库拣货': 'cart', '出库复核': 'compose', '装箱交接': 'paperplane',
|
||||
'库存盘点': 'list', '商品移库': 'loop', '库存调整': 'compose',
|
||||
'商品查询': 'search', '库位查询': 'location', '任务查询': 'flag'
|
||||
}
|
||||
@@ -34,20 +35,41 @@
|
||||
...module,
|
||||
name: module.key === 'inventory' ? '库内作业' : module.name,
|
||||
available: module.operations.some((operation) => operation.available),
|
||||
operations: module.operations.map((operation) => ({ ...operation, iconType: ICON_TYPES[operation.name] || 'circle' }))
|
||||
}))
|
||||
operations: module.operations.filter((operation) => operation.visible)
|
||||
.map((operation) => ({ ...operation, iconType: ICON_TYPES[operation.name] || 'circle' }))
|
||||
})).filter((module) => module.operations.length)
|
||||
|
||||
export default {
|
||||
components: { PortalHeader },
|
||||
data() { return { user: session.currentUser, groups: HOME_GROUPS, overview: getPickingOverview(), menuOpen: false } },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() },
|
||||
data() { return { user: session.currentUser, groups: HOME_GROUPS, menuOpen: false, warehouses: [], warehouseLoading: false, warehouseError: '' } },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadWarehouses() },
|
||||
onBackPress() {
|
||||
if (!this.menuOpen) return false
|
||||
this.$refs.portalHeader.closeMenu()
|
||||
return true
|
||||
},
|
||||
methods: {
|
||||
openOperation(operation) { if (operation.available) uni.navigateTo({ url: '/pages/picking/picking' }) }
|
||||
async loadWarehouses() {
|
||||
if (this.warehouseLoading) return
|
||||
this.warehouseLoading = true; this.warehouseError = ''
|
||||
try {
|
||||
this.warehouses = await listPdaWarehouses()
|
||||
if (!this.user.warehouseId) this.$nextTick(() => this.$refs.portalHeader.openMenu())
|
||||
} catch (error) {
|
||||
this.warehouseError = error.message || '仓库加载失败,请稍后重试'
|
||||
} finally { this.warehouseLoading = false }
|
||||
},
|
||||
changeWarehouse(warehouse) { this.user = selectWarehouse(warehouse) },
|
||||
openOperation(operation) {
|
||||
if (!operation.available) return
|
||||
if (!this.user.warehouseId) { this.$refs.portalHeader.openMenu(); return }
|
||||
const routes = {
|
||||
'一件代发': '/pages/dropship-tasks/dropship-tasks',
|
||||
'出库拣货': '/pages/tasks/tasks',
|
||||
'库位查询': `/pages/operation/operation?module=query&name=${encodeURIComponent('库位查询')}`
|
||||
}
|
||||
uni.navigateTo({ url: routes[operation.name] })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,38 +1,66 @@
|
||||
<template>
|
||||
<view class="page-shell login-page">
|
||||
<view class="login-brand">
|
||||
<view class="login-logo-icon" aria-hidden="true"><view class="logo-grid-line logo-grid-line-horizontal"></view><view class="logo-grid-line logo-grid-line-vertical"></view></view>
|
||||
<image class="login-logo-icon" src="/static/brand-mark-white.png" mode="aspectFit" aria-hidden="true" />
|
||||
<text class="login-logo-title">柏悦物流 · PDA作业端</text><text class="login-logo-sub">仓库现场作业系统</text>
|
||||
</view>
|
||||
<view class="login-card">
|
||||
<view class="login-field"><text class="login-field-label">工号 / 用户名</text><input v-model="form.userId" class="login-field-input" aria-label="工号 / 用户名" placeholder="请输入工号" confirm-type="next" @focus="error = ''" /></view>
|
||||
<view class="login-field"><text class="login-field-label">密码</text><input v-model="form.password" class="login-field-input" aria-label="密码" type="password" placeholder="请输入密码" confirm-type="done" @focus="error = ''" @confirm="login" /></view>
|
||||
<view class="login-field"><text class="login-field-label">作业仓库</text><picker aria-label="作业仓库" :range="warehouses" :value="warehouseIndex" @change="changeWarehouse"><view class="login-field-select"><text>{{ form.warehouse }}</text><text class="select-chevron">⌄</text></view></picker></view>
|
||||
<view class="login-remember-row" @tap="toggleRememberAccount"><text class="login-remember-label">记住账号</text><switch :checked="rememberAccount" color="#1A73E8" aria-label="记住账号" @change="handleRememberChange" @tap.stop /></view>
|
||||
<text v-if="error" class="login-error" role="alert">{{ error }}</text>
|
||||
<button class="login-btn" @tap="login">登录</button>
|
||||
<view class="login-accounts"><text class="login-accounts-title">快捷登录(演示账号)</text><view class="login-quick-btns"><button v-for="account in accounts" :key="account.id" class="login-quick-btn" @tap="enter(account)"><text class="login-quick-btn-name">{{ account.id }} {{ account.name }}</text><text class="login-quick-btn-role"> · {{ account.role }}</text></button></view></view>
|
||||
<button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? '登录中...' : '登录' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DEMO_ACCOUNTS, WAREHOUSES } from '@/data/demo-data.js'
|
||||
import { session, signIn } from '@/stores/session.js'
|
||||
import { login as loginRequest } from '@/api/auth.js'
|
||||
import { ensureAccessToken } from '@/api/http.js'
|
||||
import { loadLoginPreference, saveLoginPreference, session, restoreSession, signInWithTokens } from '@/stores/session.js'
|
||||
import { requireOnline } from '@/utils/network.js'
|
||||
import { formatApiError } from '@/utils/user-facing.js'
|
||||
export default {
|
||||
data() { return { accounts: DEMO_ACCOUNTS, warehouses: WAREHOUSES, form: { userId: '', password: '', warehouse: WAREHOUSES[0] }, error: '' } },
|
||||
computed: { warehouseIndex() { return Math.max(0, this.warehouses.indexOf(this.form.warehouse)) } },
|
||||
onLoad() { if (session.currentUser) uni.reLaunch({ url: '/pages/home/home' }) },
|
||||
data() { return { form: { userId: '', password: '' }, rememberAccount: true, error: '', submitting: false } },
|
||||
async onLoad() {
|
||||
const preference = loadLoginPreference()
|
||||
this.rememberAccount = preference.rememberAccount
|
||||
if (preference.account) this.form.userId = preference.account
|
||||
if (!session.currentUser && !restoreSession()) return
|
||||
try {
|
||||
await ensureAccessToken()
|
||||
uni.reLaunch({ url: '/pages/home/home' })
|
||||
} catch (error) {
|
||||
if (error.network) this.error = '当前网络不可用,暂时无法恢复登录状态'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
changeWarehouse(event) { this.form.warehouse = this.warehouses[Number(event.detail.value)] || this.warehouses[0] },
|
||||
login() {
|
||||
handleRememberChange(event) {
|
||||
this.rememberAccount = Boolean(event.detail.value)
|
||||
if (!this.rememberAccount) saveLoginPreference(false)
|
||||
},
|
||||
toggleRememberAccount() {
|
||||
this.rememberAccount = !this.rememberAccount
|
||||
if (!this.rememberAccount) saveLoginPreference(false)
|
||||
},
|
||||
async login() {
|
||||
const userId = this.form.userId.trim()
|
||||
if (!userId) return void (this.error = '请输入工号')
|
||||
if (!this.form.password) return void (this.error = '请输入密码')
|
||||
const account = this.accounts.find((entry) => entry.id === userId)
|
||||
if (!account || account.password !== this.form.password) return void (this.error = '工号或密码错误')
|
||||
this.enter(account)
|
||||
},
|
||||
enter(account) { signIn(account, this.form.warehouse); uni.reLaunch({ url: '/pages/home/home' }) }
|
||||
this.submitting = true
|
||||
this.error = ''
|
||||
try {
|
||||
requireOnline('当前网络不可用,无法登录')
|
||||
const tokens = await loginRequest(userId, this.form.password)
|
||||
signInWithTokens(tokens)
|
||||
saveLoginPreference(this.rememberAccount, userId)
|
||||
uni.reLaunch({ url: '/pages/home/home' })
|
||||
} catch (error) {
|
||||
this.error = error.network ? '当前网络不可用,请联网后重试' : formatApiError(error, '登录失败')
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<view class="module-hero" :class="`module-hero-${currentModule.key}`"><view class="module-hero-icon">{{ currentModule.icon }}</view><view><text class="module-hero-title">{{ currentModule.name }}</text><text class="module-hero-desc">{{ currentModule.description }}</text></view></view>
|
||||
<text class="group-title">业务功能</text>
|
||||
<view class="operation-list"><button v-for="operation in currentModule.operations" :key="operation.name" class="operation-item" :class="{ 'operation-item-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)"><view class="operation-icon">{{ operation.icon }}</view><view class="operation-info"><text class="operation-name">{{ operation.name }}</text><text class="operation-desc">{{ operation.description }}</text></view><text v-if="operation.available" class="operation-count">{{ overview.pendingBoxCount }}</text><text v-else class="operation-prototype">未开放</text><text v-if="operation.available" class="operation-arrow">›</text></button></view>
|
||||
<view class="module-empty-note">第一版本仅开放“出库 → 集装箱拣货”,其他业务将在后续版本接入。</view>
|
||||
<view class="module-empty-note">当前展示“一件代发”和“出库拣货”。</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,45 @@
|
||||
<template>
|
||||
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">{{ user.role }} · {{ user.id }}</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">08:00–17:00</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">0.0.1</text></view></view><button class="profile-action" @tap="showSettings">系统设置 <text>›</text></button><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
|
||||
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">{{ user.role }} · {{ user.id }}</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">08:00–17:00</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">{{ appVersion }}</text></view><button class="profile-row profile-row-action" @tap="checkUpdate"><text>检查更新</text><text class="profile-row-arrow">›</text></button><button class="profile-row profile-row-action" @tap="showSettings"><text>系统设置</text><text class="profile-row-arrow">›</text></button></view><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
|
||||
</template>
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'; import PopupHeader from '@/components/PopupHeader.vue'; import { session, requireSession, signOut } from '@/stores/session.js'
|
||||
export default { components: { PopupHeader, PortalHeader }, data() { return { user: session.currentUser, logoutOpen: false, toast: false } }, onShow() { if (!requireSession()) return; this.user = session.currentUser }, beforeUnmount() { clearTimeout(this._toastTimer) }, onBackPress() { if (!this.logoutOpen) return false; this.closeLogout(); return true }, methods: { openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) }, showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) }, logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) } } }
|
||||
import { logout as logoutRequest } from '@/api/auth.js'
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import PopupHeader from '@/components/PopupHeader.vue'
|
||||
import { session, requireSession, signOut } from '@/stores/session.js'
|
||||
import { checkForAppUpdate } from '@/utils/app-update.js'
|
||||
|
||||
export default {
|
||||
components: { PopupHeader, PortalHeader },
|
||||
data() { return { user: session.currentUser, appVersion: '0.0.1', logoutOpen: false, toast: false } },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadAppVersion() },
|
||||
beforeUnmount() { clearTimeout(this._toastTimer) },
|
||||
onBackPress() { if (!this.logoutOpen) return false; this.closeLogout(); return true },
|
||||
methods: {
|
||||
loadAppVersion() {
|
||||
// #ifdef APP-PLUS
|
||||
this.appVersion = String(plus.runtime.version || this.appVersion)
|
||||
// #endif
|
||||
},
|
||||
checkUpdate() {
|
||||
// #ifdef APP-PLUS
|
||||
checkForAppUpdate({ manual: true })
|
||||
return
|
||||
// #endif
|
||||
// #ifndef APP-PLUS
|
||||
uni.showToast({ title: '请在 PDA App 中检查更新', icon: 'none' })
|
||||
// #endif
|
||||
},
|
||||
openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() },
|
||||
closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() },
|
||||
handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
|
||||
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
|
||||
logout() {
|
||||
const refreshToken = session.refreshToken
|
||||
const accessToken = session.token
|
||||
signOut()
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
if (refreshToken) logoutRequest(refreshToken, accessToken).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<view class="page-shell task-page">
|
||||
<PortalHeader title="拣货任务" :user="user" :fallback-url="'/pages/home/home'" />
|
||||
<view class="task-content">
|
||||
<view class="task-toolbar">
|
||||
<view class="task-heading">
|
||||
<text class="task-title">待处理任务</text>
|
||||
<text class="task-summary">{{ loading ? '正在同步任务' : `共 ${tasks.length} 个待处理任务` }}</text>
|
||||
</view>
|
||||
<button class="task-refresh" :disabled="loading" @tap="loadTasks">{{ loading ? '刷新中' : '刷新' }}</button>
|
||||
</view>
|
||||
<view v-if="error" class="task-error" role="alert">{{ error }}</view>
|
||||
<view v-if="loading" class="task-state">正在加载真实任务...</view>
|
||||
<view v-else-if="!tasks.length && !error" class="task-state">暂无待处理的拣货任务</view>
|
||||
<view v-if="notice" class="task-notice" role="status">{{ notice }}</view>
|
||||
<view class="task-list">
|
||||
<button v-for="task in tasks" :key="task.taskId" class="task-card" :disabled="!isTaskReady(task)" :aria-label="`${taskActionLabel(task)},${containerReference(task)}`" @tap="enterTask(task)">
|
||||
<view class="task-card-head">
|
||||
<view class="task-container-info"><text class="task-code">{{ containerReference(task) }}</text></view>
|
||||
<text class="task-status" :class="{ progress: task.workflowStatus === 'IN_PROGRESS', evidence: task.workflowStatus === 'AWAITING_REVIEW', processing: task.workflowStatus === 'PROCESSING', failed: task.workflowStatus === 'COMPLETION_FAILED' }">{{ statusLabel(task.workflowStatus) }}</text>
|
||||
</view>
|
||||
<view class="task-card-foot">
|
||||
<view class="task-meta-group"><text class="task-warehouse">仓库 {{ displayBusinessValue(task.warehouseName, '未关联') }}</text><text class="task-pending">待处理 {{ task.pendingItemCount ?? task.itemCount ?? 0 }} 项</text></view>
|
||||
<view class="task-enter"><text>{{ taskActionLabel(task) }}</text><text class="task-enter-arrow">›</text></view>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PortalHeader from '@/components/PortalHeader.vue'
|
||||
import { listLoadingOrders } from '@/api/pda-loading.js'
|
||||
import { session, requireSession } from '@/stores/session.js'
|
||||
import { clearPickingDraft } from '@/stores/picking.js'
|
||||
import { isOnline, watchNetwork } from '@/utils/network.js'
|
||||
import { containerReference, displayBusinessValue, formatApiError } from '@/utils/user-facing.js'
|
||||
|
||||
export default {
|
||||
components: { PortalHeader },
|
||||
data() { return { user: session.currentUser, tasks: [], loading: false, error: '', notice: '', online: true, toast: { visible: false, message: '', type: '' } } },
|
||||
onLoad() { if (!requireSession()) return; this._stopNetwork = watchNetwork((value) => { this.online = value }) },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() },
|
||||
onUnload() { if (this._stopNetwork) this._stopNetwork(); clearTimeout(this._toastTimer) },
|
||||
methods: {
|
||||
displayBusinessValue,
|
||||
containerReference,
|
||||
statusLabel(status) {
|
||||
if (status === 'PROCESSING') return '完成处理中'
|
||||
if (status === 'COMPLETION_FAILED') return '完成失败'
|
||||
if (status === 'AWAITING_REVIEW') return '待上传证据/确认装柜'
|
||||
return status === 'IN_PROGRESS' ? '拣货中' : '待领取'
|
||||
},
|
||||
taskActionLabel(task) {
|
||||
if (!task.currentContainerNo) return '待平台补录集装箱号'
|
||||
if (!this.isTaskReady(task)) return '任务暂不可进入'
|
||||
if (task.workflowStatus === 'COMPLETION_FAILED') return '重试完成'
|
||||
return task.workflowStatus === 'AWAITING_REVIEW' ? '继续装柜取证' : '进入并开始拣货'
|
||||
},
|
||||
isMergedTask(task) { return task?.recordStatus === 'MERGED' || Boolean(task?.mergedIntoContainerBookingId) },
|
||||
clearMergedTaskDraft(task) {
|
||||
const operatorId = session.currentUser?.id || session.currentUser?.userId || session.currentUser?.accountId
|
||||
clearPickingDraft({ operatorId, taskId: task.taskId, loadingPlanId: task.loadingPlanId, containerBookingId: task.containerBookingId, warehouseId: task.warehouseId })
|
||||
},
|
||||
isTaskReady(task) { return Boolean(task.recordStatus === 'ACTIVE' && !task.mergedIntoContainerBookingId && task.taskId && task.loadingPlanId && task.containerBookingId && task.cabinetNo && task.currentContainerNo && task.warehouseId && task.version !== null && task.version !== undefined && Number.isInteger(Number(task.version)) && (['AWAITING_REVIEW', 'COMPLETION_FAILED'].includes(task.workflowStatus) || (task.assignmentVersion !== null && task.assignmentVersion !== undefined && Number.isInteger(Number(task.assignmentVersion)))) && ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETION_FAILED'].includes(task.workflowStatus)) },
|
||||
isTaskVisible(task) { return Boolean(task.recordStatus === 'ACTIVE' && !task.mergedIntoContainerBookingId && task.taskId && ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'PROCESSING', 'COMPLETION_FAILED'].includes(task.workflowStatus)) },
|
||||
async loadTasks() {
|
||||
if (!isOnline()) { this.error = '当前网络不可用,请联网后刷新'; return }
|
||||
if (!this.user?.warehouseId) { this.error = '请返回首页选择作业仓库'; return }
|
||||
this.loading = true; this.error = ''; this.notice = ''
|
||||
try {
|
||||
const result = await listLoadingOrders({ page: 1, size: 50, warehouseId: this.user.warehouseId })
|
||||
const records = result.records || []
|
||||
records.filter((task) => this.isMergedTask(task)).forEach((task) => this.clearMergedTaskDraft(task))
|
||||
this.tasks = records.filter((task) => this.isTaskVisible(task))
|
||||
if (records.length > this.tasks.length) this.notice = '部分任务暂不可作业,请稍后刷新'
|
||||
} catch (error) {
|
||||
this.error = error.network ? '当前网络不可用,请联网后刷新' : formatApiError(error, '任务加载失败')
|
||||
} finally { this.loading = false }
|
||||
},
|
||||
enterTask(task) {
|
||||
if (!task.currentContainerNo) { this.showToast('请先在平台预装详情补录真实集装箱号', 'error'); return }
|
||||
if (!this.isTaskReady(task)) { this.showToast('任务尚未满足后端进入条件', 'error'); return }
|
||||
uni.navigateTo({ url: `/pages/picking/picking?taskId=${encodeURIComponent(task.taskId)}&warehouseId=${encodeURIComponent(task.warehouseId)}` })
|
||||
},
|
||||
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2200) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-content { padding: 12px 12px 40px; }
|
||||
.task-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
||||
.task-heading { display: flex; min-width: 0; flex-direction: column; }
|
||||
.task-title { color: #12233f; font-size: 18px; font-weight: 700; line-height: 25px; }
|
||||
.task-summary { margin-top: 2px; color: #667085; font-size: 11px; line-height: 16px; }
|
||||
.task-refresh { flex-shrink: 0; margin: 0; min-width: 76px; height: 44px; border: 0; border-radius: 9px; background: #1a73e8; color: #fff; font-size: 14px; line-height: 44px; }
|
||||
.task-refresh[disabled] { opacity: .6; }
|
||||
.task-error, .task-state { padding: 20px 14px; border-radius: 10px; background: #fff; color: #667085; font-size: 13px; line-height: 20px; text-align: center; }
|
||||
.task-error { color: #b42318; background: #fff1f0; }
|
||||
.task-notice { padding: 10px 12px; margin-bottom: 10px; border-left: 3px solid #f79009; border-radius: 6px; background: #fffaeb; color: #7a2e0e; font-size: 12px; line-height: 18px; }
|
||||
.task-list { display: flex; flex-direction: column; }
|
||||
.task-card { box-sizing: border-box; width: 100%; margin: 0 0 12px; padding: 12px; border: 1px solid #dce6f1; border-radius: 10px; color: #12233f; text-align: left; background: #fff; box-shadow: 0 2px 8px rgba(23, 55, 93, .05); }
|
||||
.task-card:last-child { margin-bottom: 0; }
|
||||
.task-card:active { border-color: #8ab8ef; background: #f7faff; }
|
||||
.task-card[disabled] { opacity: .62; }
|
||||
.task-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.task-container-info { display: flex; min-width: 0; flex-direction: column; }
|
||||
.task-code { margin-top: 1px; color: #12233f; font-size: 17px; font-weight: 700; line-height: 23px; overflow-wrap: anywhere; }
|
||||
.task-status { flex-shrink: 0; padding: 4px 7px; border-radius: 99px; color: #52616f; font-size: 10px; line-height: 16px; background: #eef2f6; }
|
||||
.task-status.progress { color: #155eef; background: #eff4ff; }
|
||||
.task-status.evidence { color: #9a5b13; background: #fff4e5; }
|
||||
.task-status.processing { color: #155eef; background: #eff4ff; }
|
||||
.task-status.failed { color: #b42318; background: #fff1f0; }
|
||||
.task-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; padding-top: 9px; border-top: 1px solid #edf1f5; }
|
||||
.task-meta-group { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.task-warehouse { color: #667085; font-size: 10px; line-height: 15px; overflow-wrap: anywhere; }
|
||||
.task-pending { color: #344054; font-size: 12px; font-weight: 600; line-height: 17px; }
|
||||
.task-enter { display: flex; min-height: 40px; flex-shrink: 0; align-items: center; gap: 5px; padding-left: 10px; color: #155eef; font-size: 12px; font-weight: 600; }
|
||||
.task-enter-arrow { font-size: 22px; font-weight: 400; line-height: 22px; }
|
||||
@media (max-width: 340px) {
|
||||
.task-content { padding-right: 10px; padding-left: 10px; }
|
||||
.task-enter { max-width: 124px; text-align: right; }
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 889 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 中检查更新/)
|
||||
})
|
||||
@@ -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\)/)
|
||||
})
|
||||
@@ -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, /<template v-if="palletSetupReady">[\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, /<uni-popup ref="unsealPopup"/)
|
||||
assert.doesNotMatch(work, /v-for="pallet in task\.pallets"/)
|
||||
assert.doesNotMatch(work, /downloadDropshipPalletLabel|downloadAllDropshipPalletLabels|打开当前托盘 A4 单|打开全部 A4 托盘单/)
|
||||
assert.match(work, /formatApiError/)
|
||||
assert.doesNotMatch(work, /Number\(this\.warehouseId\)/)
|
||||
assert.doesNotMatch(work, /taskId\s*\}\}|warehouseId\s*\}\}|inboundId\s*\}\}/)
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
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 = 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, /<uni-popup ref="unsealPopup"/)
|
||||
assert.match(page, /确认解除封托/)
|
||||
assert.doesNotMatch(page, /模拟成功|actualSkuQuantity/)
|
||||
})
|
||||
test('palletization preserves compact multi-SKU input without desktop printing actions', () => {
|
||||
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/)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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, '照片模糊')
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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, '装柜已完成')
|
||||
})
|
||||
@@ -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\(\)/)
|
||||
})
|
||||
@@ -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, /<ScanTargetInput v-model="scanValue" name="sku"[\s\S]*?:active="activeScanField === 'sku'"[\s\S]*?@activate="setScanTarget"[\s\S]*?@submit="handleManualSubmit"/)
|
||||
assert.match(page, /<ScanTargetInput v-model="scanValue" name="boxCode"[\s\S]*?:active="activeScanField === 'boxCode'"[\s\S]*?@activate="setScanTarget"[\s\S]*?@submit="handleManualSubmit"/)
|
||||
assert.match(page, /beforeUnmount\(\)/)
|
||||
assert.match(page, /startScanBroadcast/)
|
||||
assert.match(page, /onHide\(\) \{ this\.stopHardwareScanner\(\) \}/)
|
||||
assert.match(page, /onHide\(\) \{[\s\S]*?this\.stopHardwareScanner\(\)[\s\S]*?\}/)
|
||||
assert.match(page, /handleHardwareScan\(value\)[\s\S]*?this\.scanValue = value[\s\S]*?this\.processScannedValue\(value, 'broadcast'\)/)
|
||||
assert.match(page, /handleManualSubmit\(value\)[\s\S]*?\.trim\(\)\.toUpperCase\(\)[\s\S]*?this\.processScannedValue\(normalizedValue, 'manual'\)/)
|
||||
const processScanMethod = page.match(/processScannedValue\(scanValue, source = 'broadcast'\) \{[\s\S]*?\n\t\t\t\},\n\t\t\tpickItem/)
|
||||
assert.ok(processScanMethod)
|
||||
assert.doesNotMatch(processScanMethod[0], /this\.scanValue = ''/)
|
||||
assert.match(page, /recordScanResult\(type, title, boxCode, detail\)/)
|
||||
assert.match(page, /lastScanResult\.boxCode/)
|
||||
assert.match(page, /boxCode: entry\.boxCode \?\? entry\.sku \?\? ''/)
|
||||
assert.doesNotMatch(page, /lastScanResult\.sku|entry\.sku\s*}}/)
|
||||
const processScanStart = page.indexOf("processScannedValue(scanValue, source = 'broadcast') {")
|
||||
const processScanEnd = page.indexOf('\n\t\t\tevidenceEntries', processScanStart)
|
||||
assert.ok(processScanStart >= 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, /<uni-popup ref="menuPopup"[\s\S]*?:is-mask-click="true"/)
|
||||
assert.match(header, /<PopupHeader title="功能菜单" @close="closeMenu"/)
|
||||
assert.match(header, /<PopupHeader title="选择作业仓库" @close="closeMenu"/)
|
||||
assert.match(header, /v-for="warehouse in warehouses"[\s\S]*?@tap="chooseWarehouse\(warehouse\)"/)
|
||||
assert.doesNotMatch(header, />作业记录<|>个人中心</)
|
||||
assert.doesNotMatch(header, /uni\.showActionSheet/)
|
||||
assert.match(profile, /<uni-popup ref="logoutPopup" type="center"[\s\S]*?:is-mask-click="true"[\s\S]*?@change="handleLogoutChange"/)
|
||||
assert.match(profile, /<PopupHeader title="确认退出登录?"[\s\S]*?@close="closeLogout"/)
|
||||
assert.match(profile, /class="logout-dialog-confirm"/)
|
||||
for (const name of ['boxList', 'skip', 'partial', 'switchBoxConfirm', 'submitConfirm', 'complete', 'allComplete']) {
|
||||
for (const name of ['boxList', 'skip', 'partial', 'switchBoxConfirm', 'submitConfirm', 'complete']) {
|
||||
assert.match(picking, new RegExp(`<uni-popup ref="${name}Popup"[^>]*:is-mask-click="true"[^>]*@change="handleOverlayChange\\('${name}', \\$event\\)"`))
|
||||
}
|
||||
assert.equal((picking.match(/<PopupHeader\b/g) || []).length, 7)
|
||||
assert.equal((picking.match(/<PopupHeader\b/g) || []).length, 6)
|
||||
assert.match(picking, /openOverlay\(name\)[\s\S]*?this\.\$refs\[`\$\{name\}Popup`\]/)
|
||||
assert.match(picking, /closeOverlay\(\)[\s\S]*?popup\.close\(\)/)
|
||||
assert.match(picking, /handleOverlayChange\(name, event\)[\s\S]*?!event\.show[\s\S]*?this\.resetOverlayState\(\)/)
|
||||
@@ -179,7 +271,7 @@ test('picking scan results use the shared success and error audio feedback', asy
|
||||
assert.match(audio, /export function playErrorSound\(\)/)
|
||||
assert.match(picking, /import \{ playErrorSound, playSuccessSound \} from '@\/utils\/audio-feedback\.js'/)
|
||||
assert.match(picking, /result\.type === 'unknown'[\s\S]*?playErrorSound\(\)/)
|
||||
assert.match(picking, /playSuccessSound\(\)[\s\S]*?this\.pickItem\(result\.item\.id, 'scan'\)/)
|
||||
assert.match(picking, /await this\.pickScannedBox\(result\.item, value\)[\s\S]*?if \(submitted\) playSuccessSound\(\)/)
|
||||
})
|
||||
|
||||
test('reusable scan target input separates field selection from page-level broadcast handling', async () => {
|
||||
@@ -190,11 +282,11 @@ test('reusable scan target input separates field selection from page-level broad
|
||||
assert.match(component, /<input[\s\S]*?:value="modelValue"[\s\S]*?@input="handleInput"[\s\S]*?@confirm="handleConfirm"/)
|
||||
assert.doesNotMatch(component, /<input[\s\S]*?disabled/)
|
||||
assert.doesNotMatch(component, /startScanBroadcast|plus\.android/)
|
||||
assert.match(picking, /activeScanField: 'sku'/)
|
||||
assert.match(picking, /handleHardwareScan\(value\)[\s\S]*?activeScanField !== 'sku'[\s\S]*?processScannedValue\(value, 'broadcast'\)/)
|
||||
assert.match(picking, /activeScanField: 'boxCode'/)
|
||||
assert.match(picking, /handleHardwareScan\(value\)[\s\S]*?activeScanField !== 'boxCode'[\s\S]*?processScannedValue\(value, 'broadcast'\)/)
|
||||
})
|
||||
|
||||
test('picking session is memory-only, survives navigation and clears on logout', async () => {
|
||||
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"/)
|
||||
|
||||
@@ -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;/)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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'/)
|
||||
})
|
||||
@@ -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, '装柜完成失败'\)/)
|
||||
})
|
||||
@@ -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, /<text class="pick-item-location" :class="\{ 'location-missing': !item\.location \}">库位 \{\{ 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)
|
||||
})
|
||||
@@ -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, /<view v-if="currentUser" class="page-shell">/)
|
||||
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, '任务同步失败'\)/)
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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\)/)
|
||||
})
|
||||
@@ -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!/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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(/<template>[\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\)/)
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: '' }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||