fix(pda): 修复海外PDA真机作业并固定APK与热更新发布流程

This commit is contained in:
2026-11-06 12:32:22 +08:00
parent 10d9896b6b
commit 7e793b1d1b
13 changed files with 420 additions and 47 deletions
+1 -1
View File
@@ -21,7 +21,7 @@
- 并发401仅一次refresh,完整校验Token/快照再原子替换;主体/仓变化清会话重登录,原请求/照片上传最多重放一次。刷新失败清会话;业务/网络错留code、requestId、traceId或network诊断。
- 国内/海外 AppID、Android 包名、签名、版本号、产物、更新通道独立;`src/manifest.json` AppID须正式打包前由海外所属DCloud账号创建,禁伪造/复用国内AppID。
- 环境地址仅配 `.env.local` 等未提交环境文件,不入源码。
- Android启动仅在 `APP-PLUS` 调 `src/utils/app-update.js`,以独立 `VITE_PDA_UPDATE_URL` 查HTTPS APK/WGT;强更禁跳过,WGT安装即重启。更新服务/标识/签名/版本序列不得与国内 PDA 混用。
- Android仅在 `APP-PLUS` 调 `src/utils/app-update.js`,用独立 `VITE_PDA_UPDATE_URL` 查HTTPS APK/WGT;强更禁跳过,WGT安装即重启,不得与国内 PDA 混用。发布先装APK再发兼容WGT;原生变化先升APK。步骤及版本见 `README.md`“App 在线更新”。
- 源码根 `src/`;扫码广播 Action=`android.intent.action.SCANRESULT`,Extra=`value`。
- 退货号/SKU候选进入,按本包实收生成退货箱/计费批次;其他作业范围见下表及对应接口边界。
+5 -4
View File
@@ -72,10 +72,11 @@ HBuilderX 真机调试及标准基座使用当前工程 `src/manifest.json` 中
`packageType` 只允许 `apk` 或 `wgt`,`appid` 必须与当前安装包一致。WGT 清单还须提供 `minAppVersionCode`(最低兼容 APK 整数版本),不满足时客户端拒绝安装并提示升级 APK。`mandatory=true` 时员工不能跳过,并在下载或安装失败后提供重试;wgt 安装完成会立即重启,APK 交由 Android 安装器完成覆盖安装。正式发布要求:
1. 海外 PDA 每次整包发布同时递增 `src/manifest.json` 的 `versionName` 与整数 `versionCode`,使用海外独立 AppID、包名和签名打包 APK。
2. wgt 发布至少递增 `versionName`,并使用与已安装原生包兼容的 uni-app 编译器生成;新增原生模块、权限或不兼容运行时能力时必须发布 APK,不能只发 wgt。
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
4. 海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
1. 发布顺序固定为“先安装 APK 基线,再发布后续 WGT 热更新”。首次安装先使用海外独立 AppID、包名和签名打包 APK,在目标 PDA 安装后确认登录、扫码、拍照和“个人中心 → 检查更新”正常,再以该 APK 作为后续热更新基线。
2. 后续兼容的页面、交互和业务逻辑修改发布 WGT。递增 `src/manifest.json` 的 `versionName`,使其高于设备当前的 `wgtVersion`,沿用 APK 基线的整数 `versionCode`,并使用与基线 APK 兼容的 uni-app 编译器生成。更新记录的 `minAppVersionCode` 填该 WGT 要求的 APK 基线版本码;设备低于基线时先安装新版 APK。
3. 例如 APK 基线为 `0.0.5(versionCode=5)`,下一次 WGT 可发布 `0.0.6`,`minAppVersionCode=5`。安装基线 APK 后首次检查同版本资源会判定为最新版本;后续 WGT 使用更高的资源版本号。
4. 新增原生模块、权限或不兼容运行时能力时,先同时递增 `versionName` 与整数 `versionCode` 并发布、安装新版 APK,完成真机验证后再基于新 APK 发布后续 WGT。
5. 更新清单与下载地址使用 HTTPS。每次发布先在已安装对应 APK 基线的测试设备验证实际升级路径、断网重试、WGT 安装重启及本次受影响作业,再切换正式清单;新 APK 基线同时验证覆盖安装和签名。海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
## H5 静态托管(TEST 与灰度)
+2 -2
View File
@@ -2,8 +2,8 @@
"name" : "柏悦物流 PDA 海外版",
"appid" : "__UNI__936D079",
"description" : "柏悦物流海外仓 PDA 作业端",
"versionName" : "0.0.4",
"versionCode" : 4,
"versionName" : "0.0.5",
"versionCode" : 5,
"transformPx" : false,
"app-plus" : {
"usingComponents" : true,
+39 -23
View File
@@ -78,7 +78,7 @@
<view v-else class="picking-quantity"><text class="picking-instruction">拣货箱数</text><input type="number" aria-label="本次拣货箱数" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" :disabled="busy" @input="updatePackages" /><text class="task-meta">本次最多可拣 {{ selectedRemaining }} 箱</text></view>
<button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">确认真实拣货</button>
</view>
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card picking-completion"><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy" @tap="prepareLoading">确认装车完成</button></view>
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card picking-completion"><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy || loading || loadingPreview !== null" :loading="requestBusy" @tap="prepareLoading">{{ requestBusy ? '处理中…' : '确认装车完成' }}</button></view>
</view>
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</view>
</view></scroll-view>
@@ -90,13 +90,6 @@
<text class="scan-notice-count">{{ scanNotice.packages }}<text class="loading-count-unit"> 箱</text></text>
<text class="scan-notice-hint">3 秒后自动关闭</text>
</view>
</view>
<view v-if="loadingPreview" class="loading-overlay">
<view class="loading-dialog" role="dialog" aria-modal="true" aria-labelledby="loading-confirm-title">
<view class="loading-dialog-heading"><text id="loading-confirm-title" class="loading-dialog-title">确认装车</text></view>
<view class="loading-counts"><view class="loading-count"><text class="loading-count-value">{{ loadingRows.length }}<text class="loading-count-unit"> 托</text></text><text class="loading-count-label">已拣托盘</text></view><view class="loading-count"><text class="loading-count-value">{{ loadingRows.reduce((sum, row) => sum + Number(row.pickedPackages || 0), 0) }}<text class="loading-count-unit"> 箱</text></text><text class="loading-count-label">已拣箱数</text></view></view>
<view class="loading-dialog-actions"><button class="loading-cancel" :disabled="busy" @tap="loadingPreview = null">取消</button><button class="loading-confirm" :disabled="busy" :loading="busy" @tap="completeLoading">确认</button></view>
</view>
</view>
</view>
</template>
@@ -131,7 +124,7 @@ export default {
},
onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo); else this.loadTasks() } else if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo); else this.loadTasks() },
onHide() { this.stopScanner(); this.clearScanNotice(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.clearScanNotice(); this.generation++ },
onHide() { this.stopScanner(); this.clearScanNotice(); this.loadingPreview = null; this.generation++ }, beforeUnmount() { this.stopScanner(); this.clearScanNotice(); this.loadingPreview = null; this.generation++ },
methods: {
clearScanNotice() { clearTimeout(this._scanNoticeTimer); this._scanNoticeTimer = null; this.scanNotice = null },
showScanNotice(item) {
@@ -148,7 +141,7 @@ export default {
closeTask() { if (this.busy) return; this.current = null; this.activeTab = 'picking'; this.reviewReason = ''; this.loadingPreview = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 }); this.loadTasks() },
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim()
if (!code || this.busy || this.loading || this.activeTab !== 'picking') return
if (!code || this.busy || this.loading || this.loadingPreview || this.activeTab !== 'picking') return
this.clearScanNotice()
this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = ''
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 })
@@ -183,7 +176,42 @@ export default {
this.sourceChoices = []; this.choiceCartonCode = ''; this.error = null
},
async submitPick() { if (!this.canPick) return; try { const body = { recordVersion: requireDispatchVersion(this.current.recordVersion), positionId: this.selectedReturnCarton ? null : this.selectedPosition.positionId, packages: this.selectedReturnCarton ? 1 : Number(this.packages), productQuantity: this.selectedReturnCarton?.productQuantity || 0, cartonMark: this.selectedReturnCarton?.cartonMark || null, remark: null }; await this.submitCommand(this.current.dispatchNo, 'PICK', { ...body, dispatchItemId: this.selectedItem.dispatchItemId }) } catch (cause) { this.setError(cause, '拣货未提交') } },
async prepareLoading() { if (!this.canComplete || 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 prepareLoading() {
if (!this.canComplete || this.busy || this.loading || this.loadingPreview) return
const no = this.current.dispatchNo, generation = this.generation
await this.run(async () => {
const data = await getDispatchLoading(no)
if (generation !== this.generation || this.current?.dispatchNo !== no) return
if (data.dispatchNo !== no || data.pickingReconciled !== true || data.loadingComplete?.allowed !== true) throw new Error(data.loadingComplete?.blockReason || '装车前置未满足')
requireDispatchVersion(data.recordVersion)
if (!Array.isArray(data.pallets) || !data.pallets.length) throw new Error('装车清单未返回有效托盘,请重新核对')
this.loadingRows = data.pallets
this.loadingPreview = data
})
const preview = this.loadingPreview
if (!preview) return
const pickedPackages = this.loadingRows.reduce((sum, row) => sum + Number(row.pickedPackages || 0), 0)
uni.showModal({
title: '确认装车',
content: `已拣托盘 ${this.loadingRows.length} 托\n已拣箱数 ${pickedPackages} 箱`,
confirmText: '确认',
cancelText: '取消',
success: async result => {
if (this.loadingPreview !== preview) return
if (!result.confirm || generation !== this.generation || this.current?.dispatchNo !== no) {
this.loadingPreview = null
return
}
await this.completeLoading()
if (this.loadingPreview === preview) this.loadingPreview = null
},
fail: cause => {
if (this.loadingPreview !== preview) return
this.loadingPreview = null
this.setError(new Error(cause.errMsg || '无法打开装车确认,请重试'), '无法打开装车确认')
}
})
},
async completeLoading() { if (!this.loadingPreview || this.busy) return; try { if (!this.loadingRows.length || this.loadingRows.some(row => row.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new Error('托盘卸货站尚未匹配,请后台核对'); await this.submitCommand(this.loadingPreview.dispatchNo, 'LOADING_COMPLETE', { recordVersion: requireDispatchVersion(this.loadingPreview.recordVersion), manifestHash: this.loadingPreview.manifestHash, remark: null }) } catch (cause) { this.setError(cause, '装车完成未确认') } },
updateReviewReason(event) { if (this.busy || this.loading) return; this.reviewReason = String(event.detail.value || '') },
async submitReview(decision) {
@@ -288,20 +316,8 @@ export default {
.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; top: 0; right: 0; bottom: 0; left: 0; background: rgba(15, 23, 42, .46); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 40rpx; box-sizing: border-box; }
.loading-dialog { width: 100%; max-width: 620rpx; max-height: 88vh; overflow-y: auto; box-sizing: border-box; background: #fff; border-radius: 28rpx; padding: 36rpx 32rpx 32rpx; box-shadow: 0 24rpx 80rpx rgba(15, 23, 42, .18); }
.loading-dialog-heading { display: flex; flex-direction: column; gap: 10rpx; }
.loading-dialog-title { color: #142c49; font-size: 36rpx; font-weight: 700; line-height: 1.4; }
.loading-counts { display: flex; margin: 28rpx 0; padding: 24rpx 0; border-radius: 20rpx; background: #f3f7ff; }
.loading-count { flex: 1; display: flex; align-items: center; flex-direction: column; gap: 8rpx; }
.loading-count + .loading-count { border-left: 1px solid #dce6f6; }
.loading-count-value { color: #1764ec; font-size: 48rpx; font-weight: 700; line-height: 1.2; }
.loading-count-unit { font-size: 26rpx; font-weight: 500; }
.loading-count-label { color: #72839b; font-size: 24rpx; }
.loading-dialog-actions { display: flex; gap: 20rpx; margin-top: 32rpx; }
.loading-dialog-actions button { flex: 1; min-width: 0; margin: 0; height: 88rpx; line-height: 88rpx; padding: 0 12rpx; border-radius: 16rpx; font-size: 30rpx; font-weight: 600; }
.loading-dialog-actions button::after { border: 0; }
.loading-cancel { color: #52657d; background: #f0f3f8; }
.loading-confirm { color: #fff; background: #1764ec; }
.loading-dialog-actions button[disabled] { opacity: .5; }
.scan-notice-overlay { pointer-events: none; }
.scan-notice-dialog { text-align: center; }
.scan-notice-pallet { display: block; margin-top: 20rpx; color: #243c58; font-size: 30rpx; overflow-wrap: anywhere; }
+18 -3
View File
@@ -7,6 +7,7 @@
<view class="login-card">
<view class="login-field"><text class="login-field-label">工号 / 用户名</text><input v-model="form.userId" class="login-field-input" aria-label="工号 / 用户名" placeholder="请输入工号" confirm-type="next" @focus="error = ''" /></view>
<view class="login-field"><text class="login-field-label">密码</text><input v-model="form.password" class="login-field-input" aria-label="密码" type="password" placeholder="请输入密码" confirm-type="done" @focus="error = ''" @confirm="login" /></view>
<view class="login-remember-row" @tap="toggleRememberAccount"><text class="login-remember-label">记住账号</text><switch :checked="rememberAccount" color="#1A73E8" aria-label="记住账号" @change="handleRememberChange" @tap.stop /></view>
<text v-if="error" class="login-error" role="alert">{{ error }}</text>
<button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? '登录中...' : '登录' }}</button>
</view>
@@ -16,12 +17,25 @@
<script>
import { login as loginRequest } from '@/api/auth.js'
import { DEV_LOGIN } from '@/config/runtime.js'
import { session, restoreSession, signInWithTokens } from '@/stores/session.js'
import { loadLoginPreference, saveLoginPreference, session, restoreSession, signInWithTokens } from '@/stores/session.js'
import { requireOnline } from '@/utils/network.js'
export default {
data() { return { form: { userId: DEV_LOGIN.username, password: DEV_LOGIN.password }, error: '', submitting: false } },
onLoad() { if (session.currentUser || restoreSession()) uni.reLaunch({ url: '/pages/home/home' }) },
data() { return { form: { userId: DEV_LOGIN.username, password: DEV_LOGIN.password }, rememberAccount: true, error: '', submitting: false } },
onLoad() {
const preference = loadLoginPreference()
this.rememberAccount = preference.rememberAccount
if (preference.account) this.form.userId = preference.account
if (session.currentUser || restoreSession()) uni.reLaunch({ url: '/pages/home/home' })
},
methods: {
handleRememberChange(event) {
this.rememberAccount = Boolean(event.detail.value)
if (!this.rememberAccount) saveLoginPreference(false)
},
toggleRememberAccount() {
this.rememberAccount = !this.rememberAccount
if (!this.rememberAccount) saveLoginPreference(false)
},
async login() {
const userId = this.form.userId.trim()
if (!userId) return void (this.error = '请输入工号')
@@ -32,6 +46,7 @@
requireOnline('当前网络不可用,无法登录')
const tokens = await loginRequest(userId, this.form.password)
signInWithTokens(tokens)
saveLoginPreference(this.rememberAccount, userId)
uni.reLaunch({ url: '/pages/home/home' })
} catch (error) {
console.error('[Overseas PDA login] failed', { message: error.message, network: error.network === true,
File diff suppressed because one or more lines are too long
+16
View File
@@ -5,6 +5,7 @@ import { resetFulfillmentSession } from './fulfillment.js'
import { resetWarehouseWork } from './warehouse-work.js'
const SESSION_KEY = 'overseas-pda-auth-session'
const LOGIN_PREFERENCE_KEY = 'overseas-pda-login-account-preference'
let sessionRevision = 0
export function getSessionRevision() { return sessionRevision }
export const PDA_ROLE = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR'
@@ -96,6 +97,21 @@ function storageAvailable() {
return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function'
}
export function loadLoginPreference() {
if (!storageAvailable()) return { rememberAccount: true, account: '' }
const saved = uni.getStorageSync(LOGIN_PREFERENCE_KEY)
if (!saved || typeof saved !== 'object') return { rememberAccount: true, account: '' }
return { rememberAccount: saved.rememberAccount !== false, account: String(saved.account || '') }
}
export function saveLoginPreference(rememberAccount, account = '') {
if (!storageAvailable()) return
uni.setStorageSync(LOGIN_PREFERENCE_KEY, {
rememberAccount: Boolean(rememberAccount),
account: rememberAccount ? String(account || '').trim() : ''
})
}
function persistSession() {
if (!storageAvailable()) return
uni.setStorageSync(SESSION_KEY, {
+14
View File
@@ -134,6 +134,20 @@
font-size: 13px;
}
.login-remember-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 48px;
margin: -8px 0 10px;
color: $text-secondary;
}
.login-remember-label {
font-size: 14px;
font-weight: 500;
}
.login-btn {
width: 100%;
height: 50px;
+1 -1
View File
@@ -24,7 +24,7 @@ test('picking separates source facts, current quantity and completion while reta
assert.match(template, /aria-label="本次拣货箱数"[^>]+:disabled="busy"[^>]+@input="updatePackages"/)
assert.match(template, /name="dispatch"[^>]+:active="activeTab === 'picking'"[^>]+@submit="handleScan"/)
assert.match(template, /:disabled="!canPick \|\| busy" @tap="submitPick"/)
assert.match(template, /:disabled="!canComplete \|\| busy" @tap="prepareLoading"/)
assert.match(template, /:disabled="!canComplete \|\| busy \|\| loading \|\| loadingPreview !== null" :loading="requestBusy" @tap="prepareLoading"/)
assert.match(template, /@tap="selectPallet\(item\)"/)
assert.match(page, /\.picking-fact > text:last-child \{[^}]*overflow-wrap: anywhere/)
})
+78
View File
@@ -107,6 +107,84 @@ test('actual loading confirmation leaves reference measurements blank and blocks
assert.equal(state.loadingRows[0].deliveryIndex, null)
Object.assign(state.loadingRows[0], { weightKg: 92, volumeCbm: 1.5 }); await state.completeLoading(); assert.equal(posts, 0); assert.match(state.error.message, /卸货站/)
})
test('loading completion opens a callback-based confirmation with pallet and package counts before submitting', async () => {
storage(); let modal, posts = 0, reads = 0
uni.showModal = options => { modal = options }
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'two-pallets', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [
{ palletNo: 'PALLET-1', pickedPackages: '12', deliveryIndex: 1 },
{ palletNo: 'PALLET-2', pickedPackages: '10', deliveryIndex: 1 }
] }
const state = page({ getDispatchLoading: async () => { reads++; return preview },
completeDispatchLoading: async () => { posts++; return proof(lastCommand) },
getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING' }) })
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] })
await state.prepareLoading()
assert.equal(state.busy, false)
assert.equal(modal.title, '确认装车')
assert.equal(modal.content, '已拣托盘 2 托\n已拣箱数 22 箱')
assert.equal(posts, 0)
await state.prepareLoading()
assert.equal(reads, 1)
await modal.success({ confirm: true })
assert.equal(posts, 1)
assert.equal(state.current.stage, 'AFTER_LOADING')
assert.equal(state.loadingPreview, null)
})
test('cancelling or failing to open loading confirmation releases the preview for a fresh attempt', async () => {
storage(); let modal, reads = 0
uni.showModal = options => { modal = options }
const preview = { dispatchNo: 'PC/1', recordVersion: 0, pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ pickedPackages: 2, deliveryIndex: 1 }] }
const state = page({ getDispatchLoading: async () => { reads++; return preview } })
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] })
await state.prepareLoading()
await modal.success({ cancel: true })
assert.equal(state.loadingPreview, null)
await state.prepareLoading()
modal.fail({ errMsg: '弹窗打开失败' })
assert.equal(state.loadingPreview, null)
assert.equal(state.error.message, '弹窗打开失败')
await state.prepareLoading()
assert.equal(reads, 3)
})
test('loading confirmation ignores responses and callbacks after leaving the task', async () => {
storage(); let resolve, modal, posts = 0
uni.showModal = options => { modal = options }
const preview = { dispatchNo: 'PC/1', recordVersion: 0, pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ pickedPackages: 2, deliveryIndex: 1 }] }
const state = page({ getDispatchLoading: () => new Promise(done => { resolve = done }), completeDispatchLoading: async () => { posts++ } })
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] })
const reading = state.prepareLoading()
assert.equal(state.requestBusy, true)
state.generation++
resolve(preview)
await reading
assert.equal(modal, undefined)
assert.equal(state.loadingPreview, null)
const secondReading = state.prepareLoading()
resolve(preview)
await secondReading
state.generation++
await modal.success({ confirm: true })
assert.equal(posts, 0)
assert.equal(state.loadingPreview, null)
})
test('failed loading submission closes the native confirmation and allows a new read', async () => {
storage(); let modal, reads = 0
uni.showModal = options => { modal = options }
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'manifest', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ pickedPackages: 2, deliveryIndex: 1 }] }
const state = page({ getDispatchLoading: async () => { reads++; return preview }, completeDispatchLoading: async () => { throw new Error('装车提交失败') } })
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] })
await state.prepareLoading()
await modal.success({ confirm: true })
assert.equal(state.error.message, '装车提交失败')
assert.equal(state.loadingPreview, null)
assert.equal(state.busy, false)
await state.prepareLoading()
assert.equal(reads, 2)
})
test('missing versions surface a local error before picking or loading completion and never produce a write', async () => {
storage(); const state = page(); state.selectPallet(state.current.items[0]); state.current.recordVersion = undefined
await state.submitPick(); assert.match(state.error.message, /版本/); assert.equal('pendingCommand' in fulfillment.dispatch, false)
+129
View File
@@ -0,0 +1,129 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { parse, compileTemplate } from '@vue/compiler-sfc'
import {
PDA_AUTHORITY, PDA_ROLE, loadLoginPreference, saveLoginPreference,
restoreSession, session, signInWithTokens, signOut
} from '../src/stores/session.js'
const source = readFileSync(new URL('../src/pages/login/login.vue', import.meta.url), 'utf8')
const { descriptor, errors } = parse(source)
const preferenceKey = 'overseas-pda-login-account-preference'
function storage() {
const values = new Map()
globalThis.uni = {
setStorageSync: (key, value) => values.set(key, JSON.parse(JSON.stringify(value))),
getStorageSync: (key) => values.get(key),
removeStorageSync: (key) => values.delete(key),
reLaunch() {}
}
signOut()
return values
}
function tokens() {
const claims = {
user_id: 42, membership_id: 84, tenant_id: 0, app_code: 'PLATFORM',
overseas_warehouse_id: 900, exp: Math.floor(Date.now() / 1000) + 3600
}
return {
accessToken: `header.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.signature`,
refreshToken: 'refresh-42', roles: [PDA_ROLE], authorities: [PDA_AUTHORITY]
}
}
function page(overrides = {}) {
const dependencies = {
DEV_LOGIN: { username: '', password: '' }, session, restoreSession, signInWithTokens,
loadLoginPreference, saveLoginPreference, requireOnline() {},
loginRequest: async () => tokens(), console: { error() {} }, ...overrides
}
const script = descriptor.script.content.replace(/^\s*import .*$/gm, '').replace('export default', 'return')
const definition = new Function(...Object.keys(dependencies), script)(...Object.values(dependencies))
const vm = definition.data()
for (const [name, method] of Object.entries(definition.methods)) vm[name] = method.bind(vm)
definition.onLoad.call(vm)
return vm
}
test('login template compiles with an account switch and separate row and switch events', () => {
assert.deepEqual(errors, [])
const compiled = compileTemplate({ source: descriptor.template.content, filename: 'login.vue', id: 'login' })
assert.deepEqual(compiled.errors, [])
assert.match(descriptor.template.content, /@tap="toggleRememberAccount"/)
assert.match(descriptor.template.content, /<switch[^>]*:checked="rememberAccount"[^>]*@change="handleRememberChange"[^>]*@tap\.stop/)
})
test('new installations default to remembering the account and an empty login form', () => {
storage()
assert.deepEqual(loadLoginPreference(), { rememberAccount: true, account: '' })
const vm = page()
assert.equal(vm.rememberAccount, true)
assert.deepEqual(vm.form, { userId: '', password: '' })
})
test('successful login remembers the trimmed account across sign out and page reopening', async () => {
const values = storage()
let credentials
const vm = page({ loginRequest: async (...args) => { credentials = args; return tokens() } })
vm.form = { userId: ' picker-42 ', password: 'test-password' }
await vm.login()
assert.equal(vm.error, '')
assert.deepEqual(credentials, ['picker-42', 'test-password'])
assert.deepEqual(values.get(preferenceKey), { rememberAccount: true, account: 'picker-42' })
assert.equal(session.currentUser.id, '42')
signOut()
const reopened = page()
assert.equal(reopened.form.userId, 'picker-42')
assert.equal(reopened.form.password, '')
assert.equal(reopened.rememberAccount, true)
})
test('switch and row can each clear the saved account immediately without logging in', () => {
storage()
saveLoginPreference(true, 'picker-42')
const vm = page()
vm.handleRememberChange({ detail: { value: false } })
assert.equal(vm.rememberAccount, false)
assert.deepEqual(loadLoginPreference(), { rememberAccount: false, account: '' })
assert.equal(page().form.userId, '')
assert.equal(page().rememberAccount, false)
vm.toggleRememberAccount()
saveLoginPreference(true, 'picker-42')
vm.toggleRememberAccount()
assert.equal(vm.rememberAccount, false)
assert.deepEqual(loadLoginPreference(), { rememberAccount: false, account: '' })
})
test('login with remembering disabled leaves the next login form empty', async () => {
storage()
const vm = page()
vm.toggleRememberAccount()
vm.form = { userId: 'picker-42', password: 'test-password' }
await vm.login()
assert.equal(vm.error, '')
assert.equal(session.currentUser.id, '42')
signOut()
const reopened = page()
assert.equal(reopened.rememberAccount, false)
assert.deepEqual(reopened.form, { userId: '', password: '' })
})
test('failed authentication and rejected operator tokens preserve the last successful account', async () => {
storage()
saveLoginPreference(true, 'previous-picker')
for (const loginRequest of [
async () => { throw new Error('密码错误') },
async () => ({ ...tokens(), authorities: [] })
]) {
const vm = page({ loginRequest })
vm.form = { userId: 'other-picker', password: 'test-password' }
await vm.login()
assert.ok(vm.error)
assert.equal(vm.submitting, false)
assert.equal(session.currentUser, null)
assert.deepEqual(loadLoginPreference(), { rememberAccount: true, account: 'previous-picker' })
}
})
+38
View File
@@ -54,6 +54,44 @@ test('camera resume retains all unsigned uploads by role and clears them across
vm.suspendReceiving()
})
test('camera callbacks before and after onShow keep every unsigned photo after preparing a container', async t => {
store.resetReceivingSession()
const current = sessionValue({ status: 'WAIT_SIGN', preparedAt: '2026-11-06T17:24:00.124', photos: [], domesticLoadingPhotos: [] })
const { vm, definition } = page({
prepareReceivingContainer: async () => ({ ...current, preparedAt: '2026-11-06T17:24:00.123789' }),
getCurrentReceiving: async () => structuredClone(current),
uploadCurrentUnloadPhoto: async path => ({ fileId: path.split(':')[1] })
})
t.after(() => vm.suspendReceiving())
const previousUni = globalThis.uni
t.after(() => { globalThis.uni = previousUni })
vm.refreshBoundWarehouse = async () => {}
let refresh, upload, camera
const bootstrap = vm.bootstrap, replacePhoto = vm.replacePhoto
vm.bootstrap = () => { refresh = bootstrap(); return refresh }
vm.replacePhoto = (...args) => { upload = replacePhoto(...args); return upload }
globalThis.uni = { chooseImage: options => { camera = options; definition.onHide.call(vm) } }
store.receiving.selected = { ...current, sourceContainerBookingId: 'BOOKING-A', allPlansReady: true, operable: true }
await vm.prepareContainer()
vm.bootstrapped = true
vm.choosePhoto(0)
camera.success({ tempFilePaths: ['camera:plate'] })
definition.onShow.call(vm)
await upload
assert.equal(vm.photoSlots[0].fileId, 'plate')
for (const [index, role] of [[1, 'seal'], [2, 'door']]) {
vm.choosePhoto(index)
definition.onShow.call(vm)
await refresh
camera.success({ tempFilePaths: [`camera:${role}`] })
await upload
}
assert.deepEqual(vm.photoSlots.map(photo => photo?.fileId), ['plate', 'seal', 'door'])
assert.equal(vm.photosReady, true)
})
test('same current readback retains three signed previews; collapsed unversioned domestic images issue no downloads', async () => {
store.resetReceivingSession(); const calls = []
const download = async path => { calls.push(path); return `local:${path}` }
+28 -3
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { parse, compileStyle, compileTemplate } from '@vue/compiler-sfc'
const read = (relative) => readFileSync(new URL(relative, import.meta.url), 'utf8')
const page = read('../src/pages/operation/operation.vue')
@@ -91,10 +92,34 @@ test('domestic cabinet photos are read-only, previewable and non-blocking in sig
})
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, /v-if="canFinishOnsite" class="receiving-finish-card"/)
assert.match(page, /allInboundsReconciled \? '完成整柜入库' : '现场收货结束(标记差异)'/)
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.ok(page.indexOf('class="receiving-finish-card"') > page.indexOf('@tap="submitReceipt"'))
assert.match(page, /:class="allInboundsReconciled \? 'primary-button' : 'secondary-button'"/)
assert.match(page, /receiving-content\{[^}]*padding-bottom:calc\(24px \+ env\(safe-area-inset-bottom,0px\)\)/)
assert.match(page, /primary-button,\.secondary-button,\.ghost-button\{[^}]*align-items:center;justify-content:center[^}]*line-height:normal;text-align:center/)
assert.match(page, /sticky-action button\{[^}]*align-items:center;justify-content:center;line-height:normal;text-align:center/)
const { descriptor, errors } = parse(page)
assert.deepEqual(errors, [])
const style = compileStyle({ source: descriptor.styles[0].content, filename: 'operation.vue', id: 'receiving', scoped: true })
assert.deepEqual(style.errors, [])
const dockPositions = []
style.rawResult.root.walkRules(rule => {
if (rule.selector.includes('.sticky-action-dock')) rule.walkDecls('position', declaration => dockPositions.push(declaration.value))
if (rule.selector.includes('.receiving-finish-card')) rule.walkDecls('position', declaration => assert.equal(declaration.value, 'static'))
})
assert.deepEqual(dockPositions, ['static'])
})
test('pallet quantity input reserves room above the keyboard and confirms through receipt submission', () => {
const { descriptor, errors } = parse(page)
assert.deepEqual(errors, [])
const compiled = compileTemplate({ source: descriptor.template.content, filename: 'operation.vue', id: 'receiving' })
assert.deepEqual(compiled.errors, [])
const quantityInput = descriptor.template.content.match(/<input[^>]*class="quantity-input"[^>]*>/)?.[0] || ''
assert.match(quantityInput, /v-model\.number="packageCount"/)
assert.match(quantityInput, /type="number" inputmode="numeric"/)
assert.match(quantityInput, /:adjust-position="true" :cursor-spacing="120" :auto-blur="true"/)
assert.match(quantityInput, /@focus="activeTarget = 'quantity'" @confirm="submitReceipt"/)
assert.match(page, /class="quantity-unit">箱/)
})