From afd722a7e2d655c48ea3fdbdebbf57c043424831 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 09:17:59 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index e6cd6f3..596ade2 100644 --- a/manifest.json +++ b/manifest.json @@ -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" } -- 2.54.0 From 117431a4fbc87bf67275428ba54b02713149455f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 14:22:14 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 ++- pages/picking/picking.vue | 9 +++++---- tests/app-update.test.mjs | 13 +++++++++++++ tests/picking-partial-actions.test.mjs | 22 ++++++++++++++++++++++ utils/app-update.js | 11 +++++++++-- 5 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 tests/picking-partial-actions.test.mjs diff --git a/README.md b/README.md index d22491f..5a9293e 100644 --- a/README.md +++ b/README.md @@ -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。 diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index 5d8c823..e68e4fd 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -101,10 +101,10 @@ - + - + 已同步服务端 @@ -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 diff --git a/tests/app-update.test.mjs b/tests/app-update.test.mjs index 7b0d8e6..9e04dad 100644 --- a/tests/app-update.test.mjs +++ b/tests/app-update.test.mjs @@ -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') diff --git a/tests/picking-partial-actions.test.mjs b/tests/picking-partial-actions.test.mjs new file mode 100644 index 0000000..aa8972a --- /dev/null +++ b/tests/picking-partial-actions.test.mjs @@ -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\) : ''/) +}) diff --git a/utils/app-update.js b/utils/app-update.js index 41c4316..33d7c7b 100644 --- a/utils/app-update.js +++ b/utils/app-update.js @@ -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) { -- 2.54.0 From 1e171ec81c18bb7f276e9371c7c1f06a4fc9fdf7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 16:10:48 +0800 Subject: [PATCH 3/4] fix: prefer pending pallet for carton scan --- tests/picking-state.test.mjs | 11 +++++++++++ utils/picking-state.js | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/picking-state.test.mjs b/tests/picking-state.test.mjs index 8d8347c..f8ca3b6 100644 --- a/tests/picking-state.test.mjs +++ b/tests/picking-state.test.mjs @@ -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', + }) +}) diff --git a/utils/picking-state.js b/utils/picking-state.js index db267b6..a1e3566 100644 --- a/utils/picking-state.js +++ b/utils/picking-state.js @@ -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 -- 2.54.0 From 4ed1faea5f4cca3d1bdcf54b31a901572ae1155b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 22:19:02 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stores/session.js | 35 ++++++++++++++++------------------ tests/auth-session.test.mjs | 18 +++++++++++++++++- utils/jwt-claims.js | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 utils/jwt-claims.js diff --git a/stores/session.js b/stores/session.js index a5fa224..89ffc90 100644 --- a/stores/session.js +++ b/stores/session.js @@ -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() diff --git a/tests/auth-session.test.mjs b/tests/auth-session.test.mjs index e6a1108..0cf13d4 100644 --- a/tests/auth-session.test.mjs +++ b/tests/auth-session.test.mjs @@ -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 () => { diff --git a/utils/jwt-claims.js b/utils/jwt-claims.js new file mode 100644 index 0000000..b4e3410 --- /dev/null +++ b/utils/jwt-claims.js @@ -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 无法解析') + } +} -- 2.54.0