Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c17ec2779 | ||
|
|
7e793b1d1b | ||
|
|
10d9896b6b | ||
|
|
1b66aeb4aa | ||
|
|
30f370dd94 | ||
|
|
3cc77c98b5 | ||
|
|
2b6f512daa | ||
|
|
cc280338f1 |
@@ -21,7 +21,7 @@
|
||||
- 并发401仅一次refresh,完整校验Token/快照再原子替换;主体/仓变化清会话重登录,原请求/照片上传最多重放一次。刷新失败清会话;业务/网络错留code、requestId、traceId或network诊断。
|
||||
- 国内/海外 AppID、Android 包名、签名、版本号、产物、更新通道独立;`src/manifest.json` AppID须正式打包前由海外所属DCloud账号创建,禁伪造/复用国内AppID。
|
||||
- 环境地址仅配 `.env.local` 等未提交环境文件,不入源码。
|
||||
- Android启动仅在 `APP-PLUS` 调 `src/utils/app-update.js`,以独立 `VITE_PDA_UPDATE_URL` 查HTTPS APK/WGT;强更禁跳过,WGT安装即重启。更新服务/标识/签名/版本序列不得与国内 PDA 混用。
|
||||
- Android仅在 `APP-PLUS` 调 `src/utils/app-update.js`,用独立 `VITE_PDA_UPDATE_URL` 查HTTPS APK/WGT;强更禁跳过,WGT安装即重启,不得与国内 PDA 混用。发布先装APK再发兼容WGT;原生变化先升APK。步骤及版本见 `README.md`“App 在线更新”。
|
||||
|
||||
- 源码根 `src/`;扫码广播 Action=`android.intent.action.SCANRESULT`,Extra=`value`。
|
||||
- 退货号/SKU候选进入,按本包实收生成退货箱/计费批次;其他作业范围见下表及对应接口边界。
|
||||
|
||||
@@ -54,6 +54,8 @@ npm run build:app
|
||||
|
||||
Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独立更新通道,员工也可在“个人中心 → 检查更新”手动触发;H5 仅展示 App 操作提示,不执行更新。更新服务接收 `appid`、`platform`、`appVersion`、`appVersionCode`、`wgtVersion` 查询参数,无更新返回 `{ "update": false }`,有更新返回:
|
||||
|
||||
HBuilderX 真机调试及标准基座使用当前工程 `src/manifest.json` 中的 AppID、版本名和版本码模拟更新检查,避免把 HBuilderX 基座版本作为业务版本上报;正式 APK 仍使用实际安装包版本码及独立的 WGT 资源版本。调试检查同样只读取后台已发布版本,上传草稿不会触发更新;调试基座不能代替正式 APK 的覆盖安装与签名验收。
|
||||
|
||||
```json
|
||||
{
|
||||
"update": true,
|
||||
@@ -70,10 +72,11 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独
|
||||
|
||||
`packageType` 只允许 `apk` 或 `wgt`,`appid` 必须与当前安装包一致。WGT 清单还须提供 `minAppVersionCode`(最低兼容 APK 整数版本),不满足时客户端拒绝安装并提示升级 APK。`mandatory=true` 时员工不能跳过,并在下载或安装失败后提供重试;wgt 安装完成会立即重启,APK 交由 Android 安装器完成覆盖安装。正式发布要求:
|
||||
|
||||
1. 海外 PDA 每次整包发布同时递增 `src/manifest.json` 的 `versionName` 与整数 `versionCode`,使用海外独立 AppID、包名和签名打包 APK。
|
||||
2. wgt 发布至少递增 `versionName`,并使用与已安装原生包兼容的 uni-app 编译器生成;新增原生模块、权限或不兼容运行时能力时必须发布 APK,不能只发 wgt。
|
||||
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
|
||||
4. 海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
|
||||
1. 发布顺序固定为“先安装 APK 基线,再发布后续 WGT 热更新”。首次安装先使用海外独立 AppID、包名和签名打包 APK,在目标 PDA 安装后确认登录、扫码、拍照和“个人中心 → 检查更新”正常,再以该 APK 作为后续热更新基线。
|
||||
2. 后续兼容的页面、交互和业务逻辑修改发布 WGT。递增 `src/manifest.json` 的 `versionName`,使其高于设备当前的 `wgtVersion`,沿用 APK 基线的整数 `versionCode`,并使用与基线 APK 兼容的 uni-app 编译器生成。更新记录的 `minAppVersionCode` 填该 WGT 要求的 APK 基线版本码;设备低于基线时先安装新版 APK。
|
||||
3. 例如 APK 基线为 `0.0.5(versionCode=5)`,下一次 WGT 可发布 `0.0.6`,`minAppVersionCode=5`。安装基线 APK 后首次检查同版本资源会判定为最新版本;后续 WGT 使用更高的资源版本号。
|
||||
4. 新增原生模块、权限或不兼容运行时能力时,先同时递增 `versionName` 与整数 `versionCode` 并发布、安装新版 APK,完成真机验证后再基于新 APK 发布后续 WGT。
|
||||
5. 更新清单与下载地址使用 HTTPS。每次发布先在已安装对应 APK 基线的测试设备验证实际升级路径、断网重试、WGT 安装重启及本次受影响作业,再切换正式清单;新 APK 基线同时验证覆盖安装和签名。海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
|
||||
|
||||
## H5 静态托管(TEST 与灰度)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
| 商品移库真实作业 | `src/pages/move/move.vue`、`src/api/warehouse-operations.js`、`src/stores/warehouse-work.js` | 扫描来源托盘与目标库位执行同仓移库;冲突后重新读取服务端事实 |
|
||||
| 换标真实作业 | `src/pages/relabel/relabel.vue`、`src/api/relabel.js` | 同一页面按操作参数承载换标收货、执行与复核;执行页按当前单明细切换 SKU/箱唛 Tab,组合明细同时显示两类需求。SKU 逐条扫码旧新SKU并携带审批件数,箱唛扫码仓库唛头/新FBA箱唛;组合单按同箱保留两个Tab的核对输入,全部核对后只提交一次箱事实。认领同步处理中;支持拍照、异常/返工和幂等恢复 |
|
||||
| 现场查询真实作业 | `src/pages/query/query.vue`、`src/api/warehouse-operations.js` | 同一页面按操作参数承载库存、库位与托盘查询,只展示稳定业务标识 |
|
||||
| 排车拣货与发车/理货审核 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入展示任务列表和“上次作业”,不因本地草稿自动打开详情;明确跳转 `?resume=1` 才恢复排车。默认任务列表含待理货排车,详情展示服务端车辆/司机快照、计划发车及预计送达时间、当前有效FBA收货确认、调度审核状态/意见和货物;时间明确标注北京时间。预约或审核依据失效时 `APPROVAL_BLOCKED` 禁止批准但保留驳回。仅 `actions.LOADING_REVIEW.allowed=true` 时可调用 `POST /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review`,提交原 `recordVersion/idempotencyKey/decision/reason`;驳回原因必填,任一级拒绝统一回客服处理(车辆信息),重新提交后再次逐级审核。PDA 不执行调度审核;理货通过并重读资格后才允许按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE` 分别决定真实拣货和装车完成资格;按已扫托数、箱数整车一次确认装车,不逐托填重量、体积或托位,资料未齐进入 `AFTER_LOADING`、已齐直接进入 `DELIVERING`;详情采用“拣货作业/装车拍照”两个 Tab,打开任务默认拣货;切换保留拣货草稿与照片状态,拍照页不响应拣货扫码,同单照片保存刷新保留当前 Tab。装车中即可在拍照 Tab 通过 `src/components/DispatchLoadingDocuments.vue` 上传五类整车照片(司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字,每类1至5张),照片齐全且确认装车完成后由服务端在最后一项完成的事务自动生成派送并发车;不额外安排派送或确认发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**`。回归 `tests/dispatch-workflow.test.mjs tests/fulfillment-workflows.test.mjs` |
|
||||
| 排车拣货与发车/理货审核 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 普通进入展示任务列表和“上次作业”,不因本地草稿自动打开详情;明确跳转 `?resume=1` 才恢复排车。默认任务列表含待理货排车,详情展示服务端车辆/司机快照、计划发车及预计送达时间、当前有效FBA收货确认、调度审核状态/意见和货物;时间同时展示美东(America/New_York)与美西(America/Los_Angeles),按地区时区处理夏令时;只换算带时区的真实时间点,缺失时不补造时间。预约或审核依据失效时 `APPROVAL_BLOCKED` 禁止批准但保留驳回。仅 `actions.LOADING_REVIEW.allowed=true` 时可调用 `POST /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review`,提交原 `recordVersion/idempotencyKey/decision/reason`;驳回原因必填,任一级拒绝统一回客服处理(车辆信息),重新提交后再次逐级审核。PDA 不执行调度审核;理货通过并重读资格后才允许按实物托盘码或来源箱唛真实拣货,服务端 `actions.PICK/LOADING_COMPLETE` 分别决定真实拣货和装车完成资格;按已扫托数、箱数整车一次确认装车,不逐托填重量、体积或托位,资料未齐进入 `AFTER_LOADING`、已齐直接进入 `DELIVERING`;详情采用“拣货作业/装车拍照”两个 Tab,打开任务默认拣货;切换保留拣货草稿与照片状态,拍照页不响应拣货扫码,同单照片保存刷新保留当前 Tab。装车中即可在拍照 Tab 通过 `src/components/DispatchLoadingDocuments.vue` 上传五类整车照片(司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字,每类1至5张),照片齐全且确认装车完成后由服务端在最后一项完成的事务自动生成派送并发车;不额外安排派送或确认发车。公网前缀 `/api/pda/v1/last-leg/dispatches/**`。回归 `tests/dispatch-workflow.test.mjs tests/fulfillment-workflows.test.mjs` |
|
||||
| 装车确认、自动派送与证照提醒 | `src/pages/dispatch/dispatch.vue`、`src/api/fulfillment.js` 的 `completeDispatchLoading` | 一单一趟,使用 `dispatchNo`、公开 `recordVersion`(0 合法)及本次 `idempotencyKey`。只在请求中锁定,结束即释放;保留表单,不持久化或重放失败命令,下次点击按当前输入和版本生成新键。结果不明或 409 先刷新权威任务,不以超时推断未执行或成功。结果核对单号、动作、请求键和版本;理货审核通过须 `stage=LOADING`,驳回须 `stage=BEFORE_LOADING`;`LOADING_COMPLETE` 返回 `stage=AFTER_LOADING|DELIVERING`、Loaded 结果,提交版本、manifestHash 和幂等键,不逐托填测量。成功后重读任务,版本不得早于已确认结果;重读失败只刷新,不自动重复 POST。司机证照、资源及各客户派送资格由服务端校验;正式流程见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`,不使用独立车次发车请求 |
|
||||
| 普通/一件代发出库 | `src/pages/outbound/outbound.vue`、`src/api/fulfillment.js`、`src/stores/fulfillment.js` | 首次进入清空扫描框并展示任务列表和“上次作业”标记,不自动恢复旧详情;详情提供独立返回列表入口。逐箱扫码、DP 托盘整托拣取及幂等完成保持不变;扫描排车业务号显式跳转排车 `?resume=1`。公网前缀 `/api/pda/v1/last-leg/outbound-tasks/**`;派送由 Admin/Client 承载 |
|
||||
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
<text class="heading-progress">{{ busy ? '同步中' : snapshot ? `${completedCategories}/5 类已保存` : '待同步' }}</text>
|
||||
<button class="heading-refresh" :disabled="busy" @tap="refresh">刷新</button>
|
||||
</view>
|
||||
<text class="section-hint">每类至少 1 张、最多 5 张,上传后自动保存,可逐张更换。</text>
|
||||
<text class="section-hint">司机驾驶证、车牌照片在理货审核通过后自动带入,可补拍或更换;PDF 驾驶证可点击查看。</text>
|
||||
<text v-if="needsRefresh" class="section-hint">保存状态尚未核实,请先刷新资料后再上传。</text>
|
||||
<view v-if="error" class="error" role="alert"><text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text></view>
|
||||
<view class="photo-list">
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
<view class="header-menu-sheet">
|
||||
<view class="header-menu-content">
|
||||
<PopupHeader title="功能菜单" @close="closeMenu" />
|
||||
<button class="header-menu-item" @tap="goHistory"><uni-icons type="list" color="#334155" size="22" /><text>作业记录</text><uni-icons type="right" color="#94a3b8" size="18" /></button>
|
||||
<button class="header-menu-item" @tap="goProfile"><uni-icons type="person" color="#334155" size="22" /><text>个人中心</text><uni-icons type="right" color="#94a3b8" size="18" /></button>
|
||||
<button class="header-menu-close" @tap="closeMenu">关闭</button>
|
||||
</view>
|
||||
@@ -39,7 +38,6 @@
|
||||
methods: {
|
||||
goBack() { navigateBackOr(this.fallbackUrl) },
|
||||
goProfile() { this.closeMenu(); navigateToUnique('/pages/profile/profile') },
|
||||
goHistory() { this.closeMenu(); navigateToUnique('/pages/history/history') },
|
||||
openMenu() { this.$refs.menuPopup.open() },
|
||||
closeMenu() { if (this.$refs.menuPopup) this.$refs.menuPopup.close() },
|
||||
handleMenuChange(event) { this.$emit('menu-change', Boolean(event.show)) }
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
"name" : "柏悦物流 PDA 海外版",
|
||||
"appid" : "__UNI__936D079",
|
||||
"description" : "柏悦物流海外仓 PDA 作业端",
|
||||
"versionName" : "0.0.4",
|
||||
"versionCode" : 4,
|
||||
"versionName" : "0.1.0",
|
||||
"versionCode" : 10,
|
||||
"transformPx" : false,
|
||||
"app-plus" : {
|
||||
"usingComponents" : true,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</view>
|
||||
<view v-if="!current || activeTab === 'picking'" class="fulfillment-card dispatch-scan-card">
|
||||
<text class="card-title">{{ current ? '扫码拣货' : '扫码查询排车' }}</text>
|
||||
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号/实物托盘码/来源箱唛" placeholder="扫描业务单号或实物码" :active="activeTab === 'picking'" @update:modelValue="scanCode = $event" @submit="handleScan" />
|
||||
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号/实物托盘码/来源箱唛" placeholder="扫托盘码" :active="activeTab === 'picking'" @update:modelValue="scanCode = $event" @submit="handleScan" />
|
||||
<button class="secondary-button" :disabled="busy || loading || !scanCode" @tap="handleScan(scanCode)">匹配扫描</button>
|
||||
</view>
|
||||
<view v-if="loading" class="fulfillment-state"><text>正在加载排车事实…</text></view>
|
||||
@@ -32,9 +32,7 @@
|
||||
<DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo" :record-version="current.recordVersion" />
|
||||
<view class="fulfillment-card summary-card">
|
||||
<text class="card-title">任务摘要</text>
|
||||
<view class="task-head"><text class="business-code">{{ current.dispatchNo }}</text><text class="status-badge">{{ stageLabel(current) }}</text></view>
|
||||
<text class="task-meta">任务号 {{ current.taskNo || '—' }}</text>
|
||||
<text v-if="!gate('PICK') && current.stage === 'LOADING'" class="gate-warning">{{ current.actions.PICK?.blockReason || '拣货资格尚未允许,请核对两级审核及当前作业状态' }}</text>
|
||||
<view class="task-head"><text class="status-badge">{{ stageLabel(current) }}</text></view>
|
||||
<text class="picking-section-label">拣货进度</text>
|
||||
<view class="metrics" aria-label="拣货进度"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>真实已拣</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>未拣箱数</text></view></view>
|
||||
</view>
|
||||
@@ -45,18 +43,17 @@
|
||||
<view class="review-fact"><text class="review-label">托车公司</text><text>{{ current.resources?.vehicle?.carrierName || '—' }}</text></view>
|
||||
<view class="review-fact"><text class="review-label">司机</text><text>{{ current.resources?.driver?.name || '—' }}</text></view>
|
||||
<view class="review-fact"><text class="review-label">电话</text><text>{{ current.resources?.driver?.phone || '—' }}</text></view>
|
||||
<view class="review-fact"><text class="review-label">计划发车</text><text>{{ reviewTime(current.plannedDepartureAt) }}(北京时间)</text></view>
|
||||
<view class="review-fact"><text class="review-label">预计送达</text><text>{{ reviewTime(current.estimatedArrivalAt) }}(北京时间)</text></view>
|
||||
<view v-for="appointment in current.fbaAppointments || []" :key="appointment.destinationKey" class="review-fact"><text class="review-label">{{ appointment.destinationWarehouseCode }}</text><view class="review-appointment"><text>{{ appointment.appointmentNo || '—' }}</text><text>{{ appointment.status === 'SUCCESS' ? '已确认' : appointment.status === 'REGISTERED' ? '已登记' : '未填写' }}:{{ reviewTime(appointment.confirmedAt) }}(北京时间)</text></view></view>
|
||||
<view class="review-fact"><text class="review-label">计划发车</text><text class="review-time">{{ reviewTime(current.plannedDepartureAt) }}</text></view>
|
||||
<view class="review-fact"><text class="review-label">预计送达</text><text class="review-time">{{ reviewTime(current.estimatedArrivalAt) }}</text></view>
|
||||
<view v-for="appointment in current.fbaAppointments || []" :key="appointment.destinationKey" class="review-fact"><text class="review-label">{{ appointment.destinationWarehouseCode }}</text><view class="review-appointment"><text>{{ appointment.appointmentNo || '—' }}</text><text>{{ appointment.status === 'SUCCESS' ? '已确认' : appointment.status === 'REGISTERED' ? '已登记' : '未填写' }}</text><text class="review-time">{{ reviewTime(appointment.confirmedAt) }}</text></view></view>
|
||||
<view class="review-fact"><text class="review-label">驾驶证号</text><text>{{ current.resources?.driver?.evidence?.licenseNo || '—' }}</text></view>
|
||||
<view class="review-fact"><text class="review-label">有效期</text><text>{{ current.resources?.driver?.evidence?.licenseExpiresOn || '—' }}</text></view>
|
||||
<view class="review-fact"><text class="review-label">调度审核</text><text>{{ reviewLabel(current.reviews?.dispatchReview) }} · {{ reviewTime(current.reviews?.dispatchReview?.reviewedAt) }}</text></view>
|
||||
<view class="review-fact"><text class="review-label">调度审核</text><view class="review-appointment"><text>{{ reviewLabel(current.reviews?.dispatchReview) }}</text><text class="review-time">{{ reviewTime(current.reviews?.dispatchReview?.reviewedAt) }}</text></view></view>
|
||||
<view v-if="current.reviews?.dispatchReview?.reason" class="review-fact"><text class="review-label">调度意见</text><text>{{ current.reviews.dispatchReview.reason }}</text></view>
|
||||
</view>
|
||||
<text v-if="rejectionReason" class="gate-warning">已退回客服处理(车辆信息):{{ rejectionReason }}。修改后须重新提交审核。</text>
|
||||
<template v-if="canReview">
|
||||
<text v-if="current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" class="gate-warning">{{ current.actions.LOADING_REVIEW.blockReason }}</text>
|
||||
<text class="task-meta">请核对车辆、司机及下方货物;通过后才能开始真实拣货。驳回将回到客服处理(车辆信息)。</text>
|
||||
<textarea :value="reviewReason" maxlength="1000" placeholder="审核意见(驳回时必填)" :disabled="busy || loading" @input="updateReviewReason" />
|
||||
<view class="review-actions"><button :disabled="busy || loading || !reviewReason.trim()" @tap="submitReview('REJECTED')">驳回</button><button class="primary-button" :disabled="busy || loading || current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" @tap="submitReview('APPROVED')">审核通过</button></view>
|
||||
</template>
|
||||
@@ -81,7 +78,7 @@
|
||||
<view v-else class="picking-quantity"><text class="picking-instruction">拣货箱数</text><input type="number" aria-label="本次拣货箱数" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" :disabled="busy" @input="updatePackages" /><text class="task-meta">本次最多可拣 {{ selectedRemaining }} 箱</text></view>
|
||||
<button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">确认真实拣货</button>
|
||||
</view>
|
||||
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card picking-completion"><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy" @tap="prepareLoading">确认装车完成</button></view>
|
||||
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card picking-completion"><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy || loading || loadingPreview !== null" :loading="requestBusy" @tap="prepareLoading">{{ requestBusy ? '处理中…' : '确认装车完成' }}</button></view>
|
||||
</view>
|
||||
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</view>
|
||||
</view></scroll-view>
|
||||
@@ -93,13 +90,6 @@
|
||||
<text class="scan-notice-count">{{ scanNotice.packages }}<text class="loading-count-unit"> 箱</text></text>
|
||||
<text class="scan-notice-hint">3 秒后自动关闭</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="loadingPreview" class="loading-overlay">
|
||||
<view class="loading-dialog" role="dialog" aria-modal="true" aria-labelledby="loading-confirm-title">
|
||||
<view class="loading-dialog-heading"><text id="loading-confirm-title" class="loading-dialog-title">确认装车</text></view>
|
||||
<view class="loading-counts"><view class="loading-count"><text class="loading-count-value">{{ loadingRows.length }}<text class="loading-count-unit"> 托</text></text><text class="loading-count-label">已拣托盘</text></view><view class="loading-count"><text class="loading-count-value">{{ loadingRows.reduce((sum, row) => sum + Number(row.pickedPackages || 0), 0) }}<text class="loading-count-unit"> 箱</text></text><text class="loading-count-label">已拣箱数</text></view></view>
|
||||
<view class="loading-dialog-actions"><button class="loading-cancel" :disabled="busy" @tap="loadingPreview = null">取消</button><button class="loading-confirm" :disabled="busy" :loading="busy" @tap="completeLoading">确认</button></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -134,7 +124,7 @@ export default {
|
||||
},
|
||||
onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
|
||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo); else this.loadTasks() } else if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo); else this.loadTasks() },
|
||||
onHide() { this.stopScanner(); this.clearScanNotice(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.clearScanNotice(); this.generation++ },
|
||||
onHide() { this.stopScanner(); this.clearScanNotice(); this.loadingPreview = null; this.generation++ }, beforeUnmount() { this.stopScanner(); this.clearScanNotice(); this.loadingPreview = null; this.generation++ },
|
||||
methods: {
|
||||
clearScanNotice() { clearTimeout(this._scanNoticeTimer); this._scanNoticeTimer = null; this.scanNotice = null },
|
||||
showScanNotice(item) {
|
||||
@@ -151,7 +141,7 @@ export default {
|
||||
closeTask() { if (this.busy) return; this.current = null; this.activeTab = 'picking'; this.reviewReason = ''; this.loadingPreview = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 }); this.loadTasks() },
|
||||
handleScan(raw) {
|
||||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim()
|
||||
if (!code || this.busy || this.loading || this.activeTab !== 'picking') return
|
||||
if (!code || this.busy || this.loading || this.loadingPreview || this.activeTab !== 'picking') return
|
||||
this.clearScanNotice()
|
||||
this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = ''
|
||||
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 })
|
||||
@@ -186,7 +176,42 @@ export default {
|
||||
this.sourceChoices = []; this.choiceCartonCode = ''; this.error = null
|
||||
},
|
||||
async submitPick() { if (!this.canPick) return; try { const body = { recordVersion: requireDispatchVersion(this.current.recordVersion), positionId: this.selectedReturnCarton ? null : this.selectedPosition.positionId, packages: this.selectedReturnCarton ? 1 : Number(this.packages), productQuantity: this.selectedReturnCarton?.productQuantity || 0, cartonMark: this.selectedReturnCarton?.cartonMark || null, remark: null }; await this.submitCommand(this.current.dispatchNo, 'PICK', { ...body, dispatchItemId: this.selectedItem.dispatchItemId }) } catch (cause) { this.setError(cause, '拣货未提交') } },
|
||||
async prepareLoading() { if (!this.canComplete) return; await this.run(async () => { const data = await getDispatchLoading(this.current.dispatchNo); if (data.dispatchNo !== this.current.dispatchNo || data.pickingReconciled !== true || data.loadingComplete?.allowed !== true) throw new Error(data.loadingComplete?.blockReason || '装车前置未满足'); requireDispatchVersion(data.recordVersion); this.loadingPreview = data; this.loadingRows = data.pallets }) },
|
||||
async prepareLoading() {
|
||||
if (!this.canComplete || this.busy || this.loading || this.loadingPreview) return
|
||||
const no = this.current.dispatchNo, generation = this.generation
|
||||
await this.run(async () => {
|
||||
const data = await getDispatchLoading(no)
|
||||
if (generation !== this.generation || this.current?.dispatchNo !== no) return
|
||||
if (data.dispatchNo !== no || data.pickingReconciled !== true || data.loadingComplete?.allowed !== true) throw new Error(data.loadingComplete?.blockReason || '装车前置未满足')
|
||||
requireDispatchVersion(data.recordVersion)
|
||||
if (!Array.isArray(data.pallets) || !data.pallets.length) throw new Error('装车清单未返回有效托盘,请重新核对')
|
||||
this.loadingRows = data.pallets
|
||||
this.loadingPreview = data
|
||||
})
|
||||
const preview = this.loadingPreview
|
||||
if (!preview) return
|
||||
const pickedPackages = this.loadingRows.reduce((sum, row) => sum + Number(row.pickedPackages || 0), 0)
|
||||
uni.showModal({
|
||||
title: '确认装车',
|
||||
content: `已拣托盘 ${this.loadingRows.length} 托\n已拣箱数 ${pickedPackages} 箱`,
|
||||
confirmText: '确认',
|
||||
cancelText: '取消',
|
||||
success: async result => {
|
||||
if (this.loadingPreview !== preview) return
|
||||
if (!result.confirm || generation !== this.generation || this.current?.dispatchNo !== no) {
|
||||
this.loadingPreview = null
|
||||
return
|
||||
}
|
||||
await this.completeLoading()
|
||||
if (this.loadingPreview === preview) this.loadingPreview = null
|
||||
},
|
||||
fail: cause => {
|
||||
if (this.loadingPreview !== preview) return
|
||||
this.loadingPreview = null
|
||||
this.setError(new Error(cause.errMsg || '无法打开装车确认,请重试'), '无法打开装车确认')
|
||||
}
|
||||
})
|
||||
},
|
||||
async completeLoading() { if (!this.loadingPreview || this.busy) return; try { if (!this.loadingRows.length || this.loadingRows.some(row => row.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new Error('托盘卸货站尚未匹配,请后台核对'); await this.submitCommand(this.loadingPreview.dispatchNo, 'LOADING_COMPLETE', { recordVersion: requireDispatchVersion(this.loadingPreview.recordVersion), manifestHash: this.loadingPreview.manifestHash, remark: null }) } catch (cause) { this.setError(cause, '装车完成未确认') } },
|
||||
updateReviewReason(event) { if (this.busy || this.loading) return; this.reviewReason = String(event.detail.value || '') },
|
||||
async submitReview(decision) {
|
||||
@@ -228,9 +253,19 @@ export default {
|
||||
},
|
||||
async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
|
||||
async run(work, loading = false) { if (this.busy || this.loading) return; this.clearScanNotice(); this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } },
|
||||
setError(cause, fallback) { this.clearScanNotice(); this.error = toDispatchError(cause, fallback); playErrorSound() },
|
||||
setError(cause, fallback) { this.clearScanNotice(); this.error = toDispatchError(cause, fallback); playErrorSound(); if (typeof uni !== 'undefined') uni.showToast?.({ title: this.error.message || fallback, icon: 'none', duration: 3000 }) },
|
||||
stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', AFTER_LOADING: '装车后', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
|
||||
reviewTime(value) { if (!value) return '未填写'; const date = new Date(new Date(value).getTime() + 8 * 60 * 60 * 1000); return Number.isNaN(date.getTime()) ? '时间无效' : date.toISOString().slice(0, 16).replace('T', ' ') },
|
||||
reviewTime(value) {
|
||||
if (!value) return '未填写'
|
||||
const instant = typeof value === 'string' && /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? Date.parse(value) : NaN
|
||||
if (!Number.isFinite(instant)) return '时间无效'
|
||||
return [['美东', 'America/New_York'], ['美西', 'America/Los_Angeles']].map(([label, timeZone]) => {
|
||||
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||
}).formatToParts(instant).map(part => [part.type, part.value]))
|
||||
return `${label} ${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
|
||||
}).join('\n')
|
||||
},
|
||||
reviewLabel(review) { return ({ NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' })[review?.status] || '待核对' },
|
||||
routeLabel(item) { return [item.destination?.warehouseCode, item.destination?.warehouseName, item.destination?.address].filter(Boolean).join(' · ') || '目的仓未关联' }, itemProgress(item) { return item.scheduledPackages ? Math.min(100, Math.round(item.pickedPackages / item.scheduledPackages * 100)) : 0 }
|
||||
}
|
||||
@@ -274,26 +309,15 @@ export default {
|
||||
.review-label { flex: 0 0 132rpx; color: #718095; overflow-wrap: anywhere; }
|
||||
.review-fact > :last-child { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.review-appointment { display: flex; flex-direction: column; gap: 4rpx; }
|
||||
.review-time { white-space: pre-line; }
|
||||
.review-actions { display: flex; gap: 20rpx; margin-top: 24rpx; }
|
||||
.review-actions button { flex: 1; min-width: 0; margin: 0; padding: 0 16rpx; min-height: 88rpx; line-height: 88rpx; font-size: 28rpx; }
|
||||
.review-actions .primary-button { min-height: 88rpx; line-height: 88rpx; font-size: 28rpx; }
|
||||
.review-card textarea { box-sizing: border-box; width: 100%; height: 160rpx; min-height: 160rpx; margin-top: 20rpx; padding: 16rpx; border: 1px solid #cbd5e1; border-radius: 8rpx; }
|
||||
.loading-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .46); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 40rpx; box-sizing: border-box; }
|
||||
.loading-overlay { position: fixed; top: 0; right: 0; bottom: 0; left: 0; background: rgba(15, 23, 42, .46); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 40rpx; box-sizing: border-box; }
|
||||
.loading-dialog { width: 100%; max-width: 620rpx; max-height: 88vh; overflow-y: auto; box-sizing: border-box; background: #fff; border-radius: 28rpx; padding: 36rpx 32rpx 32rpx; box-shadow: 0 24rpx 80rpx rgba(15, 23, 42, .18); }
|
||||
.loading-dialog-heading { display: flex; flex-direction: column; gap: 10rpx; }
|
||||
.loading-dialog-title { color: #142c49; font-size: 36rpx; font-weight: 700; line-height: 1.4; }
|
||||
.loading-counts { display: flex; margin: 28rpx 0; padding: 24rpx 0; border-radius: 20rpx; background: #f3f7ff; }
|
||||
.loading-count { flex: 1; display: flex; align-items: center; flex-direction: column; gap: 8rpx; }
|
||||
.loading-count + .loading-count { border-left: 1px solid #dce6f6; }
|
||||
.loading-count-value { color: #1764ec; font-size: 48rpx; font-weight: 700; line-height: 1.2; }
|
||||
.loading-count-unit { font-size: 26rpx; font-weight: 500; }
|
||||
.loading-count-label { color: #72839b; font-size: 24rpx; }
|
||||
.loading-dialog-actions { display: flex; gap: 20rpx; margin-top: 32rpx; }
|
||||
.loading-dialog-actions button { flex: 1; min-width: 0; margin: 0; height: 88rpx; line-height: 88rpx; padding: 0 12rpx; border-radius: 16rpx; font-size: 30rpx; font-weight: 600; }
|
||||
.loading-dialog-actions button::after { border: 0; }
|
||||
.loading-cancel { color: #52657d; background: #f0f3f8; }
|
||||
.loading-confirm { color: #fff; background: #1764ec; }
|
||||
.loading-dialog-actions button[disabled] { opacity: .5; }
|
||||
.scan-notice-overlay { pointer-events: none; }
|
||||
.scan-notice-dialog { text-align: center; }
|
||||
.scan-notice-pallet { display: block; margin-top: 20rpx; color: #243c58; font-size: 30rpx; overflow-wrap: anywhere; }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<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-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" :disabled="submitting" @tap="login">{{ submitting ? '登录中...' : '登录' }}</button>
|
||||
</view>
|
||||
@@ -16,12 +17,25 @@
|
||||
<script>
|
||||
import { login as loginRequest } from '@/api/auth.js'
|
||||
import { DEV_LOGIN } from '@/config/runtime.js'
|
||||
import { session, restoreSession, signInWithTokens } from '@/stores/session.js'
|
||||
import { loadLoginPreference, saveLoginPreference, session, restoreSession, signInWithTokens } from '@/stores/session.js'
|
||||
import { requireOnline } from '@/utils/network.js'
|
||||
export default {
|
||||
data() { return { form: { userId: DEV_LOGIN.username, password: DEV_LOGIN.password }, error: '', submitting: false } },
|
||||
onLoad() { if (session.currentUser || restoreSession()) uni.reLaunch({ url: '/pages/home/home' }) },
|
||||
data() { return { form: { userId: DEV_LOGIN.username, password: DEV_LOGIN.password }, rememberAccount: true, error: '', submitting: false } },
|
||||
onLoad() {
|
||||
const preference = loadLoginPreference()
|
||||
this.rememberAccount = preference.rememberAccount
|
||||
if (preference.account) this.form.userId = preference.account
|
||||
if (session.currentUser || restoreSession()) uni.reLaunch({ url: '/pages/home/home' })
|
||||
},
|
||||
methods: {
|
||||
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 = '请输入工号')
|
||||
@@ -32,6 +46,7 @@
|
||||
requireOnline('当前网络不可用,无法登录')
|
||||
const tokens = await loginRequest(userId, this.form.password)
|
||||
signInWithTokens(tokens)
|
||||
saveLoginPreference(this.rememberAccount, userId)
|
||||
uni.reLaunch({ url: '/pages/home/home' })
|
||||
} catch (error) {
|
||||
console.error('[Overseas PDA login] failed', { message: error.message, network: error.network === true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -362,7 +362,10 @@ export default {
|
||||
const detail = await countWarehouseTransferCartons(this.selected.transferNo, { idempotencyKey: this.cartonRequestKey, boxes, discrepancyReason: this.countDiscrepancyReason, forecastPackageCount: Number(this.selected.packageCount) });
|
||||
if (!detail.cartons?.length || !detail.cartons.every(box => box.countedAt)) throw new Error('清点结果未确认,请刷新核实');
|
||||
uni.setStorageSync(this.cartonDraftKey(), { deletedBoxes: this.deletedCartonBoxes }); this.selected = detail; this.pallets = detail.pallets.map(restoredPallet); this.restoreCartons();
|
||||
} catch (error) { this.error = error } finally { this.countingCartons = false }
|
||||
} catch (error) { this.error = error } finally {
|
||||
this.countingCartons = false;
|
||||
this.cartonRequestKey = ''; this.cartonSignature = '';
|
||||
}
|
||||
},
|
||||
openPalletOperations() { uni.navigateTo({ url: '/pages/pallet/pallet' }) },
|
||||
destinationLabel(value, fallback = {}) { const code = String(value?.fbaWarehouseCode || fallback?.fbaWarehouseCode || '').trim(); if (code) return code; const type = value?.destinationType || fallback?.destinationType; return type === 'PRIVATE' ? '私人仓' : '目的仓待定' },
|
||||
|
||||
@@ -48,12 +48,14 @@ export function receivingProgress(session = receiving.current) {
|
||||
export function setCurrentReceiving(value, owner = '') {
|
||||
const nextOwner = photoOwner(value, owner)
|
||||
const previous = previewOwner && previewOwner === nextOwner ? receiving.photoSlots : []
|
||||
const keepUnsigned = receiving.current?.status === 'WAIT_SIGN' && value?.status === 'WAIT_SIGN'
|
||||
previewOwner = nextOwner
|
||||
receiving.current = value || null
|
||||
receiving.selected = value || null
|
||||
receiving.photoSlots = RECEIVING_PHOTO_ROLES.map((role) => {
|
||||
const photo = (value?.photos || []).find((item) => item.role === role)
|
||||
if (!photo) return null
|
||||
// 相机返回会重新读取 current;签收前已上传的文件尚未绑定,保留同一身份、同一柜会话的草稿。
|
||||
if (!photo) return keepUnsigned ? previous.find(item => item?.role === role && item.fileId) || null : null
|
||||
const old = previous.find(item => item?.role === role && String(item.fileId) === String(photo.fileId)
|
||||
&& item.downloadPath === photo.downloadPath)
|
||||
return old || { ...photo, fileId: String(photo.fileId), role, localPath: '', uploaded: true, status: 'idle' }
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resetFulfillmentSession } from './fulfillment.js'
|
||||
import { resetWarehouseWork } from './warehouse-work.js'
|
||||
|
||||
const SESSION_KEY = 'overseas-pda-auth-session'
|
||||
const LOGIN_PREFERENCE_KEY = 'overseas-pda-login-account-preference'
|
||||
let sessionRevision = 0
|
||||
export function getSessionRevision() { return sessionRevision }
|
||||
export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'
|
||||
@@ -96,6 +97,21 @@ 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, {
|
||||
|
||||
@@ -134,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;
|
||||
|
||||
+61
-13
@@ -1,3 +1,5 @@
|
||||
import appManifest from '@/manifest.json'
|
||||
|
||||
const updateEndpoint = String(import.meta.env.VITE_PDA_UPDATE_URL || '').trim()
|
||||
|
||||
let checking = false
|
||||
@@ -15,20 +17,40 @@ function getRuntimeInfo() {
|
||||
return
|
||||
}
|
||||
|
||||
// 标准真机基座的版本属于 HBuilderX;调试更新按当前工程版本模拟。
|
||||
const debugBase = import.meta.env.DEV || uni.getAppBaseInfo().packageName === 'io.dcloud.HBuilder'
|
||||
const runtime = {
|
||||
appid: String((debugBase ? appManifest.appid : plus.runtime.appid) || ''),
|
||||
appVersion: String((debugBase ? appManifest.versionName : plus.runtime.version) || ''),
|
||||
appVersionCode: Number(debugBase ? appManifest.versionCode : plus.runtime.versionCode)
|
||||
}
|
||||
if (!runtime.appid || !runtime.appVersion || !Number.isInteger(runtime.appVersionCode) || runtime.appVersionCode < 1) {
|
||||
reject(new Error('无法读取当前应用版本,请重新运行 App'))
|
||||
return
|
||||
}
|
||||
if (debugBase) {
|
||||
resolve({ ...runtime, wgtVersion: runtime.appVersion })
|
||||
return
|
||||
}
|
||||
|
||||
// 正式 APK 始终保留原生基座版本码,WGT 升级只改变资源版本。
|
||||
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 || '')
|
||||
})
|
||||
resolve({ ...runtime, wgtVersion: String(properties?.version || runtime.appVersion) })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeUpdate(payload, runtime) {
|
||||
const value = payload && typeof payload === 'object' ? payload : {}
|
||||
if (value.update !== true) return null
|
||||
if ('code' in value && String(value.code) !== '200') {
|
||||
const error = new Error(String(value.message || '检查更新失败,请稍后重试'))
|
||||
error.code = value.code
|
||||
error.requestId = value.requestId
|
||||
error.traceId = value.traceId
|
||||
throw error
|
||||
}
|
||||
if (typeof value.update !== 'boolean') throw new Error('更新服务返回了无效的版本信息')
|
||||
if (!value.update) return null
|
||||
|
||||
if (value.appid !== runtime.appid) throw new Error('更新包与当前应用不匹配')
|
||||
const packageType = String(value.packageType || '').toLowerCase()
|
||||
@@ -110,20 +132,45 @@ function downloadUpdate(update) {
|
||||
})
|
||||
}
|
||||
|
||||
function prepareUpdateFile(filePath, update) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fail = (cause) => {
|
||||
const error = new Error('更新包文件准备失败,请重新下载')
|
||||
error.stage = 'prepare'
|
||||
error.code = cause?.code
|
||||
reject(error)
|
||||
}
|
||||
// 下载接口以 /package 结尾,临时文件可能没有安装器要求的扩展名。
|
||||
const filename = `overseas-pda-${update.versionName}-${Date.now()}.${update.packageType}`
|
||||
plus.io.resolveLocalFileSystemURL(filePath, (entry) => {
|
||||
entry.getParent((parent) => {
|
||||
entry.moveTo(parent, filename, (file) => resolve(file.toLocalURL()), fail)
|
||||
}, fail)
|
||||
}, fail)
|
||||
})
|
||||
}
|
||||
|
||||
function installUpdate(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
plus.runtime.install(
|
||||
filePath,
|
||||
{ force: false },
|
||||
resolve,
|
||||
() => reject(new Error('更新包安装失败,请重新下载'))
|
||||
(cause) => {
|
||||
const code = cause?.code == null ? '' : `(${cause.code})`
|
||||
const reason = String(cause?.message || '请重新下载后重试')
|
||||
const error = new Error(`更新包安装失败${code}:${reason}`)
|
||||
error.stage = 'install'
|
||||
error.code = cause?.code
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function showFailure(error, mandatory) {
|
||||
const message = error?.message || '检查更新失败,请稍后重试'
|
||||
if (!mandatory) {
|
||||
if (!mandatory && error?.stage !== 'install' && error?.stage !== 'prepare') {
|
||||
uni.showToast({ title: message, icon: 'none', duration: 3000 })
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
@@ -132,9 +179,9 @@ function showFailure(error, mandatory) {
|
||||
uni.showModal({
|
||||
title: '更新失败',
|
||||
content: message,
|
||||
confirmText: '重新尝试',
|
||||
confirmText: mandatory ? '重新尝试' : '知道了',
|
||||
showCancel: false,
|
||||
complete: () => resolve(true)
|
||||
complete: () => resolve(mandatory)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -145,7 +192,8 @@ async function runUpdate(update) {
|
||||
|
||||
try {
|
||||
const filePath = await downloadUpdate(update)
|
||||
await installUpdate(filePath)
|
||||
const installPath = await prepareUpdateFile(filePath, update)
|
||||
await installUpdate(installPath)
|
||||
if (update.packageType === 'wgt') {
|
||||
uni.showModal({
|
||||
title: '更新完成',
|
||||
@@ -184,7 +232,7 @@ export async function checkForAppUpdate({ manual = false } = {}) {
|
||||
})
|
||||
if (response.statusCode !== 200) throw new Error('更新服务暂时不可用')
|
||||
|
||||
const update = normalizeUpdate(response.data?.data ?? response.data, runtime)
|
||||
const update = normalizeUpdate(response.data, runtime)
|
||||
if (update && isNewerUpdate(update, runtime)) {
|
||||
await runUpdate(update)
|
||||
} else if (manual) {
|
||||
|
||||
@@ -1,9 +1,82 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
|
||||
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
async function createUpdater({ development = true, packageName = 'io.dcloud.HBuilder',
|
||||
manifest = { appid: '__UNI__936D079', versionName: '0.0.4', versionCode: 4 },
|
||||
runtime = {}, resourceVersion = '0.0.4', response = { update: false }, acceptUpdate = false,
|
||||
downloadResult = { statusCode: 200, tempFilePath: '_doc/temp/download/package' },
|
||||
fileErrorAt, installError } = {}) {
|
||||
const calls = { requests: [], modals: [], toasts: [], downloads: [], moves: [], installs: [], restarts: 0 }
|
||||
const source = (await read('../src/utils/app-update.js'))
|
||||
.replace(/^import .*$/gm, '')
|
||||
.replaceAll('import.meta.env.VITE_PDA_UPDATE_URL', 'updateUrl')
|
||||
.replaceAll('import.meta.env.DEV', 'development')
|
||||
.replace('export async function checkForAppUpdate', 'async function checkForAppUpdate')
|
||||
const updater = runInNewContext(`${source}\n;({ getRuntimeInfo, checkForAppUpdate })`, {
|
||||
appManifest: manifest, development, updateUrl: 'https://updates.example.test/pda/overseas/latest',
|
||||
plus: {
|
||||
io: {
|
||||
resolveLocalFileSystemURL(path, success, fail) {
|
||||
const fileError = { code: 10, message: 'file operation failed' }
|
||||
if (fileErrorAt === 'resolve') return fail(fileError)
|
||||
const parent = {}
|
||||
success({
|
||||
getParent(success, fail) { fileErrorAt === 'parent' ? fail(fileError) : success(parent) },
|
||||
moveTo(destination, name, success, fail) {
|
||||
assert.equal(destination, parent)
|
||||
calls.moves.push({ path, name })
|
||||
if (fileErrorAt === 'move') return fail(fileError)
|
||||
success({ toLocalURL: () => `_doc/temp/download/${name}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
runtime: { appid: 'HBuilder', version: '5.24.0', versionCode: 52400,
|
||||
getProperty(_appid, callback) { callback({ version: resourceVersion }) },
|
||||
install(path, options, success, fail) {
|
||||
calls.installs.push({ path, options })
|
||||
if (installError) return fail(installError)
|
||||
if (!/\.(wgt|apk)$/.test(path)) return fail({ code: -1201, message: 'WGT/WGTU文件格式错误' })
|
||||
success()
|
||||
},
|
||||
restart() { calls.restarts += 1 }, ...runtime
|
||||
}
|
||||
},
|
||||
uni: {
|
||||
getAppBaseInfo: () => ({ packageName }),
|
||||
request(options) {
|
||||
calls.requests.push(options)
|
||||
options.success({ statusCode: 200, data: typeof response === 'function' ? response(options.data) : response })
|
||||
},
|
||||
downloadFile(options) {
|
||||
calls.downloads.push(options)
|
||||
options.success(downloadResult)
|
||||
options.complete()
|
||||
return { onProgressUpdate() {} }
|
||||
},
|
||||
showLoading() {}, hideLoading() {},
|
||||
showModal(options) {
|
||||
calls.modals.push(options)
|
||||
options.success?.({ confirm: acceptUpdate && options.confirmText === '立即更新' })
|
||||
options.complete?.()
|
||||
},
|
||||
showToast(options) { calls.toasts.push(options) }
|
||||
}
|
||||
})
|
||||
return { ...updater, calls }
|
||||
}
|
||||
|
||||
const publishedApk = { update: true, appid: '__UNI__936D079', packageType: 'apk',
|
||||
versionName: '0.0.5', versionCode: 5, minAppVersionCode: 5,
|
||||
downloadUrl: 'https://updates.example.test/overseas-0.0.5.apk', mandatory: false }
|
||||
|
||||
const publishedWgt = { ...publishedApk, packageType: 'wgt', minAppVersionCode: 4,
|
||||
downloadUrl: 'https://updates.example.test/overseas-0.0.5.wgt', releaseNotes: '更新测试' }
|
||||
|
||||
test('App-Plus checks the independent overseas update channel after launch', async () => {
|
||||
const [app, updater, env] = await Promise.all([
|
||||
read('../src/App.vue'),
|
||||
@@ -53,3 +126,148 @@ test('overseas profile exposes manual App update checking and the runtime versio
|
||||
assert.match(profile, /checkForAppUpdate\(\{ manual: true \}\)/)
|
||||
assert.match(profile, /#ifndef APP-PLUS[\s\S]*?请在 PDA App 中检查更新/)
|
||||
})
|
||||
|
||||
test('HBuilderX 0.0.4 checks the project version and offers a published 0.0.5 APK', async () => {
|
||||
const updater = await createUpdater({ response: query => query.appVersionCode < 5 ? publishedApk : { update: false } })
|
||||
await updater.checkForAppUpdate({ manual: true })
|
||||
const query = updater.calls.requests[0].data
|
||||
assert.equal(query.appid, '__UNI__936D079')
|
||||
assert.equal(query.appVersion, '0.0.4')
|
||||
assert.equal(query.appVersionCode, 4)
|
||||
assert.equal(query.wgtVersion, '0.0.4')
|
||||
assert.equal(updater.calls.modals[0]?.title, '发现更新 0.0.5')
|
||||
assert.equal(updater.calls.toasts.length, 0)
|
||||
})
|
||||
|
||||
test('standard HBuilder base uses project versions when running production WGT resources', async () => {
|
||||
const updater = await createUpdater({ development: false })
|
||||
const info = await updater.getRuntimeInfo()
|
||||
assert.equal(info.appid, '__UNI__936D079')
|
||||
assert.equal(info.appVersionCode, 4)
|
||||
assert.equal(info.appVersion, '0.0.4')
|
||||
})
|
||||
|
||||
test('released APK keeps its native base version after a WGT version increase', async () => {
|
||||
const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas',
|
||||
manifest: { appid: '__UNI__936D079', versionName: '0.0.5', versionCode: 5 },
|
||||
runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 4 }, resourceVersion: '0.0.5' })
|
||||
const info = await updater.getRuntimeInfo()
|
||||
assert.equal(info.appVersionCode, 4)
|
||||
assert.equal(info.appVersion, '0.0.4')
|
||||
assert.equal(info.wgtVersion, '0.0.5')
|
||||
})
|
||||
|
||||
test('HBuilderX 0.0.4 can detect a compatible published 0.0.5 WGT', async () => {
|
||||
const updater = await createUpdater({ response: publishedWgt })
|
||||
await updater.checkForAppUpdate({ manual: true })
|
||||
assert.equal(updater.calls.requests[0].data.appVersionCode, 4)
|
||||
assert.equal(updater.calls.requests[0].data.wgtVersion, '0.0.4')
|
||||
assert.equal(updater.calls.modals[0]?.title, '发现更新 0.0.5')
|
||||
assert.equal(updater.calls.modals[0]?.content, '更新测试')
|
||||
})
|
||||
|
||||
test('released APK 0.0.4 shows the published 0.0.5 WGT release notes', async () => {
|
||||
const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas',
|
||||
runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 4 }, response: publishedWgt })
|
||||
await updater.checkForAppUpdate({ manual: true })
|
||||
assert.equal(updater.calls.modals[0]?.title, '发现更新 0.0.5')
|
||||
assert.equal(updater.calls.modals[0]?.content, '更新测试')
|
||||
})
|
||||
|
||||
test('an APK 0.0.4 with WGT 0.0.5 does not offer that WGT again', async () => {
|
||||
const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas',
|
||||
runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 4 },
|
||||
resourceVersion: '0.0.5', response: publishedWgt })
|
||||
await updater.checkForAppUpdate({ manual: true })
|
||||
assert.equal(updater.calls.modals.length, 0)
|
||||
assert.equal(updater.calls.toasts[0]?.title, '当前已是最新版本')
|
||||
})
|
||||
|
||||
test('a WGT requiring APK 5 is rejected on a released APK 4 after resource updates', async () => {
|
||||
const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas',
|
||||
manifest: { appid: '__UNI__936D079', versionName: '0.0.5', versionCode: 5 },
|
||||
runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 4 },
|
||||
resourceVersion: '0.0.5', response: { ...publishedWgt, versionName: '0.0.6', minAppVersionCode: 5 } })
|
||||
await updater.checkForAppUpdate({ manual: true })
|
||||
assert.equal(updater.calls.modals.length, 0)
|
||||
assert.equal(updater.calls.toasts[0]?.title, '当前安装包不支持此热更新,请安装新版 APK')
|
||||
})
|
||||
|
||||
test('an unreadable native APK version is reported instead of claiming the latest version', async () => {
|
||||
const updater = await createUpdater({ development: false, packageName: 'com.baiyuelogistics.pda.overseas',
|
||||
runtime: { appid: '__UNI__936D079', version: '0.0.4', versionCode: 0 } })
|
||||
await updater.checkForAppUpdate({ manual: true })
|
||||
assert.equal(updater.calls.requests.length, 0)
|
||||
assert.equal(updater.calls.toasts[0]?.title, '无法读取当前应用版本,请重新运行 App')
|
||||
})
|
||||
|
||||
test('update service business errors are shown instead of claiming the latest version', async () => {
|
||||
const updater = await createUpdater({ response: { code: 'S0503', message: 'PDA 发布通道未初始化', data: null } })
|
||||
await updater.checkForAppUpdate({ manual: true })
|
||||
assert.equal(updater.calls.toasts[0]?.title, 'PDA 发布通道未初始化')
|
||||
assert.equal(updater.calls.modals.length, 0)
|
||||
})
|
||||
|
||||
test('invalid update responses are rejected instead of claiming the latest version', async () => {
|
||||
const updater = await createUpdater({ response: {} })
|
||||
await updater.checkForAppUpdate({ manual: true })
|
||||
assert.equal(updater.calls.toasts[0]?.title, '更新服务返回了无效的版本信息')
|
||||
})
|
||||
|
||||
test('unpublished versions remain unavailable to the device', async () => {
|
||||
const updater = await createUpdater({ response: { update: false } })
|
||||
await updater.checkForAppUpdate({ manual: true })
|
||||
assert.equal(updater.calls.modals.length, 0)
|
||||
assert.equal(updater.calls.toasts[0]?.title, '当前已是最新版本')
|
||||
})
|
||||
|
||||
test('a downloaded extensionless WGT is renamed before install and restarts on success', async () => {
|
||||
const updater = await createUpdater({ response: publishedWgt, acceptUpdate: true })
|
||||
await updater.checkForAppUpdate({ manual: true })
|
||||
assert.equal(updater.calls.downloads[0]?.url, publishedWgt.downloadUrl)
|
||||
assert.equal(updater.calls.moves[0]?.path, '_doc/temp/download/package')
|
||||
assert.match(updater.calls.moves[0]?.name, /^overseas-pda-0\.0\.5-\d+\.wgt$/)
|
||||
assert.equal(updater.calls.installs[0]?.path, `_doc/temp/download/${updater.calls.moves[0].name}`)
|
||||
assert.equal(updater.calls.installs[0]?.options.force, false)
|
||||
assert.equal(updater.calls.modals[1]?.title, '更新完成')
|
||||
assert.equal(updater.calls.restarts, 1)
|
||||
})
|
||||
|
||||
test('an extensionless APK receives the APK suffix and does not restart as a WGT', async () => {
|
||||
const updater = await createUpdater({ response: publishedApk, acceptUpdate: true })
|
||||
await updater.checkForAppUpdate({ manual: true })
|
||||
assert.match(updater.calls.installs[0]?.path, /\.apk$/)
|
||||
assert.equal(updater.calls.installs[0]?.options.force, false)
|
||||
assert.equal(updater.calls.restarts, 0)
|
||||
})
|
||||
|
||||
test('native installation errors remain visible with their actual reason and code', async () => {
|
||||
const updater = await createUpdater({ response: publishedWgt, acceptUpdate: true,
|
||||
installError: { code: -1205, message: 'WGT安装包中manifest.json文件的version版本不匹配' } })
|
||||
await updater.checkForAppUpdate({ manual: true })
|
||||
assert.equal(updater.calls.modals[1]?.title, '更新失败')
|
||||
assert.match(updater.calls.modals[1]?.content, /-1205/)
|
||||
assert.match(updater.calls.modals[1]?.content, /version版本不匹配/)
|
||||
assert.equal(updater.calls.restarts, 0)
|
||||
assert.equal(updater.calls.installs.length, 1)
|
||||
})
|
||||
|
||||
test('unreadable or unrenamable downloads never reach the native installer', async () => {
|
||||
for (const fileErrorAt of ['resolve', 'parent', 'move']) {
|
||||
const updater = await createUpdater({ response: publishedWgt, acceptUpdate: true, fileErrorAt })
|
||||
await updater.checkForAppUpdate({ manual: true })
|
||||
assert.equal(updater.calls.installs.length, 0, fileErrorAt)
|
||||
assert.equal(updater.calls.restarts, 0, fileErrorAt)
|
||||
assert.equal(updater.calls.modals[1]?.title, '更新失败', fileErrorAt)
|
||||
assert.match(updater.calls.modals[1]?.content, /更新包文件准备失败/, fileErrorAt)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unsuccessful HTTP download never prepares or installs an update', async () => {
|
||||
const updater = await createUpdater({ response: publishedWgt, acceptUpdate: true,
|
||||
downloadResult: { statusCode: 404, tempFilePath: '_doc/temp/download/package' } })
|
||||
await updater.checkForAppUpdate({ manual: true })
|
||||
assert.equal(updater.calls.moves.length, 0)
|
||||
assert.equal(updater.calls.installs.length, 0)
|
||||
assert.match(updater.calls.toasts[0]?.title, /下载失败(404)/)
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ test('picking separates source facts, current quantity and completion while reta
|
||||
assert.match(template, /aria-label="本次拣货箱数"[^>]+:disabled="busy"[^>]+@input="updatePackages"/)
|
||||
assert.match(template, /name="dispatch"[^>]+:active="activeTab === 'picking'"[^>]+@submit="handleScan"/)
|
||||
assert.match(template, /:disabled="!canPick \|\| busy" @tap="submitPick"/)
|
||||
assert.match(template, /:disabled="!canComplete \|\| busy" @tap="prepareLoading"/)
|
||||
assert.match(template, /:disabled="!canComplete \|\| busy \|\| loading \|\| loadingPreview !== null" :loading="requestBusy" @tap="prepareLoading"/)
|
||||
assert.match(template, /@tap="selectPallet\(item\)"/)
|
||||
assert.match(page, /\.picking-fact > text:last-child \{[^}]*overflow-wrap: anywhere/)
|
||||
})
|
||||
|
||||
@@ -107,6 +107,84 @@ test('actual loading confirmation leaves reference measurements blank and blocks
|
||||
assert.equal(state.loadingRows[0].deliveryIndex, null)
|
||||
Object.assign(state.loadingRows[0], { weightKg: 92, volumeCbm: 1.5 }); await state.completeLoading(); assert.equal(posts, 0); assert.match(state.error.message, /卸货站/)
|
||||
})
|
||||
|
||||
test('loading completion opens a callback-based confirmation with pallet and package counts before submitting', async () => {
|
||||
storage(); let modal, posts = 0, reads = 0
|
||||
uni.showModal = options => { modal = options }
|
||||
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'two-pallets', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [
|
||||
{ palletNo: 'PALLET-1', pickedPackages: '12', deliveryIndex: 1 },
|
||||
{ palletNo: 'PALLET-2', pickedPackages: '10', deliveryIndex: 1 }
|
||||
] }
|
||||
const state = page({ getDispatchLoading: async () => { reads++; return preview },
|
||||
completeDispatchLoading: async () => { posts++; return proof(lastCommand) },
|
||||
getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING' }) })
|
||||
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] })
|
||||
await state.prepareLoading()
|
||||
assert.equal(state.busy, false)
|
||||
assert.equal(modal.title, '确认装车')
|
||||
assert.equal(modal.content, '已拣托盘 2 托\n已拣箱数 22 箱')
|
||||
assert.equal(posts, 0)
|
||||
await state.prepareLoading()
|
||||
assert.equal(reads, 1)
|
||||
await modal.success({ confirm: true })
|
||||
assert.equal(posts, 1)
|
||||
assert.equal(state.current.stage, 'AFTER_LOADING')
|
||||
assert.equal(state.loadingPreview, null)
|
||||
})
|
||||
|
||||
test('cancelling or failing to open loading confirmation releases the preview for a fresh attempt', async () => {
|
||||
storage(); let modal, reads = 0
|
||||
uni.showModal = options => { modal = options }
|
||||
const preview = { dispatchNo: 'PC/1', recordVersion: 0, pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ pickedPackages: 2, deliveryIndex: 1 }] }
|
||||
const state = page({ getDispatchLoading: async () => { reads++; return preview } })
|
||||
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] })
|
||||
await state.prepareLoading()
|
||||
await modal.success({ cancel: true })
|
||||
assert.equal(state.loadingPreview, null)
|
||||
await state.prepareLoading()
|
||||
modal.fail({ errMsg: '弹窗打开失败' })
|
||||
assert.equal(state.loadingPreview, null)
|
||||
assert.equal(state.error.message, '弹窗打开失败')
|
||||
await state.prepareLoading()
|
||||
assert.equal(reads, 3)
|
||||
})
|
||||
|
||||
test('loading confirmation ignores responses and callbacks after leaving the task', async () => {
|
||||
storage(); let resolve, modal, posts = 0
|
||||
uni.showModal = options => { modal = options }
|
||||
const preview = { dispatchNo: 'PC/1', recordVersion: 0, pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ pickedPackages: 2, deliveryIndex: 1 }] }
|
||||
const state = page({ getDispatchLoading: () => new Promise(done => { resolve = done }), completeDispatchLoading: async () => { posts++ } })
|
||||
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] })
|
||||
const reading = state.prepareLoading()
|
||||
assert.equal(state.requestBusy, true)
|
||||
state.generation++
|
||||
resolve(preview)
|
||||
await reading
|
||||
assert.equal(modal, undefined)
|
||||
assert.equal(state.loadingPreview, null)
|
||||
const secondReading = state.prepareLoading()
|
||||
resolve(preview)
|
||||
await secondReading
|
||||
state.generation++
|
||||
await modal.success({ confirm: true })
|
||||
assert.equal(posts, 0)
|
||||
assert.equal(state.loadingPreview, null)
|
||||
})
|
||||
|
||||
test('failed loading submission closes the native confirmation and allows a new read', async () => {
|
||||
storage(); let modal, reads = 0
|
||||
uni.showModal = options => { modal = options }
|
||||
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'manifest', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ pickedPackages: 2, deliveryIndex: 1 }] }
|
||||
const state = page({ getDispatchLoading: async () => { reads++; return preview }, completeDispatchLoading: async () => { throw new Error('装车提交失败') } })
|
||||
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] })
|
||||
await state.prepareLoading()
|
||||
await modal.success({ confirm: true })
|
||||
assert.equal(state.error.message, '装车提交失败')
|
||||
assert.equal(state.loadingPreview, null)
|
||||
assert.equal(state.busy, false)
|
||||
await state.prepareLoading()
|
||||
assert.equal(reads, 2)
|
||||
})
|
||||
test('missing versions surface a local error before picking or loading completion and never produce a write', async () => {
|
||||
storage(); const state = page(); state.selectPallet(state.current.items[0]); state.current.recordVersion = undefined
|
||||
await state.submitPick(); assert.match(state.error.message, /版本/); assert.equal('pendingCommand' in fulfillment.dispatch, false)
|
||||
@@ -211,12 +289,32 @@ test('missing appointment blocks approval while preserving rejection to customer
|
||||
storage(); const sent = []
|
||||
const state = page({ reviewDispatchLoading: async (...args) => { sent.push(args); return proof(lastCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
|
||||
state.current = reviewTask({ actions: { LOADING_REVIEW: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } })
|
||||
assert.equal(state.reviewTime('2026-10-21T17:00:00Z'), '2026-10-22 01:00')
|
||||
assert.equal(state.reviewTime('2026-10-21T17:00:00Z'), '美东 2026-10-21 13:00\n美西 2026-10-21 10:00')
|
||||
assert.equal(state.reviewTime(null), '未填写')
|
||||
state.reviewReason = '请客服补齐预约时间'
|
||||
await state.submitReview('APPROVED'); assert.equal(sent.length, 0); assert.match(state.error.message, /预约收货时间尚未确认/)
|
||||
await state.submitReview('REJECTED'); assert.equal(sent.length, 1); assert.equal(sent[0][1].decision, 'REJECTED')
|
||||
})
|
||||
|
||||
for (const [instant, expected] of [
|
||||
['2026-11-06T00:00:00+08:00', '美东 2026-11-05 11:00\n美西 2026-11-05 08:00'],
|
||||
['2026-11-05T16:00:00Z', '美东 2026-11-05 11:00\n美西 2026-11-05 08:00'],
|
||||
['2026-07-01T15:00:00Z', '美东 2026-07-01 11:00\n美西 2026-07-01 08:00'],
|
||||
['2026-03-08T06:30:00Z', '美东 2026-03-08 01:30\n美西 2026-03-07 22:30'],
|
||||
['2026-03-08T07:30:00Z', '美东 2026-03-08 03:30\n美西 2026-03-07 23:30'],
|
||||
['2026-03-08T10:30:00Z', '美东 2026-03-08 06:30\n美西 2026-03-08 03:30'],
|
||||
['2026-11-01T05:30:00Z', '美东 2026-11-01 01:30\n美西 2026-10-31 22:30'],
|
||||
['2026-11-01T07:30:00Z', '美东 2026-11-01 02:30\n美西 2026-11-01 00:30'],
|
||||
['2026-11-01T10:30:00Z', '美东 2026-11-01 05:30\n美西 2026-11-01 02:30'],
|
||||
]) test(`review times use eastern and pacific daylight-saving rules for ${instant}`, () => {
|
||||
assert.equal(page().reviewTime(instant), expected)
|
||||
})
|
||||
|
||||
test('review times preserve missing values and reject invalid or timezone-free timestamps', () => {
|
||||
const state = page()
|
||||
for (const missing of [null, undefined, '']) assert.equal(state.reviewTime(missing), '未填写')
|
||||
for (const invalid of ['not-a-date', 'invalidZ', '2026-11-06T00:00:00', '2026-11-06']) assert.equal(state.reviewTime(invalid), '时间无效')
|
||||
})
|
||||
test('review task is reachable in the existing list and blocks actual source selection until approval', async () => {
|
||||
storage(); const state = page({ listDispatchTasks: async () => [reviewTask()], getDispatchTask: async () => reviewTask() })
|
||||
await state.loadTasks(); assert.equal(state.tasks.length, 1); assert.equal(state.stageLabel(state.tasks[0]), '待理货审核')
|
||||
|
||||
@@ -25,9 +25,9 @@ const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
|
||||
|
||||
test('outbound list and detail lead with the outbound number and label the task number separately', async () => {
|
||||
const page = await read('../src/pages/outbound/outbound.vue')
|
||||
assert.match(page, /class="business-code">\{\{ task\.outboundNo \|\| '—' \}\}/)
|
||||
assert.match(page, /class="pda-task-no">\{\{ task\.outboundNo \|\| '—' \}\}/)
|
||||
assert.match(page, /class="business-code">\{\{ current\.outboundNo \|\| '—' \}\}/)
|
||||
assert.match(page, /任务号 \{\{ task\.taskNo \|\| '—' \}\}/)
|
||||
assert.match(page, /任务号 \{\{ task\.taskNo \}\}/)
|
||||
assert.match(page, /任务号 \{\{ current\.taskNo \|\| '—' \}\}/)
|
||||
assert.match(page, /@tap="openTask\(task\.taskNo\)"/)
|
||||
})
|
||||
@@ -248,10 +248,12 @@ test('native action gates reject strings and retain server block reasons', () =>
|
||||
async function dispatchScanHarness(items) {
|
||||
const source = (await read('../src/pages/dispatch/dispatch.vue')).match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||
const draft={palletCode:'OLD',dispatchItemId:'old',cartonCode:''};let sounds=0
|
||||
const deps={PortalHeader:{},ScanTargetInput:{},DispatchHandoverStatus:{},DispatchLoadingDocuments:{},fulfillment:{dispatch:draft},updateFulfillmentDraft:(_,value)=>Object.assign(draft,value),playSuccessSound:()=>{sounds++}}
|
||||
const deps={PortalHeader:{},ScanTargetInput:{},DispatchHandoverStatus:{},DispatchLoadingDocuments:{},fulfillment:{dispatch:draft},updateFulfillmentDraft:(_,value)=>Object.assign(draft,value),playSuccessSound:()=>{sounds++},clearTimeout:()=>{},setTimeout:()=>0}
|
||||
const definition=new Function(...Object.keys(deps),source.replace(/^\s*import .*$/gm,'').replace('export default','return'))(...Object.values(deps))
|
||||
const state={activeTab:'picking',current:{dispatchNo:'DS-TEST',stage:'LOADING',actions:{PICK:{allowed:true}},items:items.map(item=>({sourceKey:`source-${item.dispatchItemId}`,sourceBusinessType:'RELABEL_ORDER',sourceBusinessNo:`RL-${item.dispatchItemId}`,positions:[],...item,returnCartons:item.returnCartons||[]}))},error:{message:'previous error'},successMessage:'',setError(error){this.error={message:error.message}}}
|
||||
state.gate=definition.methods.gate.bind(state);state.selectPallet=definition.methods.selectPallet.bind(state);state.chooseSource=definition.methods.chooseSource.bind(state)
|
||||
state.clearScanNotice = definition.methods.clearScanNotice.bind(state)
|
||||
state.showScanNotice = definition.methods.showScanNotice.bind(state)
|
||||
return {state,draft,scan:definition.methods.handleScan.bind(state),sounds:()=>sounds}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import test from 'node:test'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { loadEnv } from 'vite'
|
||||
const read = path => readFile(new URL(path, import.meta.url), 'utf8')
|
||||
test('PDA H5 has no container publishing entry', async () => {
|
||||
const files = await readdir(new URL('../deploy/', import.meta.url))
|
||||
@@ -28,10 +32,12 @@ test('host nginx serves uploaded files and proxies only the API prefix', async (
|
||||
test('only H5 production overrides the toolchain asset base', async () => {
|
||||
const source = (await read('../vite.config.js'))
|
||||
.replace(/^import .*$/gm, '')
|
||||
.replaceAll('import.meta.url', 'configUrl')
|
||||
.replace('export default ', '')
|
||||
for (const platform of ['h5', 'app', 'app-plus', 'app-harmony', undefined]) {
|
||||
for (const mode of ['production', 'development']) {
|
||||
const factory = runInNewContext(source, {
|
||||
URL, fileURLToPath, configUrl: new URL('../vite.config.js', import.meta.url).href,
|
||||
defineConfig: value => value,
|
||||
loadEnv: () => ({}),
|
||||
uni: () => ({}),
|
||||
@@ -45,8 +51,10 @@ test('only H5 production overrides the toolchain asset base', async () => {
|
||||
})
|
||||
|
||||
test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async () => {
|
||||
const source = (await read('../vite.config.js')).replace(/^import .*$/gm, '').replace('export default ', '')
|
||||
const source = (await read('../vite.config.js')).replace(/^import .*$/gm, '')
|
||||
.replaceAll('import.meta.url', 'configUrl').replace('export default ', '')
|
||||
const factory = runInNewContext(source, {
|
||||
URL, fileURLToPath, configUrl: new URL('../vite.config.js', import.meta.url).href,
|
||||
defineConfig: value => value, loadEnv: () => ({ API_PROXY_TARGET: 'https://gateway.example.com' }),
|
||||
uni: () => ({}), process: { env: { UNI_PLATFORM: 'h5' }, cwd: () => '/project' }
|
||||
})
|
||||
@@ -57,3 +65,24 @@ test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async
|
||||
assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('App update configuration loads from the project when HBuilderX starts elsewhere', async t => {
|
||||
const fixturePrefix = join(tmpdir(), 'overseas-pda-env-')
|
||||
const fixtureDir = await mkdtemp(fixturePrefix)
|
||||
assert.ok(resolve(fixtureDir).startsWith(resolve(fixturePrefix)))
|
||||
t.after(() => rm(fixtureDir, { recursive: true, force: true }))
|
||||
const endpoint = 'https://updates.example.test/pda/overseas/latest'
|
||||
await writeFile(join(fixtureDir, '.env.local'), `VITE_PDA_UPDATE_URL=${endpoint}\n`)
|
||||
const source = (await read('../vite.config.js')).replace(/^import .*$/gm, '')
|
||||
.replaceAll('import.meta.url', 'configUrl').replace('export default ', '')
|
||||
const factory = runInNewContext(source, {
|
||||
URL, fileURLToPath, configUrl: pathToFileURL(join(fixtureDir, 'vite.config.js')).href,
|
||||
defineConfig: value => value, loadEnv, uni: () => ({}),
|
||||
process: { env: { UNI_PLATFORM: 'app' }, cwd: () => join(fixtureDir, 'HBuilderX') }
|
||||
})
|
||||
for (const mode of ['development', 'production']) {
|
||||
const config = factory({ mode })
|
||||
assert.equal(JSON.parse(config.define['import.meta.env.VITE_PDA_UPDATE_URL']), endpoint)
|
||||
assert.equal(resolve(config.envDir), resolve(fixtureDir))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse, compileTemplate } from '@vue/compiler-sfc'
|
||||
import {
|
||||
PDA_AUTHORITY, PDA_ROLE, loadLoginPreference, saveLoginPreference,
|
||||
restoreSession, session, signInWithTokens, signOut
|
||||
} from '../src/stores/session.js'
|
||||
|
||||
const source = readFileSync(new URL('../src/pages/login/login.vue', import.meta.url), 'utf8')
|
||||
const { descriptor, errors } = parse(source)
|
||||
const preferenceKey = 'overseas-pda-login-account-preference'
|
||||
|
||||
function storage() {
|
||||
const values = new Map()
|
||||
globalThis.uni = {
|
||||
setStorageSync: (key, value) => values.set(key, JSON.parse(JSON.stringify(value))),
|
||||
getStorageSync: (key) => values.get(key),
|
||||
removeStorageSync: (key) => values.delete(key),
|
||||
reLaunch() {}
|
||||
}
|
||||
signOut()
|
||||
return values
|
||||
}
|
||||
|
||||
function tokens() {
|
||||
const claims = {
|
||||
user_id: 42, membership_id: 84, tenant_id: 0, app_code: 'PLATFORM',
|
||||
overseas_warehouse_id: 900, exp: Math.floor(Date.now() / 1000) + 3600
|
||||
}
|
||||
return {
|
||||
accessToken: `header.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.signature`,
|
||||
refreshToken: 'refresh-42', roles: [PDA_ROLE], authorities: [PDA_AUTHORITY]
|
||||
}
|
||||
}
|
||||
|
||||
function page(overrides = {}) {
|
||||
const dependencies = {
|
||||
DEV_LOGIN: { username: '', password: '' }, session, restoreSession, signInWithTokens,
|
||||
loadLoginPreference, saveLoginPreference, requireOnline() {},
|
||||
loginRequest: async () => tokens(), console: { error() {} }, ...overrides
|
||||
}
|
||||
const script = descriptor.script.content.replace(/^\s*import .*$/gm, '').replace('export default', 'return')
|
||||
const definition = new Function(...Object.keys(dependencies), script)(...Object.values(dependencies))
|
||||
const vm = definition.data()
|
||||
for (const [name, method] of Object.entries(definition.methods)) vm[name] = method.bind(vm)
|
||||
definition.onLoad.call(vm)
|
||||
return vm
|
||||
}
|
||||
|
||||
test('login template compiles with an account switch and separate row and switch events', () => {
|
||||
assert.deepEqual(errors, [])
|
||||
const compiled = compileTemplate({ source: descriptor.template.content, filename: 'login.vue', id: 'login' })
|
||||
assert.deepEqual(compiled.errors, [])
|
||||
assert.match(descriptor.template.content, /@tap="toggleRememberAccount"/)
|
||||
assert.match(descriptor.template.content, /<switch[^>]*:checked="rememberAccount"[^>]*@change="handleRememberChange"[^>]*@tap\.stop/)
|
||||
})
|
||||
|
||||
test('new installations default to remembering the account and an empty login form', () => {
|
||||
storage()
|
||||
assert.deepEqual(loadLoginPreference(), { rememberAccount: true, account: '' })
|
||||
const vm = page()
|
||||
assert.equal(vm.rememberAccount, true)
|
||||
assert.deepEqual(vm.form, { userId: '', password: '' })
|
||||
})
|
||||
|
||||
test('successful login remembers the trimmed account across sign out and page reopening', async () => {
|
||||
const values = storage()
|
||||
let credentials
|
||||
const vm = page({ loginRequest: async (...args) => { credentials = args; return tokens() } })
|
||||
vm.form = { userId: ' picker-42 ', password: 'test-password' }
|
||||
await vm.login()
|
||||
assert.equal(vm.error, '')
|
||||
assert.deepEqual(credentials, ['picker-42', 'test-password'])
|
||||
assert.deepEqual(values.get(preferenceKey), { rememberAccount: true, account: 'picker-42' })
|
||||
assert.equal(session.currentUser.id, '42')
|
||||
signOut()
|
||||
const reopened = page()
|
||||
assert.equal(reopened.form.userId, 'picker-42')
|
||||
assert.equal(reopened.form.password, '')
|
||||
assert.equal(reopened.rememberAccount, true)
|
||||
})
|
||||
|
||||
test('switch and row can each clear the saved account immediately without logging in', () => {
|
||||
storage()
|
||||
saveLoginPreference(true, 'picker-42')
|
||||
const vm = page()
|
||||
vm.handleRememberChange({ detail: { value: false } })
|
||||
assert.equal(vm.rememberAccount, false)
|
||||
assert.deepEqual(loadLoginPreference(), { rememberAccount: false, account: '' })
|
||||
assert.equal(page().form.userId, '')
|
||||
assert.equal(page().rememberAccount, false)
|
||||
vm.toggleRememberAccount()
|
||||
saveLoginPreference(true, 'picker-42')
|
||||
vm.toggleRememberAccount()
|
||||
assert.equal(vm.rememberAccount, false)
|
||||
assert.deepEqual(loadLoginPreference(), { rememberAccount: false, account: '' })
|
||||
})
|
||||
|
||||
test('login with remembering disabled leaves the next login form empty', async () => {
|
||||
storage()
|
||||
const vm = page()
|
||||
vm.toggleRememberAccount()
|
||||
vm.form = { userId: 'picker-42', password: 'test-password' }
|
||||
await vm.login()
|
||||
assert.equal(vm.error, '')
|
||||
assert.equal(session.currentUser.id, '42')
|
||||
signOut()
|
||||
const reopened = page()
|
||||
assert.equal(reopened.rememberAccount, false)
|
||||
assert.deepEqual(reopened.form, { userId: '', password: '' })
|
||||
})
|
||||
|
||||
test('failed authentication and rejected operator tokens preserve the last successful account', async () => {
|
||||
storage()
|
||||
saveLoginPreference(true, 'previous-picker')
|
||||
for (const loginRequest of [
|
||||
async () => { throw new Error('密码错误') },
|
||||
async () => ({ ...tokens(), authorities: [] })
|
||||
]) {
|
||||
const vm = page({ loginRequest })
|
||||
vm.form = { userId: 'other-picker', password: 'test-password' }
|
||||
await vm.login()
|
||||
assert.ok(vm.error)
|
||||
assert.equal(vm.submitting, false)
|
||||
assert.equal(session.currentUser, null)
|
||||
assert.deepEqual(loadLoginPreference(), { rememberAccount: true, account: 'previous-picker' })
|
||||
}
|
||||
})
|
||||
@@ -52,8 +52,8 @@ test('login has no committed demo credentials and inbound operation implements t
|
||||
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL/)
|
||||
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL/)
|
||||
assert.match(operation, /startScanBroadcast/)
|
||||
assert.match(operation, /palletWorkflowActive = Boolean\(this\.activeInbound \|\| this\.resolvedPallet\)/)
|
||||
assert.match(operation, /queuedTarget: scan\.target/)
|
||||
assert.match(operation, /this\.current === current && this\.activeInbound === inbound && this\.activeInbound\.inboundCode === inboundCode/)
|
||||
assert.match(operation, /dispatchHardwareScan\(scan\)[\s\S]*this\.activeInbound && this\.activeTarget === 'pallet'[\s\S]*this\.resolvePallet\(scan\.value\)/)
|
||||
for (const action of ['prepareReceivingContainer', 'getCurrentReceiving', 'resolveCurrentCarton', 'resolveCurrentPallet', 'receiveCurrentBulk', 'receiveCurrentCarton', 'receiveCurrentWholePallet', 'completeCurrentReceiving']) assert.match(operation, new RegExp(action))
|
||||
for (const path of ['/candidates', '/prepare', '/unload-photos/files', '/sign', '/cartons/resolve', '/pallets/resolve', '/receipts/bulk', '/receipts/carton', '/receipts/pallet', '/complete']) assert.match(inbound, new RegExp(path.replaceAll('/', '\\/')))
|
||||
assert.match(inbound, /current\('\/pallets\/resolve'\)/)
|
||||
@@ -70,7 +70,7 @@ test('login has no committed demo credentials and inbound operation implements t
|
||||
assert.match(operation, /clearReceiptContext\(\)[\s\S]*activeScanTarget = 'carton'[\s\S]*scheduleScanDrain/)
|
||||
assert.match(operation, /name="pallet"[\s\S]*@activate="activeTarget = 'pallet'"/)
|
||||
assert.match(operation, /receiving\.activeScanTarget = 'pallet'/, '普通FBA或中转解析后直接进入托盘扫码以减少操作')
|
||||
assert.match(operation, /\[Overseas PDA scan\] dispatch/)
|
||||
assert.match(operation, /this\.scanQueue\.push\(scan\)[\s\S]*this\.dispatchHardwareScan\(scan\)/)
|
||||
assert.match(operation, /\[Overseas PDA cargo\] failed[\s\S]*requestId[\s\S]*traceId/)
|
||||
for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) assert.match(adapter, new RegExp(role))
|
||||
for (const forbidden of ['customerId', 'planNo', 'allocationId', 'warehouseId', 'locationId']) assert.doesNotMatch(inbound, new RegExp(forbidden))
|
||||
@@ -125,8 +125,8 @@ test('candidate containers use compact card navigation and a small refresh actio
|
||||
assert.match(operation, /class="container-card container-card-compact"[\s\S]*container-card-disabled[\s\S]*@tap="openContainer\(item\)"/)
|
||||
assert.match(operation, /class="container-detail-link"/)
|
||||
assert.doesNotMatch(operation, /<button class="primary-button"[^>]*openContainer\(item\.containerNo\)/)
|
||||
assert.match(operation, /class="list-toolbar"><text>共 \{\{ candidates\.length \}\} 柜<\/text>/)
|
||||
assert.match(operation, /\.list-toolbar button\{[^}]*height:28px[^}]*min-height:28px/)
|
||||
assert.match(operation, /class="list-toolbar">\s*<text>共 \{\{ candidates\.length \}\} 柜<\/text>/)
|
||||
assert.match(operation, /\.list-toolbar button\{[^}]*height:36px[^}]*min-height:36px/)
|
||||
assert.match(operation, /class="candidate-facts"[\s\S]*总订单[\s\S]*入仓码[\s\S]*已生成计划[\s\S]*未操作/)
|
||||
assert.match(operation, /class="candidate-destinations"/)
|
||||
assert.match(operation, /计划到仓:\{\{ formatPlannedArrival\(item\.plannedArrivalAt\) \}\}/)
|
||||
@@ -189,7 +189,7 @@ test('return packing is a separate same-customer inventory operation', async ()
|
||||
|
||||
test('home reuses the persisted warehouse profile instead of requesting it on every show', async () => {
|
||||
const [home, inbound, operation] = await Promise.all([read('../src/pages/home/home.vue'), read('../src/api/inbound.js'), read('../src/pages/operation/operation.vue')])
|
||||
assert.match(home, /if \(!this\.user\?\.warehouseCode \|\| !this\.user\?\.warehouseName\) this\.loadWarehouseProfile\(\)/)
|
||||
assert.match(home, /if \(!this\.user\?\.warehouseCode \|\| !this\.user\?\.warehouseName\) await this\.loadWarehouseProfile\(\)/)
|
||||
assert.match(inbound, /let warehouseProfileRequest = null/)
|
||||
assert.match(inbound, /if \(!warehouseProfileRequest\)[\s\S]*\.finally\(\(\) => \{ warehouseProfileRequest = null \}\)/)
|
||||
assert.match(operation, /await this\.refreshBoundWarehouse\(\)[\s\S]*await this\.loadCandidates\(false, false\)/)
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as store from '../src/stores/receiving.js'
|
||||
import { normalizeReceivingSession, toReceivingError } from '../src/api/receiving-adapter.js'
|
||||
import { normalizeReceivingSession, toReceivingError, RECEIVING_PHOTO_ROLES } from '../src/api/receiving-adapter.js'
|
||||
|
||||
const source = readFileSync(new URL('../src/pages/operation/operation.vue', import.meta.url), 'utf8').match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||
const defer = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b }); return { promise, resolve, reject } }
|
||||
@@ -13,7 +13,7 @@ const sessionValue = (patch = {}) => normalizeReceivingSession({ containerNo: 'C
|
||||
|
||||
function page(overrides = {}) {
|
||||
const deps = { ...store, session: { tenantId: '100', currentUser: { id: 'operator', warehouseId: '900' } },
|
||||
PortalHeader: {}, ReceivingSessionSummary: {}, ScanTargetInput: {}, requireSession: () => true,
|
||||
PortalHeader: {}, ReceivingSessionSummary: {}, ScanTargetInput: {}, RECEIVING_PHOTO_ROLES, requireSession: () => true,
|
||||
startScanBroadcast: () => () => {}, playErrorSound() {}, playSuccessSound() {},
|
||||
receivingPhase: store.receivingPhase, toReceivingError: error => error, ...overrides }
|
||||
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
|
||||
@@ -25,6 +25,73 @@ function page(overrides = {}) {
|
||||
return { vm, definition, deps }
|
||||
}
|
||||
|
||||
test('camera resume retains all unsigned uploads by role and clears them across session or identity changes', async () => {
|
||||
store.resetReceivingSession()
|
||||
const unsigned = sessionValue({ status: 'WAIT_SIGN', photos: [], domesticLoadingPhotos: [] })
|
||||
let nextFile = 10
|
||||
const { vm, deps } = page({ uploadCurrentUnloadPhoto: async () => ({ fileId: String(++nextFile) }) })
|
||||
await vm.applyCurrentReceiving(unsigned)
|
||||
await vm.replacePhoto(0, 'camera:plate')
|
||||
vm.suspendReceiving(); vm._pageActive = true
|
||||
await vm.applyCurrentReceiving(structuredClone(unsigned))
|
||||
await vm.replacePhoto(1, 'camera:seal')
|
||||
vm.suspendReceiving(); vm._pageActive = true
|
||||
await vm.applyCurrentReceiving(structuredClone(unsigned))
|
||||
await vm.replacePhoto(2, 'camera:door')
|
||||
assert.deepEqual(vm.photoSlots.map(photo => photo.fileId), ['11', '12', '13'])
|
||||
assert.equal(vm.photosReady, true)
|
||||
await vm.replacePhoto(1, 'camera:seal-again')
|
||||
assert.deepEqual(vm.photoSlots.map(photo => photo.fileId), ['11', '14', '13'])
|
||||
deps.session.currentUser = { ...deps.session.currentUser, id: 'other-operator' }; vm.user = deps.session.currentUser
|
||||
await vm.applyCurrentReceiving(structuredClone(unsigned))
|
||||
assert.deepEqual(vm.photoSlots, [null, null, null])
|
||||
await vm.replacePhoto(0, 'camera:new-owner')
|
||||
await vm.applyCurrentReceiving({ ...unsigned, preparedAt: '2026-11-05T10:00:00' })
|
||||
assert.deepEqual(vm.photoSlots, [null, null, null])
|
||||
await vm.replacePhoto(0, 'camera:new-session')
|
||||
await vm.applyCurrentReceiving({ ...unsigned, preparedAt: '2026-11-05T10:00:00', status: 'SIGNED' })
|
||||
assert.deepEqual(vm.photoSlots, [null, null, null])
|
||||
vm.suspendReceiving()
|
||||
})
|
||||
|
||||
test('camera callbacks before and after onShow keep every unsigned photo after preparing a container', async t => {
|
||||
store.resetReceivingSession()
|
||||
const current = sessionValue({ status: 'WAIT_SIGN', preparedAt: '2026-11-06T17:24:00.124', photos: [], domesticLoadingPhotos: [] })
|
||||
const { vm, definition } = page({
|
||||
prepareReceivingContainer: async () => ({ ...current, preparedAt: '2026-11-06T17:24:00.123789' }),
|
||||
getCurrentReceiving: async () => structuredClone(current),
|
||||
uploadCurrentUnloadPhoto: async path => ({ fileId: path.split(':')[1] })
|
||||
})
|
||||
t.after(() => vm.suspendReceiving())
|
||||
const previousUni = globalThis.uni
|
||||
t.after(() => { globalThis.uni = previousUni })
|
||||
vm.refreshBoundWarehouse = async () => {}
|
||||
let refresh, upload, camera
|
||||
const bootstrap = vm.bootstrap, replacePhoto = vm.replacePhoto
|
||||
vm.bootstrap = () => { refresh = bootstrap(); return refresh }
|
||||
vm.replacePhoto = (...args) => { upload = replacePhoto(...args); return upload }
|
||||
globalThis.uni = { chooseImage: options => { camera = options; definition.onHide.call(vm) } }
|
||||
store.receiving.selected = { ...current, sourceContainerBookingId: 'BOOKING-A', allPlansReady: true, operable: true }
|
||||
await vm.prepareContainer()
|
||||
vm.bootstrapped = true
|
||||
|
||||
vm.choosePhoto(0)
|
||||
camera.success({ tempFilePaths: ['camera:plate'] })
|
||||
definition.onShow.call(vm)
|
||||
await upload
|
||||
assert.equal(vm.photoSlots[0].fileId, 'plate')
|
||||
|
||||
for (const [index, role] of [[1, 'seal'], [2, 'door']]) {
|
||||
vm.choosePhoto(index)
|
||||
definition.onShow.call(vm)
|
||||
await refresh
|
||||
camera.success({ tempFilePaths: [`camera:${role}`] })
|
||||
await upload
|
||||
}
|
||||
assert.deepEqual(vm.photoSlots.map(photo => photo?.fileId), ['plate', 'seal', 'door'])
|
||||
assert.equal(vm.photosReady, true)
|
||||
})
|
||||
|
||||
test('same current readback retains three signed previews; collapsed unversioned domestic images issue no downloads', async () => {
|
||||
store.resetReceivingSession(); const calls = []
|
||||
const download = async path => { calls.push(path); return `local:${path}` }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { parse, compileStyle, compileTemplate } from '@vue/compiler-sfc'
|
||||
|
||||
const read = (relative) => readFileSync(new URL(relative, import.meta.url), 'utf8')
|
||||
const page = read('../src/pages/operation/operation.vue')
|
||||
@@ -90,10 +91,35 @@ test('domestic cabinet photos are read-only, previewable and non-blocking in sig
|
||||
assert.doesNotMatch(page, /domesticLoadingPhotos[\s\S]{0,120}customer/)
|
||||
})
|
||||
|
||||
test('fixed receiving action leaves enough scroll-safe space above the PDA bottom bar', () => {
|
||||
assert.match(page, /v-if="canFinishOnsite" class="sticky-action-dock"><view class="sticky-action one"/)
|
||||
test('receiving finish action remains in document flow after the scan and save controls', () => {
|
||||
assert.match(page, /v-if="canFinishOnsite" class="receiving-finish-card"/)
|
||||
assert.match(page, /allInboundsReconciled \? '完成整柜入库' : '现场收货结束(标记差异)'/)
|
||||
assert.match(page, /receiving-content-with-action\{[^}]*padding-bottom:calc\(152px \+ env\(safe-area-inset-bottom,0px\)\)/)
|
||||
assert.ok(page.indexOf('class="receiving-finish-card"') > page.indexOf('@tap="submitReceipt"'))
|
||||
assert.match(page, /:class="allInboundsReconciled \? 'primary-button' : 'secondary-button'"/)
|
||||
assert.match(page, /receiving-content\{[^}]*padding-bottom:calc\(24px \+ env\(safe-area-inset-bottom,0px\)\)/)
|
||||
assert.match(page, /primary-button,\.secondary-button,\.ghost-button\{[^}]*align-items:center;justify-content:center[^}]*line-height:normal;text-align:center/)
|
||||
assert.match(page, /sticky-action button\{[^}]*align-items:center;justify-content:center;line-height:normal;text-align:center/)
|
||||
const { descriptor, errors } = parse(page)
|
||||
assert.deepEqual(errors, [])
|
||||
const style = compileStyle({ source: descriptor.styles[0].content, filename: 'operation.vue', id: 'receiving', scoped: true })
|
||||
assert.deepEqual(style.errors, [])
|
||||
const dockPositions = []
|
||||
style.rawResult.root.walkRules(rule => {
|
||||
if (rule.selector.includes('.sticky-action-dock')) rule.walkDecls('position', declaration => dockPositions.push(declaration.value))
|
||||
if (rule.selector.includes('.receiving-finish-card')) rule.walkDecls('position', declaration => assert.equal(declaration.value, 'static'))
|
||||
})
|
||||
assert.deepEqual(dockPositions, ['static'])
|
||||
})
|
||||
|
||||
test('pallet quantity input reserves room above the keyboard and confirms through receipt submission', () => {
|
||||
const { descriptor, errors } = parse(page)
|
||||
assert.deepEqual(errors, [])
|
||||
const compiled = compileTemplate({ source: descriptor.template.content, filename: 'operation.vue', id: 'receiving' })
|
||||
assert.deepEqual(compiled.errors, [])
|
||||
const quantityInput = descriptor.template.content.match(/<input[^>]*class="quantity-input"[^>]*>/)?.[0] || ''
|
||||
assert.match(quantityInput, /v-model\.number="packageCount"/)
|
||||
assert.match(quantityInput, /type="number" inputmode="numeric"/)
|
||||
assert.match(quantityInput, /:adjust-position="true" :cursor-spacing="120" :auto-blur="true"/)
|
||||
assert.match(quantityInput, /@focus="activeTarget = 'quantity'" @confirm="submitReceipt"/)
|
||||
assert.match(page, /class="quantity-unit">箱/)
|
||||
})
|
||||
|
||||
+5
-1
@@ -1,5 +1,8 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const projectDir = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
function loggedProxy(name, options) {
|
||||
return {
|
||||
@@ -24,9 +27,10 @@ function loggedProxy(name, options) {
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const env = loadEnv(mode, projectDir, '')
|
||||
const apiTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
|
||||
return {
|
||||
envDir: projectDir,
|
||||
base: process.env.UNI_PLATFORM === 'h5' && mode === 'production' ? '/' : undefined,
|
||||
plugins: [uni()],
|
||||
define: {
|
||||
|
||||
Reference in New Issue
Block a user