From 3c283ada464cd08e6961ec17931e070ec20d0a08 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 26 Aug 2026 15:59:08 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 3 +-
src/api/auth.js | 8 ++-
src/api/inbound.js | 3 +-
src/api/overseas.js | 32 ++++++++++
src/api/receiving-adapter.js | 2 +-
src/config/runtime.js | 9 +++
src/data/overseas-workbench.js | 20 +++---
src/manifest.json | 6 +-
src/pages/home/home.vue | 4 +-
src/pages/login/login.vue | 2 +
src/pages/module/module.vue | 4 +-
src/pages/operation/operation.vue | 42 ++++++++-----
src/stores/receiving.js | 4 +-
src/utils/network.js | 5 +-
tests/overseas-request.test.mjs | 15 ++++-
tests/project-contract.test.mjs | 4 +-
tests/receiving-adapter.test.mjs | 3 +-
tests/receiving-photo-ui-contract.test.mjs | 73 ++++++++++++++++++++++
tests/workbench-availability.test.mjs | 3 +-
19 files changed, 196 insertions(+), 46 deletions(-)
create mode 100644 tests/receiving-photo-ui-contract.test.mjs
diff --git a/AGENTS.md b/AGENTS.md
index ccd0a8b..56bd78b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,7 +37,7 @@
| AppID、平台图标与打包资源 | `src/manifest.json`、`static/app-icons/` | Android/iOS 尺寸映射由 `tests/app-icons.test.mjs` 锁定;独立 AppID、签名和发布通道不得复用国内 PDA |
| 登录、刷新与会话 | `src/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` | 国内 Auth 签发 Token;API 根见 `src/config/runtime.js` |
| 海外仓工作台与模块入口 | `src/pages/home/home.vue`、`src/pages/module/module.vue` | `src/data/overseas-workbench.js` 是模块、作业可用性和原型数据入口 |
-| 柜号签收与入仓真实作业 | `src/pages/operation/operation.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js` | Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`;公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` |
+| 柜号签收、照片与入仓真实作业 | `src/pages/operation/operation.vue`、`src/api/inbound.js`、`src/api/receiving-adapter.js`、`src/api/overseas.js`、`src/stores/receiving.js` | 三个固定照片角色各一张,支持拍照/相册、鉴权预览及更换失败保留原图;Backend `lastleg-service/inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,公网前缀 `/api/pda/v1/last-leg/receiving-containers/**` |
| 库内、托盘、换标与现场查询待接前端 | `src/data/overseas-workbench.js` 中对应 `available: false` 入口 | Backend 已有 `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/` 与 `overseas-pda-operations-v1.yaml`;本应用新增专用 API/状态恢复和测试前不得启用 |
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
| 作业记录与人员 | `src/pages/history/history.vue`、`src/pages/profile/profile.vue` | 会话隔离、退出清理和后续真实作业历史契约 |
@@ -47,6 +47,7 @@
- `src/api/inbound.js` 已接入待卸柜候选、柜详情/prepare、current 恢复、三类照片签收、入仓码下 SKU/箱唛解析、实物托盘解析、FBA bulk/OVERSEAS carton 收货及整柜完成;响应与 404/409 判定集中在 `src/api/receiving-adapter.js`,双 Token 刷新与海外 401 单次重放由 `src/api/overseas.js` 统一处理。
- 请求身份和资源范围只取 Token 与 current 会话;前端不得提交 `customerId`、`planNo`、`allocationId`、`warehouseId` 或 `locationId`。409 必须引导账号恢复当前柜,完成前不得切柜。
+- 签收照片固定为三个角色且每个角色只有一个当前文件;上传成功后使用服务端 `downloadPath` 在当前会话内鉴权读取。更换失败时保留原 `fileId` 和可提交状态,无原图的失败槽不得计入 `photosReady`;本地临时路径仅用于本次设备预览,不作为跨会话文件事实。
- 库内、换标与现场查询只有视觉原型依据,当前工作台入口保持禁用;新增真实作业必须建立 `/api/pda/**` 业务 API/DTO、`lastleg:pda:operate` 权限、Token 冻结仓库范围、错误态和契约测试,不得把平台、客户或内部接口直接暴露给 PDA。
- 已有海外入仓 PDA Controller 统一位于 Lastleg `/api/pda/v1/last-leg/inbound-plans/{planNo}/**`;浏览器或设备请求仍必须经 `gateway-overseas` 的显式 PDA 路由,不得直连 Lastleg,也不得改走国内 Gateway。
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
diff --git a/src/api/auth.js b/src/api/auth.js
index 02cf1c6..aea54d5 100644
--- a/src/api/auth.js
+++ b/src/api/auth.js
@@ -2,12 +2,17 @@ import { API_ROOT } from '../config/runtime.js'
function authRequest(path, data) {
return new Promise((resolve, reject) => {
+ const url = `${API_ROOT}${path}`
+ console.info('[Overseas PDA auth] request', { method: 'POST', url })
uni.request({
- url: `${API_ROOT}${path}`,
+ url,
method: 'POST',
data,
success(response) {
const body = response.data || {}
+ console.info('[Overseas PDA auth] response', { url, statusCode: response.statusCode, code: body.code,
+ requestId: body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'],
+ traceId: body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] })
if (String(body.code ?? response.statusCode) !== '200') {
const error = new Error(body.message || '认证服务请求失败')
error.code = body.code || response.statusCode
@@ -19,6 +24,7 @@ function authRequest(path, data) {
resolve(body.data)
},
fail(error) {
+ console.error('[Overseas PDA auth] network failure', { url, errMsg: error.errMsg || 'unknown' })
const next = new Error(error.errMsg || '网络连接失败')
next.network = true
reject(next)
diff --git a/src/api/inbound.js b/src/api/inbound.js
index d0014f4..6758163 100644
--- a/src/api/inbound.js
+++ b/src/api/inbound.js
@@ -1,4 +1,4 @@
-import { overseasRequest, overseasUpload } from '@/api/overseas.js'
+import { overseasDownload, overseasRequest, overseasUpload } from '@/api/overseas.js'
import { normalizeReceivingCandidate, normalizeReceivingSession } from '@/api/receiving-adapter.js'
const RECEIVING_BASE = '/pda/v1/last-leg/receiving-containers'
@@ -8,6 +8,7 @@ export const listReceivingCandidates = async () => (await overseasRequest('GET',
export const getReceivingContainer = async (containerNo) => normalizeReceivingSession(await overseasRequest('GET', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}`))
export const prepareReceivingContainer = async (containerNo, request) => normalizeReceivingSession(await overseasRequest('POST', `${RECEIVING_BASE}/${encodeURIComponent(containerNo)}/prepare`, request))
export const uploadCurrentUnloadPhoto = (filePath) => overseasUpload(current('/unload-photos/files'), filePath)
+export const downloadCurrentUnloadPhoto = (downloadPath) => overseasDownload(downloadPath)
export const signCurrentReceiving = async (request) => normalizeReceivingSession(await overseasRequest('POST', current('/sign'), request))
export const getCurrentReceiving = async () => normalizeReceivingSession(await overseasRequest('GET', current()))
export const resolveCurrentCarton = (cartonMark) => overseasRequest('POST', current('/cartons/resolve'), { cartonMark })
diff --git a/src/api/overseas.js b/src/api/overseas.js
index 41d4e4c..50c99e8 100644
--- a/src/api/overseas.js
+++ b/src/api/overseas.js
@@ -78,6 +78,25 @@ function rawUpload(path, filePath, name, token) {
})
}
+function overseasUrl(path) {
+ const value = String(path || '')
+ const normalized = /\/(?:api|overseas-api)$/.test(session.apiBase)
+ ? value.replace(/^\/api(?=\/)/, '')
+ : value
+ return `${session.apiBase}${normalized}`
+}
+
+function rawDownload(path, token) {
+ return new Promise((resolve, reject) => {
+ uni.downloadFile({
+ url: overseasUrl(path),
+ header: { Authorization: `Bearer ${token}` },
+ success: resolve,
+ fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '签收照片下载失败'), { network: true }))
+ })
+ })
+}
+
export async function overseasUpload(path, filePath, name = 'file', retry = true) {
const attemptedToken = session.token
let response = await rawUpload(path, filePath, name, attemptedToken)
@@ -92,4 +111,17 @@ export async function overseasUpload(path, filePath, name = 'file', retry = true
return body.data
}
+export async function overseasDownload(path, retry = true) {
+ const attemptedToken = session.token
+ let response = await rawDownload(path, attemptedToken)
+ if (retry && unauthorized(response)) {
+ const replacementToken = await tokenAfterUnauthorized(attemptedToken)
+ response = await rawDownload(path, replacementToken)
+ }
+ if (response?.statusCode !== 200 || !response?.tempFilePath) {
+ throw errorFrom({ ...response, data: {} }, '签收照片下载失败')
+ }
+ return response.tempFilePath
+}
+
export function resetOverseasRequestStateForTest() { refreshPromise = null }
diff --git a/src/api/receiving-adapter.js b/src/api/receiving-adapter.js
index ede9f27..c6874e9 100644
--- a/src/api/receiving-adapter.js
+++ b/src/api/receiving-adapter.js
@@ -30,7 +30,7 @@ export function normalizeReceivingSession(value) {
: []
return {
...value,
- photos: RECEIVING_PHOTO_ROLES.map((role) => photos.find((photo) => photo?.role === role)).filter(Boolean),
+ photos: RECEIVING_PHOTO_ROLES.map((role) => photos.find((photo) => photo?.role === role && photo?.fileId)).filter(Boolean),
totalPackages: number(value.totalPackages),
receivedPackages: number(value.receivedPackages),
pendingPackages: number(value.pendingPackages),
diff --git a/src/config/runtime.js b/src/config/runtime.js
index cab2108..26cdef6 100644
--- a/src/config/runtime.js
+++ b/src/config/runtime.js
@@ -5,6 +5,8 @@ const development = Boolean(runtimeEnvironment.DEV)
let selectedAuthApiRoot = configuredAuthApiRoot || '/api'
let selectedOverseasApiRoot = configuredOverseasApiRoot || '/overseas-api'
+const nativeRuntime = typeof plus !== 'undefined'
+ || (typeof location !== 'undefined' && location.protocol === 'file:')
// H5 must stay same-origin and use Vite's CORS-safe proxy; App builds keep the absolute API roots above.
// #ifdef H5
@@ -12,6 +14,13 @@ selectedAuthApiRoot = runtimeEnvironment.VITE_H5_AUTH_API_BASE_URL || '/api'
selectedOverseasApiRoot = runtimeEnvironment.VITE_H5_OVERSEAS_API_BASE_URL || '/overseas-api'
// #endif
+// HBuilderX/App 基座 can expose a file:// location; never allow a relative API
+// root there, even if a stale H5 bundle or compiler flag was reused.
+if (nativeRuntime) {
+ selectedAuthApiRoot = configuredAuthApiRoot || selectedAuthApiRoot
+ selectedOverseasApiRoot = configuredOverseasApiRoot || selectedOverseasApiRoot
+}
+
export const API_ROOT = String(selectedAuthApiRoot).replace(/\/$/, '')
export const OVERSEAS_API_ROOT = String(selectedOverseasApiRoot).replace(/\/$/, '')
export const DEV_LOGIN = Object.freeze({
diff --git a/src/data/overseas-workbench.js b/src/data/overseas-workbench.js
index 5163a81..37ebbb7 100644
--- a/src/data/overseas-workbench.js
+++ b/src/data/overseas-workbench.js
@@ -1,21 +1,21 @@
export const MODULES = [
{ key: 'inbound', name: '入仓作业', icon: '入', description: '单账号锁定一柜,完成柜号级入库闭环', operations: [
- { name: '拆柜收货', icon: '卸', iconType: 'download', description: '三类照片签收,FBA 按 SKU 箱数、海外仓逐箱收货', available: true, badge: '真实接口' }
+ { name: '拆柜收货', icon: '卸', iconType: 'download', description: '三类照片签收,FBA 按 SKU 箱数、海外仓逐箱收货', available: true }
] },
{ key: 'inventory', name: '库内作业', icon: '库', description: '盘点、移库与托盘管理', operations: [
- { name: '库存盘点', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', available: false, badge: '待 PDA 接口' },
- { name: '商品移库', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', available: false, badge: '待 PDA 接口' },
- { name: '托盘管理', icon: '托', iconType: 'grid', description: '组托、拆托及托盘状态核对', available: false, badge: '待 PDA 接口' }
+ { name: '库存盘点', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', available: false },
+ { name: '商品移库', icon: '移', iconType: 'loop', description: '扫描来源与目标库位完成移库', available: false },
+ { name: '托盘管理', icon: '托', iconType: 'grid', description: '组托、拆托及托盘状态核对', available: false }
] },
{ key: 'relabel', name: '换标作业', icon: '换', description: '换标收货、执行与复核', operations: [
- { name: '换标收货', icon: '收', iconType: 'download', description: '扫描换标单并确认待处理库存', available: false, badge: '待 PDA 接口' },
- { name: '换标执行', icon: '换', iconType: 'compose', description: '扫描旧标和新标记录换标结果', available: false, badge: '待 PDA 接口' },
- { name: '换标复核', icon: '核', iconType: 'checkbox', description: '复核标签和商品关联关系', available: false, badge: '待 PDA 接口' }
+ { name: '换标收货', icon: '收', iconType: 'download', description: '扫描换标单并确认待处理库存', available: false },
+ { name: '换标执行', icon: '换', iconType: 'compose', description: '扫描旧标和新标记录换标结果', available: false },
+ { name: '换标复核', icon: '核', iconType: 'checkbox', description: '复核标签和商品关联关系', available: false }
] },
{ key: 'query', name: '现场查询', icon: '查', description: '库存、库位与托盘查询', operations: [
- { name: '库存查询', icon: '存', iconType: 'search', description: '扫描商品或箱码查询可用库存', available: false, badge: '待 PDA 接口' },
- { name: '库位查询', icon: '位', iconType: 'location', description: '扫描库位查看容量和在库明细', available: false, badge: '待 PDA 接口' },
- { name: '托盘查询', icon: '托', iconType: 'flag', description: '扫描托盘码查看绑定库存', available: false, badge: '待 PDA 接口' }
+ { name: '库存查询', icon: '存', iconType: 'search', description: '扫描商品或箱码查询可用库存', available: false },
+ { name: '库位查询', icon: '位', iconType: 'location', description: '扫描库位查看容量和在库明细', available: false },
+ { name: '托盘查询', icon: '托', iconType: 'flag', description: '扫描托盘码查看绑定库存', available: false }
] }
]
diff --git a/src/manifest.json b/src/manifest.json
index dd1c697..5828a87 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -81,7 +81,11 @@
"vueVersion" : "3",
"h5" : {
"devServer" : {
- "port" : 5176
+ "proxy": {
+
+ },
+ "port" : 5176,
+ "https": false
}
}
}
diff --git a/src/pages/home/home.vue b/src/pages/home/home.vue
index a4bddf1..1f4056d 100644
--- a/src/pages/home/home.vue
+++ b/src/pages/home/home.vue
@@ -8,8 +8,8 @@
diff --git a/src/pages/login/login.vue b/src/pages/login/login.vue
index d42105e..ce38a78 100644
--- a/src/pages/login/login.vue
+++ b/src/pages/login/login.vue
@@ -34,6 +34,8 @@
signInWithTokens(tokens)
uni.reLaunch({ url: '/pages/home/home' })
} catch (error) {
+ console.error('[Overseas PDA login] failed', { message: error.message, network: error.network === true,
+ code: error.code, requestId: error.requestId, traceId: error.traceId })
this.error = error.network ? '当前网络不可用,请联网后重试' : (error.message || '登录失败')
} finally {
this.submitting = false
diff --git a/src/pages/module/module.vue b/src/pages/module/module.vue
index d7034d7..240cd66 100644
--- a/src/pages/module/module.vue
+++ b/src/pages/module/module.vue
@@ -4,8 +4,8 @@
{{ currentModule.icon }}{{ currentModule.name }}{{ currentModule.description }}
业务功能
-
- 只有拆柜收货已接海外 PDA 真实接口;其余入口在专用接口、权限与仓库范围契约完成前保持禁用。
+
+ 当前仅开放拆柜收货,其他作业暂未开放。
diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue
index a271f09..6018261 100644
--- a/src/pages/operation/operation.vue
+++ b/src/pages/operation/operation.vue
@@ -3,10 +3,7 @@
-
- {{ pageTitle }}{{ pageSubtitle }}
- 真实接口
-
+ {{ pageTitle }}{{ pageSubtitle }}
正在恢复当前柜作业…
@@ -36,12 +33,19 @@
- 卸货签收照片需完成 3 个现场照片槽位{{ uploadedPhotoCount }} / 3
+ 卸货签收照片三个固定槽位各上传 1 张,可拍照或从相册选择{{ uploadedPhotoCount }} / 3
-
+
+ {{ step.label }}{{ photoSlots[index]?.fileId ? '已上传' : '必传' }}
+
+
+
+ {{ photoSlots[index].status === 'uploading' ? '上传中…' : photoSlots[index].status === 'error' ? (photoSlots[index].fileId ? '更换失败,已保留原图' : '上传失败') : '点击查看大图' }}
+ {{ photoSlots[index].errorMessage || '请重试' }}
+
+ 尚未添加
+
+
@@ -101,8 +105,8 @@
-
- {{ operationName }}当前功能尚无海外 PDA 专用业务契约待 PDA 接口
+
+ {{ operationName }}当前功能暂未开放
功能暂未开放现有平台、客户或内部接口不能由仓库作业员直接调用。扫码不会提交,也不会产生模拟成功结果。
@@ -118,7 +122,7 @@
import { idempotencyKey, receiving, receivingPhase, receivingProgress, releaseIdempotencyKey, resetReceivingDraft, selectedInbound, setCurrentReceiving } from '@/stores/receiving.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
- import { completeCurrentReceiving, getCurrentReceiving, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, resolveCurrentCarton, resolveCurrentPallet, resolveCurrentSku, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
+ import { completeCurrentReceiving, downloadCurrentUnloadPhoto, getCurrentReceiving, getReceivingContainer, listReceivingCandidates, prepareReceivingContainer, receiveCurrentBulk, receiveCurrentCarton, resolveCurrentCarton, resolveCurrentPallet, resolveCurrentSku, signCurrentReceiving, uploadCurrentUnloadPhoto } from '@/api/inbound.js'
const SessionSummary = {
props: { session: Object },
@@ -141,7 +145,7 @@
resolvedCarton() { return receiving.resolvedCarton }, resolvedSku() { return receiving.resolvedSku }, resolvedPallet() { return receiving.resolvedPallet }, lastReceipt() { return receiving.lastReceipt },
activeTarget: { get() { return receiving.activeScanTarget }, set(value) { receiving.activeScanTarget = value } },
phase() { return this.detailOpen && !this.current ? 'detail' : receivingPhase(this.current) }, progress() { return receivingProgress(this.current) }, activeInbound() { return selectedInbound(this.current) },
- uploadedPhotoCount() { return this.photoSlots.filter((item) => item?.fileId).length }, photosReady() { return this.uploadedPhotoCount === 3 },
+ uploadedPhotoCount() { return this.photoSlots.filter((item) => item?.fileId).length }, photosReady() { return this.uploadedPhotoCount === 3 && !this.photoSlots.some((item) => item?.status === 'uploading') },
canSubmitReceipt() { if (!this.activeInbound || !this.resolvedPallet?.compatible) return false; if (this.activeInbound.destinationType === 'FBA') { const count = Number(this.packageCount); return this.resolvedSku?.inboundCode === this.activeInbound.inboundCode && Boolean(this.resolvedSku?.skuCode) && Number.isInteger(count) && count > 0 && count <= Number(this.activeInbound.pendingPackages) } return this.resolvedCarton?.inboundCode === this.activeInbound.inboundCode && Boolean(this.resolvedCarton?.cartonMark) },
allInboundsReconciled() { const values = this.current?.inbounds || []; return values.length > 0 && values.every((item) => Number(item.pendingPackages) === 0) },
pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] },
@@ -158,13 +162,17 @@
handleHardwareScan(value) { if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') { this.containerInput = value; return this.openContainer(value) } if (this.phase !== 'receive') return; if (this.activeTarget === 'carton' && this.activeInbound?.destinationType === 'OVERSEAS') { this.cartonInput = value; return this.resolveCarton(value) } if (this.activeTarget === 'sku' && this.activeInbound?.destinationType === 'FBA') { this.skuInput = value; return this.resolveSku(value) } this.palletInput = value; return this.resolvePallet(value) },
async run(action, work) { this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { this.error = error; playErrorSound(); this.showToast(error.message || `${action}失败`, 'error'); throw error } finally { this.busy = false } },
setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : (selectedInbound(value)?.destinationType === 'OVERSEAS' ? 'carton' : 'sku') },
- async bootstrap() { this.loading = true; this.error = null; this.detailOpen = false; try { const current = await getCurrentReceiving(); setCurrentReceiving(current); this.setScanTargetForCurrent(current) } catch (error) { if (isNoCurrentReceiving(error)) { resetReceivingDraft(); await this.loadCandidates(false) } else this.error = error } finally { this.loading = false } },
- async recoverCurrentAfterConflict(error) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); setCurrentReceiving(current); this.setScanTargetForCurrent(current); this.detailOpen = false; this.error = null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { this.error = recoveryError; return false } },
+ async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { photo.status = 'restoring'; try { photo.localPath = await downloadCurrentUnloadPhoto(photo.downloadPath); photo.status = 'success' } catch (error) { photo.status = 'error'; photo.errorMessage = error.message || '照片加载失败'; this.error = error } })) },
+ async applyCurrentReceiving(value) { setCurrentReceiving(value); await this.restorePhotoPreviews(); this.setScanTargetForCurrent(value) },
+ async bootstrap() { this.loading = true; this.error = null; this.detailOpen = false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current) } catch (error) { if (isNoCurrentReceiving(error)) { resetReceivingDraft(); await this.loadCandidates(false) } else this.error = error } finally { this.loading = false } },
+ async recoverCurrentAfterConflict(error) { if (!isReceivingConflict(error)) return false; try { const current = await getCurrentReceiving(); await this.applyCurrentReceiving(current); this.detailOpen = false; this.error = null; this.showToast(`账号已有未完成柜 ${current.containerNo},已返回当前柜`, 'warning'); return true } catch (recoveryError) { this.error = recoveryError; return false } },
async loadCandidates(feedback = true) { try { const work = () => listReceivingCandidates(); receiving.candidates = feedback ? await this.run('刷新待卸柜', work) : await work() } catch (_) {} },
async openContainer(raw) { const containerNo = String(raw?.detail?.value ?? raw ?? this.containerInput).trim(); if (!containerNo || this.busy) return; try { receiving.selected = await this.run('查询柜号', () => getReceivingContainer(containerNo)); this.containerInput = containerNo; this.detailOpen = true; playSuccessSound() } catch (error) { await this.recoverCurrentAfterConflict(error) } },
cancelDetail() { this.detailOpen = false; receiving.selected = null },
async prepareContainer() { if (!this.selected?.containerNo) return; const key = idempotencyKey('prepare', { containerNo: this.selected.containerNo }); try { const value = await this.run('选择当前柜', () => prepareReceivingContainer(this.selected.containerNo, { idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); this.setScanTargetForCurrent(value); this.detailOpen = false } catch (error) { await this.recoverCurrentAfterConflict(error) } },
- choosePhoto(index) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: ['camera', 'album'], success: async ({ tempFilePaths }) => { const path = tempFilePaths?.[0]; if (!path) return; try { const file = await this.run('上传签收照片', () => uploadCurrentUnloadPhoto(path)); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true }) } catch (_) {} } }) },
+ choosePhotos(index, sourceType) { if (this.busy) return; uni.chooseImage({ count: 1, sourceType: [sourceType], success: ({ tempFilePaths }) => { const path = tempFilePaths?.[0]; if (path) this.replacePhoto(index, path) } }) },
+ async replacePhoto(index, path) { const previous = receiving.photoSlots[index]; receiving.photoSlots.splice(index, 1, { ...(previous || {}), role: this.photoSteps[index].role, status: 'uploading', errorMessage: '' }); this.busy = true; this.error = null; try { const file = await uploadCurrentUnloadPhoto(path); receiving.photoSlots.splice(index, 1, { ...file, fileId: String(file.fileId), role: this.photoSteps[index].role, localPath: path, uploaded: true, status: 'success' }); this.showToast(`${this.photoSteps[index].label}上传成功`, 'success') } catch (error) { receiving.photoSlots.splice(index, 1, previous?.fileId ? { ...previous, status: 'error', errorMessage: error.message || '更换失败,已保留原图' } : { role: this.photoSteps[index].role, localPath: path, uploaded: false, status: 'error', errorMessage: error.message || '上传失败,请重试' }); this.error = error; playErrorSound() } finally { this.busy = false } },
+ previewPhoto(index) { const path = this.photoSlots[index]?.localPath; if (!path) return; uni.previewImage({ urls: [path], current: path }) },
async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: String(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('签收当前柜', () => signCurrentReceiving({ photos, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value); receiving.activeScanTarget = selectedInbound(value)?.destinationType === 'OVERSEAS' ? 'carton' : 'sku' } catch (_) {} },
chooseInbound(item) { receiving.selectedInboundCode = item.inboundCode; receiving.resolvedCarton = null; receiving.resolvedSku = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.skuInput = ''; this.palletInput = ''; this.packageCount = Math.max(1, Number(item.pendingPackages || 1)); receiving.activeScanTarget = item.destinationType === 'OVERSEAS' ? 'carton' : 'sku' },
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark || this.busy) return; try { const value = await this.run('解析箱唛', () => resolveCurrentCarton(cartonMark)); receiving.resolvedCarton = value; receiving.selectedInboundCode = value.inboundCode; receiving.resolvedPallet = null; this.cartonInput = value.cartonMark; this.palletInput = ''; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (_) {} },
@@ -181,5 +189,5 @@
diff --git a/src/stores/receiving.js b/src/stores/receiving.js
index ef8cd42..83369e9 100644
--- a/src/stores/receiving.js
+++ b/src/stores/receiving.js
@@ -35,8 +35,8 @@ export function setCurrentReceiving(value) {
receiving.selectedInboundCode = (value?.inbounds || []).find((item) => Number(item.pendingPackages || 0) > 0)?.inboundCode || value?.inbounds?.[0]?.inboundCode || ''
}
receiving.photoSlots = RECEIVING_PHOTO_ROLES.map((role) => {
- const photo = value?.photos?.find((item) => item.role === role)
- return photo ? { ...photo, fileId: String(photo.fileId), role, uploaded: true } : null
+ const photo = (value?.photos || []).find((item) => item.role === role)
+ return photo ? { ...photo, fileId: String(photo.fileId), role, uploaded: true, status: 'success' } : null
})
}
diff --git a/src/utils/network.js b/src/utils/network.js
index 723914a..e1d3172 100644
--- a/src/utils/network.js
+++ b/src/utils/network.js
@@ -12,13 +12,15 @@ function ensureListening() {
if (typeof uni.onNetworkStatusChange === 'function') {
uni.onNetworkStatusChange((result) => {
online = Boolean(result.isConnected)
+ console.info('[Overseas PDA network] status change', { isConnected: result.isConnected, networkType: result.networkType })
notify()
})
}
if (typeof uni.getNetworkType === 'function') {
uni.getNetworkType({
- success: (result) => {
+ success: (result) => {
online = result.networkType !== 'none'
+ console.info('[Overseas PDA network] initial status', { networkType: result.networkType, online })
notify()
}
})
@@ -31,6 +33,7 @@ export function isOnline() {
}
export function requireOnline(message = '当前网络不可用,请联网后刷新重试') {
+ console.info('[Overseas PDA network] preflight', { online })
if (!isOnline()) {
const error = new Error(message)
error.network = true
diff --git a/tests/overseas-request.test.mjs b/tests/overseas-request.test.mjs
index 5c6d86a..c48eb39 100644
--- a/tests/overseas-request.test.mjs
+++ b/tests/overseas-request.test.mjs
@@ -1,7 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
-import { overseasRequest, overseasUpload, resetOverseasRequestStateForTest } from '../src/api/overseas.js'
+import { overseasDownload, overseasRequest, overseasUpload, resetOverseasRequestStateForTest } from '../src/api/overseas.js'
import { PDA_AUTHORITY, PDA_ROLE, session, signInWithTokens, signOut } from '../src/stores/session.js'
const future = () => Math.floor(Date.now() / 1000) + 3600
@@ -128,6 +128,18 @@ test('photo upload refreshes and replays once while retaining the file payload',
])
})
+test('authenticated photo download maps the public api path through the H5 overseas proxy', async () => {
+ const value = prepareSession(); const calls = []
+ globalThis.uni.downloadFile = (options) => {
+ calls.push(options)
+ options.success({ statusCode: 200, tempFilePath: '/tmp/restored.jpg', header: {} })
+ }
+ assert.equal(await overseasDownload('/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download'), '/tmp/restored.jpg')
+ assert.equal(calls.length, 1)
+ assert.equal(calls[0].url, '/overseas-api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download')
+ assert.equal(calls[0].header.Authorization, `Bearer ${value.overseasAccessToken}`)
+})
+
test('business and network errors retain diagnostics without refreshing or clearing a valid session', async () => {
prepareSession(); let refreshCount = 0
globalThis.uni.request = (options) => {
@@ -147,4 +159,3 @@ test('business and network errors retain diagnostics without refreshing or clear
await assert.rejects(overseasRequest('GET', '/pda/network'), (error) => error.network === true && /socket/.test(error.message))
assert.ok(session.currentUser)
})
-
diff --git a/tests/project-contract.test.mjs b/tests/project-contract.test.mjs
index 0bfd766..4548dfa 100644
--- a/tests/project-contract.test.mjs
+++ b/tests/project-contract.test.mjs
@@ -57,6 +57,6 @@ test('unimplemented warehouse operations cannot submit prototype scans', async (
])
assert.doesNotMatch(operation, /prototypeScan|prototypeInput/)
assert.match(operation, /功能暂未开放|扫码不会提交/)
- assert.match(home, /operation\.badge/)
- assert.match(module, /专用接口、权限与仓库范围契约完成前保持禁用/)
+ assert.match(home, /暂未开放/)
+ assert.match(module, /当前仅开放拆柜收货,其他作业暂未开放/)
})
diff --git a/tests/receiving-adapter.test.mjs b/tests/receiving-adapter.test.mjs
index 73f841b..402ed42 100644
--- a/tests/receiving-adapter.test.mjs
+++ b/tests/receiving-adapter.test.mjs
@@ -6,11 +6,12 @@ test('receiving adapter preserves the three signed photo roles in workflow order
const snowflakeFileId = '2092476334483931138'
const session = normalizeReceivingSession({
status: 'WAIT_SIGN', totalPackages: 8, receivedPackages: 2, pendingPackages: 6,
- photos: [{ role: 'DOOR_OPEN', fileId: '3' }, { role: 'LICENSE_PLATE', fileId: snowflakeFileId }, { role: 'CONTAINER_SEAL', fileId: '2' }],
+ photos: [{ role: 'DOOR_OPEN', fileId: '3' }, { role: 'LICENSE_PLATE', fileId: snowflakeFileId }, { role: 'CONTAINER_SEAL', fileId: '2' }, { role: 'DOOR_OPEN', fileId: '4' }],
inbounds: [{ inboundCode: 'IN-1', plannedPalletCount: 2, totalPackages: 8, receivedPackages: 2, pendingPackages: 6 }]
})
assert.deepEqual(session.photos.map(({ role }) => role), RECEIVING_PHOTO_ROLES)
assert.equal(session.photos[0].fileId, snowflakeFileId)
+ assert.equal(session.photos.length, 3)
assert.equal(session.inbounds[0].plannedPalletCount, 2)
})
diff --git a/tests/receiving-photo-ui-contract.test.mjs b/tests/receiving-photo-ui-contract.test.mjs
new file mode 100644
index 0000000..eb31254
--- /dev/null
+++ b/tests/receiving-photo-ui-contract.test.mjs
@@ -0,0 +1,73 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+
+const read = (relative) => readFileSync(new URL(relative, import.meta.url), 'utf8')
+const page = read('../src/pages/operation/operation.vue')
+const adapter = read('../src/api/receiving-adapter.js')
+const inbound = read('../src/api/inbound.js')
+const controller = read('../../../baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java')
+const openapi = read('../../../baiyue-firstleg/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml')
+
+function photoUiBlockers() {
+ const values = []
+ const hasThumbnail = / page.toLowerCase().includes(label.toLowerCase()))
+ if (!hasThumbnail || !hasPreview) values.push({
+ code: 'PHOTO_THUMBNAIL_PREVIEW_MISSING', deploymentBlocker: true,
+ reason: '上传成功后只显示完成图标,尚未渲染缩略图或调用uni.previewImage',
+ })
+ if (!hasRestoreDownload) values.push({
+ code: 'PHOTO_RESTORE_DOWNLOAD_ENDPOINT_MISSING', deploymentBlocker: true,
+ reason: 'current恢复只返回fileId/role,后端没有签收照片鉴权下载端点,无法恢复后回显',
+ })
+ else if (!hasRestoreClient) values.push({
+ code: 'PHOTO_RESTORE_CLIENT_PENDING', deploymentBlocker: true,
+ reason: '后端下载契约仍在冻结中,前端尚未接入重载后的鉴权照片回显',
+ })
+ if (developmentLabels.length) values.push({
+ code: 'DEVELOPMENT_LABELS_VISIBLE', deploymentBlocker: true,
+ reason: `页面仍含开发标识:${developmentLabels.join(',')}`,
+ })
+ return values
+}
+
+test('three fixed receiving photo roles and string file IDs are frozen', () => {
+ assert.match(adapter, /RECEIVING_PHOTO_ROLES = \['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN'\]/)
+ assert.match(adapter, /fileId: String\(photo\.fileId\)/)
+ assert.match(page, /fileId: String\(file\.fileId\)/)
+ assert.match(page, /fileId: String\(item\.fileId\)/)
+ assert.doesNotMatch(inbound, /customerId|planNo|allocationId|warehouseId|locationId/)
+})
+
+test('camera or album replaces only one fixed slot and failed replacement preserves its previous success', () => {
+ assert.match(page, /choosePhotos\(index, sourceType\)[\s\S]*uni\.chooseImage\(\{ count: 1, sourceType: \[sourceType\]/)
+ assert.match(page, /choosePhotos\(index, 'camera'\)/)
+ assert.match(page, /choosePhotos\(index, 'album'\)/)
+ assert.match(page, /async replacePhoto\(index, path\)[\s\S]*const previous = receiving\.photoSlots\[index\]/)
+ assert.match(page, /await uploadCurrentUnloadPhoto\(path\)[\s\S]*fileId: String\(file\.fileId\)[\s\S]*localPath: path/)
+ assert.match(page, /previous\?\.fileId \? \{ \.\.\.previous, status: 'error'/)
+ assert.match(page, /status: 'uploading'/)
+ assert.match(page, /status: 'error'/)
+ assert.match(page, /已保留原图/)
+ assert.match(page, /photoSlots\[index\]\?\.fileId \? '重拍' : '拍照'/)
+ assert.match(page, /photoSlots\[index\]\?\.fileId \? '重新选择' : '从相册选择'/)
+ assert.match(page, /previewPhoto\(index\)/)
+ assert.match(page, /uploadedPhotoCount\(\) \{ return this\.photoSlots\.filter\(\(item\) => item\?\.fileId\)\.length \}/)
+ assert.match(page, /photosReady\(\) \{ return this\.uploadedPhotoCount === 3 && !this\.photoSlots\.some/)
+ assert.match(page, /:disabled="busy \|\| !photosReady" @tap="signContainer"/)
+ assert.match(page, /async signContainer\(\) \{ if \(!this\.photosReady\) return;/)
+ assert.match(page, /const photos = this\.photoSlots\.map\(/)
+ assert.doesNotMatch(page, /count:\s*9|flatMap\(|photoSlots\[index\]\.push/)
+})
+
+test('local thumbnail, preview, and authenticated session restore are wired to the frozen contract', () => {
+ assert.match(page, /restorePhotoPreviews\(\)[\s\S]*photo\?\.downloadPath[\s\S]*downloadCurrentUnloadPhoto\(photo\.downloadPath\)/)
+ assert.deepEqual(photoUiBlockers().map((item) => item.code), [])
+ assert.ok(photoUiBlockers().every((item) => item.deploymentBlocker === true && item.reason))
+})
diff --git a/tests/workbench-availability.test.mjs b/tests/workbench-availability.test.mjs
index 750e800..b64156d 100644
--- a/tests/workbench-availability.test.mjs
+++ b/tests/workbench-availability.test.mjs
@@ -11,7 +11,6 @@ test('only operations backed by a dedicated overseas PDA contract are enabled',
for (const name of pendingNames) {
const operation = operations.find((item) => item.name === name)
assert.equal(operation?.available, false, `${name} must fail closed until its PDA API exists`)
- assert.equal(operation?.badge, '待 PDA 接口')
+ assert.equal(operation?.badge, undefined)
}
})
-