6 Commits
14 changed files with 400 additions and 41 deletions
+2
View File
@@ -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,
+1 -1
View File
@@ -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">
-2
View File
@@ -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)) }
+21 -13
View File
@@ -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>
@@ -186,7 +183,7 @@ 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) 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 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 +225,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,11 +281,12 @@ 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; }
+3 -1
View File
@@ -310,7 +310,9 @@
@media(max-width:360px){.photo-role{grid-template-columns:68px minmax(0,1fr) 64px;gap:7px;padding:8px}.photo-preview{width:68px;height:64px}.photo-actions button{min-height:34px;padding:0 2px;line-height:34px}.photo-state{font-size:9px;line-height:13px}.domestic-photo-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
.receiving-page{padding-bottom:env(safe-area-inset-bottom,0px)}
.receiving-content{box-sizing:border-box;padding-bottom:calc(24px + env(safe-area-inset-bottom,0px))}
.receiving-content-with-action{padding-bottom:calc(152px + env(safe-area-inset-bottom,0px))}
.receiving-content-with-action{padding-bottom:calc(24px + env(safe-area-inset-bottom,0px))}
.sticky-action-dock{position:static;max-width:none;margin:12px 0 0;padding:0;background:transparent}
.sticky-action{padding:10px;box-shadow:none}
.primary-button,.secondary-button,.ghost-button{display:flex;box-sizing:border-box;align-items:center;justify-content:center;padding-top:0;padding-bottom:0;line-height:normal;text-align:center}
.sticky-action button{display:flex;align-items:center;justify-content:center;line-height:normal;text-align:center}
.receiving-list-page{background:#f1f5fd}
+3 -1
View File
@@ -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' }
+61 -13
View File
@@ -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) {
+218
View File
@@ -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)/)
})
+21 -1
View File
@@ -211,12 +211,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]), '待理货审核')
+31 -2
View File
@@ -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))
}
})
+31 -2
View File
@@ -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,35 @@ 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('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}` }
+3 -2
View File
@@ -90,10 +90,11 @@ 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', () => {
test('receiving finish action remains in document flow after the scan and save controls', () => {
assert.match(page, /v-if="canFinishOnsite" class="sticky-action-dock"><view class="sticky-action one"/)
assert.match(page, /allInboundsReconciled \? '完成整柜入库' : '现场收货结束(标记差异)'/)
assert.match(page, /receiving-content-with-action\{[^}]*padding-bottom:calc\(152px \+ env\(safe-area-inset-bottom,0px\)\)/)
assert.match(page, /sticky-action-dock\{position:static;max-width:none/)
assert.match(page, /receiving-content-with-action\{[^}]*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/)
})
+5 -1
View File
@@ -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: {