diff --git a/README.md b/README.md
index 26da7ad..4276243 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@ npm run build:app
## App 在线更新
-Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独立更新通道;H5 不执行该逻辑。更新服务接收 `appid`、`platform`、`appVersion`、`appVersionCode`、`wgtVersion` 查询参数,无更新返回 `{ "update": false }`,有更新返回:
+Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独立更新通道,员工也可在“个人中心 → 检查更新”手动触发;H5 仅展示 App 操作提示,不执行更新。更新服务接收 `appid`、`platform`、`appVersion`、`appVersionCode`、`wgtVersion` 查询参数,无更新返回 `{ "update": false }`,有更新返回:
```json
{
diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue
index 0bfc278..04360b8 100644
--- a/src/pages/operation/operation.vue
+++ b/src/pages/operation/operation.vue
@@ -1,7 +1,7 @@
-
+
{{ pageTitle }}{{ pageSubtitle }}
@@ -76,7 +76,7 @@
本次已收 {{ lastReceipt.acceptedPackages }} 箱,{{ lastReceipt.inboundCode }} 剩余 {{ lastReceipt.pendingPackages }} 箱
-
+
@@ -150,7 +150,7 @@
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); this.clearReceiptContext() } catch (_) {} },
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = 1; receiving.activeScanTarget = 'carton' },
- async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark) return; if (this.busy) { console.warn('[Overseas PDA carton] ignored while busy', { cartonMark }); return this.showToast('正在处理上一笔扫描,请稍后重试', 'warning') } console.info('[Overseas PDA carton] resolving', { cartonMark, containerNo: this.current?.containerNo }); try { const value = await this.run('解析箱码', () => resolveCurrentCarton(cartonMark)); console.info('[Overseas PDA carton] resolved', { cartonMark: value.cartonMark, inboundCode: value.inboundCode, destinationType: value.destinationType, pendingPackages: value.pendingPackages }); receiving.resolvedCarton = value; receiving.resolvedPallet = null; this.cartonInput = value.cartonMark; this.palletInput = ''; this.packageCount = 1; receiving.activeScanTarget = 'pallet'; playSuccessSound() } catch (error) { console.error('[Overseas PDA carton] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } },
+ async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark) return; if (this.busy) { console.warn('[Overseas PDA carton] ignored while busy', { cartonMark }); return this.showToast('正在处理上一笔扫描,请稍后重试', 'warning') } console.info('[Overseas PDA carton] resolving', { cartonMark, containerNo: this.current?.containerNo }); try { const value = await this.run('解析箱码', () => resolveCurrentCarton(cartonMark)); console.info('[Overseas PDA carton] resolved', { cartonMark: value.cartonMark, inboundCode: value.inboundCode, destinationType: value.destinationType, pendingPackages: value.pendingPackages }); receiving.resolvedCarton = value; receiving.resolvedPallet = null; this.cartonInput = value.cartonMark; this.palletInput = ''; this.packageCount = 1; playSuccessSound() } catch (error) { console.error('[Overseas PDA carton] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } },
async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim(); if (!palletCode || !this.activeInbound || this.busy) return; try { const value = await this.run('校验托盘', () => resolveCurrentPallet(palletCode, this.activeInbound.inboundCode)); receiving.resolvedPallet = value; this.palletInput = value.palletCode; if (!value.compatible) throw Object.assign(new Error('托盘不匹配'), { code: 'PALLET_INCOMPATIBLE' }); playSuccessSound() } catch (error) { if (!this.error) this.error = error } },
async submitReceipt() { if (!this.canSubmitReceipt) return; const isFba = this.activeInbound.destinationType === 'FBA'; const payload = isFba ? { inboundCode: this.activeInbound.inboundCode, palletCode: this.resolvedPallet.palletCode, packageCount: Number(this.packageCount) } : { cartonMark: this.activeInbound.cartonMark, palletCode: this.resolvedPallet.palletCode }; const key = idempotencyKey(isFba ? 'bulk-receipt' : 'carton-receipt', payload); try { const result = await this.run('提交实收', () => isFba ? receiveCurrentBulk({ ...payload, idempotencyKey: key.value }) : receiveCurrentCarton({ ...payload, idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); receiving.lastReceipt = result; setCurrentReceiving(await getCurrentReceiving()); this.clearReceiptContext(); playSuccessSound() } catch (_) {} },
async completeContainer() { if (!this.allInboundsReconciled) return; const key = idempotencyKey('complete', { containerNo: this.current.containerNo }); try { const value = await this.run('完成入库', () => completeCurrentReceiving({ idempotencyKey: key.value })); releaseIdempotencyKey(key.signature); setCurrentReceiving(value) } catch (_) {} },
@@ -185,7 +185,8 @@
.matched-inbound-progress text{padding:6px 4px;border-radius:6px;text-align:center;background:#edf3f9}
@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}}
.receiving-page{padding-bottom:env(safe-area-inset-bottom,0px)}
- .receiving-content{box-sizing:border-box;padding-bottom:calc(152px + 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))}
.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}
diff --git a/src/pages/profile/profile.vue b/src/pages/profile/profile.vue
index c9576af..9b5d0b6 100644
--- a/src/pages/profile/profile.vue
+++ b/src/pages/profile/profile.vue
@@ -6,8 +6,9 @@
作业仓库{{ warehouseDisplay }}
当前班次以海外仓排班为准
- 应用版本0.1.0
+ 应用版本{{ appVersion }}
+
@@ -20,18 +21,33 @@
import PopupHeader from '@/components/PopupHeader.vue'
import { getCurrentWarehouseProfile } from '@/api/inbound.js'
import { applyWarehouseProfile, session, requireSession, signOut } from '@/stores/session.js'
+ import { checkForAppUpdate } from '@/utils/app-update.js'
export default {
components: { PopupHeader, PortalHeader },
- data() { return { user: session.currentUser, logoutOpen: false, toast: false } },
+ data() { return { user: session.currentUser, appVersion: '0.0.1', logoutOpen: false, toast: false } },
computed: {
displayName() { const name = String(this.user?.name || ''); return name && !/^[a-z0-9._-]+$/i.test(name) ? name : '仓库作业员' },
avatar() { return this.displayName.slice(0, 1).toUpperCase() },
warehouseDisplay() { return this.user?.warehouseName ? (this.user.warehouseCode ? `${this.user.warehouseName} · ${this.user.warehouseCode}` : this.user.warehouseName) : (this.user?.warehouse || '正在读取仓库主档') }
},
- onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadWarehouseProfile() },
+ onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadAppVersion(); this.loadWarehouseProfile() },
beforeUnmount() { clearTimeout(this._toastTimer) },
onBackPress() { if (!this.logoutOpen) return false; this.closeLogout(); return true },
methods: {
+ loadAppVersion() {
+ // #ifdef APP-PLUS
+ this.appVersion = String(plus.runtime.version || this.appVersion)
+ // #endif
+ },
+ checkUpdate() {
+ // #ifdef APP-PLUS
+ checkForAppUpdate({ manual: true })
+ return
+ // #endif
+ // #ifndef APP-PLUS
+ uni.showToast({ title: '请在 PDA App 中检查更新', icon: 'none' })
+ // #endif
+ },
async loadWarehouseProfile() { try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser } },
openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
diff --git a/tests/app-update.test.mjs b/tests/app-update.test.mjs
index 398cac6..7df2435 100644
--- a/tests/app-update.test.mjs
+++ b/tests/app-update.test.mjs
@@ -30,3 +30,13 @@ test('overseas online update supports mandatory APK and wgt installation flows',
assert.match(updater, /packageType === 'wgt'[\s\S]*?plus\.runtime\.restart/)
assert.match(updater, /if \(retry\) await runUpdate\(update\)/)
})
+
+test('overseas profile exposes manual App update checking and the runtime version', async () => {
+ const profile = await read('../src/pages/profile/profile.vue')
+
+ assert.match(profile, /应用版本[\s\S]*?\{\{ appVersion \}\}/)
+ assert.match(profile, /@tap="checkUpdate">检查更新/)
+ assert.match(profile, /plus\.runtime\.version/)
+ assert.match(profile, /checkForAppUpdate\(\{ manual: true \}\)/)
+ assert.match(profile, /#ifndef APP-PLUS[\s\S]*?请在 PDA App 中检查更新/)
+})
diff --git a/tests/project-contract.test.mjs b/tests/project-contract.test.mjs
index 5188789..d07c5f1 100644
--- a/tests/project-contract.test.mjs
+++ b/tests/project-contract.test.mjs
@@ -48,6 +48,8 @@ test('login has no committed demo credentials and inbound operation implements t
assert.match(operation, /startScanBroadcast/)
for (const action of ['prepareReceivingContainer', 'getCurrentReceiving', 'resolveCurrentCarton', 'resolveCurrentPallet', 'receiveCurrentBulk', 'receiveCurrentCarton', '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', '/complete']) assert.match(inbound, new RegExp(path.replaceAll('/', '\\/')))
+ assert.match(inbound, /current\('\/pallets\/resolve'\)/)
+ assert.doesNotMatch(inbound, /platform\/v1\/warehouse-admin/, 'PDA只能调用current/pallets/resolve,不能访问Admin仓储资源接口')
assert.doesNotMatch(operation, /resolveCurrentSku|resolvedSku|skuInput|selectedInboundCode|chooseInbound|class="inbound-tabs"/)
assert.doesNotMatch(operation, /InboundList|柜内入仓码/)
assert.doesNotMatch(inbound, /skus\/resolve/)
@@ -55,6 +57,8 @@ test('login has no committed demo credentials and inbound operation implements t
assert.match(operation, /const payload = isFba \? \{ inboundCode: this\.activeInbound\.inboundCode, palletCode: this\.resolvedPallet\.palletCode, packageCount: Number\(this\.packageCount\) \}/)
assert.doesNotMatch(operation, /skuCode/)
assert.match(operation, /clearReceiptContext\(\)[\s\S]*activeScanTarget = 'carton'/)
+ assert.match(operation, /name="pallet"[\s\S]*@activate="activeTarget = 'pallet'"/)
+ assert.doesNotMatch(operation, /receiving\.activeScanTarget = 'pallet'/, '箱码解析成功后必须由作业员手动选择托盘扫码,不得自动跳转')
assert.match(operation, /\[Overseas PDA scan\] dispatch/)
assert.match(operation, /\[Overseas PDA carton\] resolved/)
assert.match(operation, /\[Overseas PDA carton\] failed[\s\S]*requestId[\s\S]*traceId/)
diff --git a/tests/receiving-photo-ui-contract.test.mjs b/tests/receiving-photo-ui-contract.test.mjs
index d183b8a..c87c25c 100644
--- a/tests/receiving-photo-ui-contract.test.mjs
+++ b/tests/receiving-photo-ui-contract.test.mjs
@@ -76,7 +76,9 @@ test('local thumbnail, preview, and authenticated session restore are wired to t
})
test('fixed receiving action leaves enough scroll-safe space above the PDA bottom bar', () => {
- assert.match(page, /receiving-content\{[^}]*padding-bottom:calc\(152px \+ env\(safe-area-inset-bottom,0px\)\)/)
+ assert.match(page, /v-if="allInboundsReconciled" class="sticky-action one"/)
+ assert.doesNotMatch(page, /尚有 \$\{progress\.pending\} 箱未收/)
+ assert.match(page, /receiving-content-with-action\{[^}]*padding-bottom:calc\(152px \+ 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/)
})