Merge pull request 'Release/test' (#2) from release/test into main
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -90,6 +90,7 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查国内 PDA 的独
|
||||
```json
|
||||
{
|
||||
"update": true,
|
||||
"appid": "<国内PDA AppID>",
|
||||
"packageType": "apk",
|
||||
"versionName": "0.0.2",
|
||||
"versionCode": 2,
|
||||
@@ -99,7 +100,7 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查国内 PDA 的独
|
||||
}
|
||||
```
|
||||
|
||||
`packageType` 只允许 `apk` 或 `wgt`。`mandatory=true` 时员工不能跳过,并在下载或安装失败后提供重试;wgt 安装完成会立即重启,APK 交由 Android 安装器完成覆盖安装。正式发布要求:
|
||||
`packageType` 只允许 `apk` 或 `wgt`,`appid` 必须与当前安装包一致。WGT 清单还须提供 `minAppVersionCode`(最低兼容 APK 整数版本),不满足时客户端拒绝安装并提示升级 APK。`mandatory=true` 时员工不能跳过,并在下载或安装失败后提供重试;wgt 安装完成会立即重启,APK 交由 Android 安装器完成覆盖安装。正式发布要求:
|
||||
|
||||
1. 国内 PDA 每次整包发布同时递增 `manifest.json` 的 `versionName` 与整数 `versionCode`,使用国内独立 AppID、包名和签名打包 APK。
|
||||
2. wgt 发布至少递增 `versionName`,并使用与已安装原生包兼容的 uni-app 编译器生成;新增原生模块、权限或不兼容运行时能力时必须发布 APK,不能只发 wgt。
|
||||
|
||||
+5
-3
@@ -2,8 +2,8 @@
|
||||
"name" : "柏悦物流 PDA",
|
||||
"appid" : "__UNI__9BFFAF2",
|
||||
"description" : "柏悦物流 PDA 作业端",
|
||||
"versionName" : "0.0.1",
|
||||
"versionCode" : 1,
|
||||
"versionName" : "0.0.2",
|
||||
"versionCode" : 2,
|
||||
"transformPx" : false,
|
||||
"app-plus" : {
|
||||
/* 5+App特有相关 */
|
||||
@@ -94,5 +94,7 @@
|
||||
"devServer" : {
|
||||
"port" : 5175
|
||||
}
|
||||
}
|
||||
},
|
||||
"fallbackLocale" : "zh-Hans",
|
||||
"locale" : "zh-Hans"
|
||||
}
|
||||
|
||||
@@ -101,10 +101,10 @@
|
||||
<view v-if="itemState(item) === ITEM_STATUS.PENDING && isSealedPalletItem(item)" class="pick-item-actions">
|
||||
<button class="pick-btn pick-btn-confirm" @tap="pickItem(item.id)">确认整托拣出</button>
|
||||
</view>
|
||||
<view v-else-if="itemState(item) === ITEM_STATUS.PENDING" class="pick-item-actions">
|
||||
<view v-else-if="!isSealedPalletItem(item) && [ITEM_STATUS.PENDING, ITEM_STATUS.PARTIAL].includes(itemState(item))" class="pick-item-actions">
|
||||
<button class="pick-btn pick-btn-confirm" @tap="pickItem(item.id)">确认拣出</button>
|
||||
<button class="pick-btn pick-btn-partial" @tap="openPartial(item.id)">部分拣货</button>
|
||||
<button class="pick-btn pick-btn-skip" @tap="openSkip(item.id)">跳拣</button>
|
||||
<button v-if="itemState(item) === ITEM_STATUS.PENDING" class="pick-btn pick-btn-skip" @tap="openSkip(item.id)">跳拣</button>
|
||||
</view>
|
||||
<view v-else-if="pickingOperationReady" class="pick-item-actions processed-actions">
|
||||
<text class="version-hint">已同步服务端</text>
|
||||
@@ -469,12 +469,13 @@
|
||||
if (!this.partialTargetItem) return false
|
||||
const value = parseInt(this.partialInput, 10)
|
||||
const available = this.availablePickQuantity(this.partialTargetItem)
|
||||
return Number.isInteger(value) && value > 0 && value < this.partialTargetItem.qty && (available === null || value <= available)
|
||||
return Number.isInteger(value) && value > 0 && value >= Number(this.partialTargetItem.pickedPackageCount || 0) && value < this.partialTargetItem.qty && (available === null || value <= available)
|
||||
},
|
||||
partialValidationMessage() {
|
||||
if (!this.partialInput || !this.partialTargetItem) return ''
|
||||
const value = parseInt(this.partialInput, 10)
|
||||
if (!Number.isInteger(value) || value <= 0) return '请输入大于 0 的整数'
|
||||
if (value < Number(this.partialTargetItem.pickedPackageCount || 0)) return `实际拣出总数不能少于已拣的 ${this.partialTargetItem.pickedPackageCount} 箱`
|
||||
if (value >= this.partialTargetItem.qty) return `部分拣货必须少于 ${this.partialTargetItem.qty},整单请返回后点击“确认拣出”`
|
||||
const available = this.availablePickQuantity(this.partialTargetItem)
|
||||
if (available !== null && value > available) return `超过库存/库位可用量,可拣 ${available}`
|
||||
@@ -1527,7 +1528,7 @@
|
||||
const item = this.currentBox.items.find((entry) => entry.id === id)
|
||||
if (isSealedPalletItem(item)) { this.showToast(`整托货物不能部分拣货,请扫描 ${item.palletNo}`, 'warning'); return }
|
||||
this.partialTargetId = id
|
||||
this.partialInput = ''
|
||||
this.partialInput = Number(item?.pickedPackageCount || 0) > 0 ? String(item.pickedPackageCount) : ''
|
||||
this.openOverlay('partial')
|
||||
this.$nextTick(() => {
|
||||
this.partialFocused = true
|
||||
|
||||
+16
-19
@@ -1,30 +1,15 @@
|
||||
import { reactive } from 'vue'
|
||||
import { resetPickingSession } from '@/stores/picking.js'
|
||||
import { PLATFORM_API_ROOT } from '@/config/runtime.js'
|
||||
import { decodeAccessToken } from '@/utils/jwt-claims.js'
|
||||
|
||||
export { decodeAccessToken } from '@/utils/jwt-claims.js'
|
||||
|
||||
const SESSION_KEY = 'pda-auth-session'
|
||||
const LOGIN_PREFERENCE_KEY = 'pda-login-account-preference'
|
||||
|
||||
export const session = reactive({ currentUser: null, token: '', refreshToken: '', tenantId: '', apiBase: PLATFORM_API_ROOT })
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
if (typeof atob === 'function') return decodeURIComponent(Array.from(atob(padded), (char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`).join(''))
|
||||
throw new Error('当前运行环境无法解析登录令牌')
|
||||
}
|
||||
|
||||
export function decodeAccessToken(token) {
|
||||
const payload = String(token || '').split('.')[1]
|
||||
if (!payload) throw new Error('登录响应中的 accessToken 格式无效')
|
||||
try {
|
||||
return JSON.parse(decodeBase64Url(payload))
|
||||
} catch (error) {
|
||||
if (error.message === '当前运行环境无法解析登录令牌') throw error
|
||||
throw new Error('登录响应中的 accessToken 无法解析')
|
||||
}
|
||||
}
|
||||
|
||||
export function isAccessTokenExpiring(token = session.token, skewSeconds = 30) {
|
||||
try {
|
||||
const expiresAt = Number(decodeAccessToken(token).exp || 0) * 1000
|
||||
@@ -137,7 +122,19 @@ export function restoreSession() {
|
||||
const claims = decodeAccessToken(saved.token)
|
||||
if (claims.app_code !== 'PLATFORM') throw new Error('登录域无效')
|
||||
if (claims.exp && claims.exp * 1000 <= Date.now() && !saved.refreshToken) throw new Error('登录状态已过期')
|
||||
Object.assign(session, saved, { apiBase: saved.apiBase || PLATFORM_API_ROOT })
|
||||
const savedUser = saved.currentUser || {}
|
||||
Object.assign(session, saved, {
|
||||
currentUser: {
|
||||
...savedUser,
|
||||
id: String(claims.user_id ?? savedUser.id ?? ''),
|
||||
name: claims.username || savedUser.name || '仓库作业员',
|
||||
role: Array.isArray(claims.roles) ? claims.roles.join('、') : (savedUser.role || ''),
|
||||
roles: claims.roles || savedUser.roles || []
|
||||
},
|
||||
tenantId: claims.tenant_id == null ? (saved.tenantId || '') : String(claims.tenant_id),
|
||||
apiBase: saved.apiBase || PLATFORM_API_ROOT
|
||||
})
|
||||
persistSession()
|
||||
return true
|
||||
} catch (_) {
|
||||
signOut()
|
||||
|
||||
@@ -31,6 +31,19 @@ test('online update supports mandatory APK and wgt installation flows', async ()
|
||||
assert.match(updater, /if \(retry\) await runUpdate\(update\)/)
|
||||
})
|
||||
|
||||
test('update manifest rejects a different AppID and incompatible WGT base', async () => {
|
||||
const updater = await read('../utils/app-update.js')
|
||||
const body = updater.match(/function normalizeUpdate\(payload, runtime\) \{[\s\S]*?\n\}/)?.[0]
|
||||
assert.ok(body)
|
||||
const normalize = new Function(`${body}; return normalizeUpdate`)()
|
||||
const runtime = { appid: 'domestic-app', appVersionCode: 3 }
|
||||
const manifest = { update: true, appid: 'domestic-app', packageType: 'wgt', downloadUrl: 'https://example.com/app.wgt', versionName: '1.0.4', minAppVersionCode: 3 }
|
||||
assert.equal(normalize(manifest, runtime).minAppVersionCode, 3)
|
||||
assert.throws(() => normalize({ ...manifest, appid: 'overseas-app' }, runtime), /不匹配/)
|
||||
assert.throws(() => normalize({ ...manifest, minAppVersionCode: 4 }, runtime), /新版 APK/)
|
||||
assert.throws(() => normalize({ ...manifest, minAppVersionCode: undefined }, runtime), /新版 APK/)
|
||||
})
|
||||
|
||||
test('profile exposes manual App update checking and the runtime version', async () => {
|
||||
const profile = await read('../pages/profile/profile.vue')
|
||||
|
||||
|
||||
@@ -26,10 +26,26 @@ test('refresh rotation persists the complete token pair and preserves operator c
|
||||
})
|
||||
|
||||
test('app launch restores and validates persisted tokens before entering the workbench', async () => {
|
||||
const [login, http] = await Promise.all([read('../pages/login/login.vue'), read('../api/http.js')])
|
||||
const [login, http, session] = await Promise.all([read('../pages/login/login.vue'), read('../api/http.js'), read('../stores/session.js')])
|
||||
assert.match(login, /async onLoad\(\)[\s\S]*?restoreSession\(\)[\s\S]*?await ensureAccessToken\(\)[\s\S]*?pages\/home\/home/)
|
||||
assert.match(http, /export function ensureAccessToken\(\)/)
|
||||
assert.match(login, /暂时无法恢复登录状态/)
|
||||
assert.match(session, /id: String\(claims\.user_id \?\? savedUser\.id \?\? ''\)/)
|
||||
assert.match(session, /persistSession\(\)[\s\S]*?return true/)
|
||||
})
|
||||
|
||||
test('JWT decoding preserves 19 digit identity claims exactly', async () => {
|
||||
const { decodeAccessToken } = await import('../utils/jwt-claims.js')
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
app_code: 'PLATFORM',
|
||||
user_id: 1,
|
||||
tenant_id: '2101483345056575489',
|
||||
exp: 4102444800
|
||||
}).replace('"user_id":1', '"user_id":2100885001485500418')).toString('base64url')
|
||||
const claims = decodeAccessToken(`header.${payload}.signature`)
|
||||
assert.equal(claims.user_id, '2100885001485500418')
|
||||
assert.equal(claims.tenant_id, '2101483345056575489')
|
||||
assert.equal(claims.exp, 4102444800)
|
||||
})
|
||||
|
||||
test('confirmed session expiry clears sensitive state and redirects only once', async () => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const page = readFileSync(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('ordinary partially picked items retain full and partial picking actions, but not skip', () => {
|
||||
assert.match(page, /v-else-if="!isSealedPalletItem\(item\) && \[ITEM_STATUS.PENDING, ITEM_STATUS.PARTIAL\].includes\(itemState\(item\)\)"/)
|
||||
assert.match(page, /v-if="itemState\(item\) === ITEM_STATUS.PENDING" class="pick-btn pick-btn-skip"/)
|
||||
assert.match(page, /const remaining = Number\(item\?\.qty \|\| 0\) - Number\(item\?\.pickedPackageCount \|\| 0\)/)
|
||||
assert.match(page, /'PICK', \{ pickedPackageCount: remaining, source \}/)
|
||||
})
|
||||
|
||||
test('partial quantity is a cumulative total and cannot erase boxes already picked', () => {
|
||||
const body = page.match(/partialInputValid\(\) \{([\s\S]*?)\n\t\t\t\},/)[1]
|
||||
const valid = new Function(body)
|
||||
const context = { partialTargetItem: { qty: 20, pickedPackageCount: 3 }, availablePickQuantity: () => 20 }
|
||||
for (const [input, expected] of [['2', false], ['3', true], ['12', true], ['20', false]]) {
|
||||
assert.equal(valid.call({ ...context, partialInput: input }), expected, input)
|
||||
}
|
||||
assert.match(page, /this.partialInput = Number\(item\?\.pickedPackageCount \|\| 0\) > 0 \? String\(item.pickedPackageCount\) : ''/)
|
||||
})
|
||||
@@ -149,3 +149,14 @@ test('sealed pallet resolves either its DP code or any member carton as one whol
|
||||
status: state.ITEM_STATUS.PICKED,
|
||||
})
|
||||
})
|
||||
|
||||
test('a carton duplicated by a stale task payload selects the remaining sealed pallet', () => {
|
||||
const picked = { id: 1, unitType: 'SEALED_PALLET', palletNo: 'DP-260920-0001', boxCodes: ['EXXHWK2809191198#20-01'] }
|
||||
const pending = { id: 2, unitType: 'SEALED_PALLET', palletNo: 'DP-260920-0002', boxCodes: ['EXXHWK2809191198#20-01', 'EXXHWK2809191198#20-19'] }
|
||||
assert.deepEqual(state.classifyScan([picked, pending], {
|
||||
1: state.ITEM_STATUS.PICKED,
|
||||
2: state.ITEM_STATUS.PENDING,
|
||||
}, 'EXXHWK2809191198#20-19'), {
|
||||
type: 'pallet', item: pending, matchedBy: 'carton',
|
||||
})
|
||||
})
|
||||
|
||||
+9
-2
@@ -30,10 +30,11 @@ function getRuntimeInfo() {
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeUpdate(payload) {
|
||||
function normalizeUpdate(payload, runtime) {
|
||||
const value = payload && typeof payload === 'object' ? payload : {}
|
||||
if (value.update !== true) return null
|
||||
|
||||
if (value.appid !== runtime.appid) throw new Error('更新包与当前应用不匹配')
|
||||
const packageType = String(value.packageType || '').toLowerCase()
|
||||
const downloadUrl = String(value.downloadUrl || '').trim()
|
||||
const versionName = String(value.versionName || '').trim()
|
||||
@@ -41,12 +42,18 @@ function normalizeUpdate(payload) {
|
||||
throw new Error('更新服务返回了无效的版本信息')
|
||||
}
|
||||
if (!/^https:\/\//i.test(downloadUrl)) throw new Error('更新包必须使用 HTTPS 地址')
|
||||
const minAppVersionCode = Number(value.minAppVersionCode)
|
||||
if (packageType === 'wgt' && (!Number.isInteger(minAppVersionCode) || minAppVersionCode < 1
|
||||
|| minAppVersionCode > runtime.appVersionCode)) {
|
||||
throw new Error('当前安装包不支持此热更新,请安装新版 APK')
|
||||
}
|
||||
|
||||
return {
|
||||
packageType,
|
||||
downloadUrl,
|
||||
versionName,
|
||||
versionCode: Number(value.versionCode || 0),
|
||||
minAppVersionCode,
|
||||
mandatory: value.mandatory === true,
|
||||
releaseNotes: String(value.releaseNotes || '').trim()
|
||||
}
|
||||
@@ -181,7 +188,7 @@ export async function checkForAppUpdate({ manual = false } = {}) {
|
||||
})
|
||||
if (response.statusCode !== 200) throw new Error('更新服务暂时不可用')
|
||||
|
||||
const update = normalizeUpdate(response.data?.data ?? response.data)
|
||||
const update = normalizeUpdate(response.data?.data ?? response.data, runtime)
|
||||
if (update && isNewerUpdate(update, runtime)) {
|
||||
await runUpdate(update)
|
||||
} else if (manual) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
const EXACT_INTEGER_CLAIMS = [
|
||||
'user_id',
|
||||
'userId',
|
||||
'tenant_id',
|
||||
'tenantId',
|
||||
'operator_tenant_id',
|
||||
'membership_id',
|
||||
'overseas_warehouse_id',
|
||||
'actor_user_id',
|
||||
'actor_membership_id'
|
||||
]
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
if (typeof atob === 'function') return decodeURIComponent(Array.from(atob(padded), (char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`).join(''))
|
||||
throw new Error('当前运行环境无法解析登录令牌')
|
||||
}
|
||||
|
||||
function restoreExactIntegerClaims(payload, claims) {
|
||||
for (const key of EXACT_INTEGER_CLAIMS) {
|
||||
const match = payload.match(new RegExp(`"${key}"\\s*:\\s*(-?\\d+)`))
|
||||
if (match) claims[key] = match[1]
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
export function decodeAccessToken(token) {
|
||||
const payloadPart = String(token || '').split('.')[1]
|
||||
if (!payloadPart) throw new Error('登录响应中的 accessToken 格式无效')
|
||||
try {
|
||||
const payload = decodeBase64Url(payloadPart)
|
||||
return restoreExactIntegerClaims(payload, JSON.parse(payload))
|
||||
} catch (error) {
|
||||
if (error.message === '当前运行环境无法解析登录令牌') throw error
|
||||
throw new Error('登录响应中的 accessToken 无法解析')
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,11 @@ export function classifyScan(items, states, scanCode, scannedBoxCodes = {}) {
|
||||
return { type: 'pallet', item: palletItem }
|
||||
}
|
||||
|
||||
const item = items.find((entry) => (entry.boxCodes || []).includes(scanCode)) || null
|
||||
const matchingItems = items.filter((entry) => (entry.boxCodes || []).includes(scanCode))
|
||||
const item = matchingItems.find((entry) => {
|
||||
const status = states[entry.id] || ITEM_STATUS.PENDING
|
||||
return status !== ITEM_STATUS.PICKED && status !== ITEM_STATUS.SKIPPED
|
||||
}) || matchingItems[0] || null
|
||||
if (!item) return { type: 'unknown', item: null }
|
||||
if (isSealedPalletItem(item)) {
|
||||
const status = states[item.id] || ITEM_STATUS.PENDING
|
||||
|
||||
Reference in New Issue
Block a user