From f6d4ed6c3c8e02363898cccc00eefe678c191406 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 24 Aug 2026 20:47:13 +0800
Subject: [PATCH 01/25] =?UTF-8?q?=E4=BC=98=E5=8C=96PDA=E8=A3=85=E6=9F=9C?=
=?UTF-8?q?=E5=AE=8C=E6=88=90=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pages/picking/picking.vue | 11 ++++++++++-
tests/picking-evidence-stage.test.mjs | 9 +++++++++
2 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue
index 9ff9360..0ee5978 100644
--- a/pages/picking/picking.vue
+++ b/pages/picking/picking.vue
@@ -651,6 +651,14 @@
isTaskConflict(error) {
return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409
},
+ remoteFailureMessage(error, fallback = '操作失败') {
+ const raw = String(error?.message || '').trim()
+ const message = /^internal server error$/i.test(raw)
+ ? '服务端处理异常,请联系管理员'
+ : (raw || fallback)
+ const reference = String(error?.requestId || error?.traceId || '').trim()
+ return reference ? `${message}(请求编号:${reference})` : message
+ },
async refreshAfterTaskConflict(message) {
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
this.submitId = ''
@@ -1405,7 +1413,8 @@
await this.refreshAfterTaskConflict(error.message || '订舱、装柜关联或证据状态已变化,请刷新后重试')
return
}
- this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败')
+ this.debugSubmit('complete.error', { message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
+ this.remoteError = error.network ? '网络失败,装柜完成未提交' : this.remoteFailureMessage(error, '装柜完成失败')
this.showToast('装柜完成失败', 'error')
}
finally {
diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs
index 313a40a..f04b8b8 100644
--- a/tests/picking-evidence-stage.test.mjs
+++ b/tests/picking-evidence-stage.test.mjs
@@ -243,3 +243,12 @@ test('picking mutations stay closed until the canonical task has been claimed',
assert.match(body, /if \(!this\.requirePickingOperation\(\)\) return/, `${method} should wait for claim`)
}
})
+
+test('completion shows a Chinese server failure with a request correlation id', () => {
+ assert.match(page, /remoteFailureMessage\(error, fallback = '操作失败'\)/)
+ assert.match(page, /\^internal server error\$\/i/)
+ assert.match(page, /服务端处理异常,请联系管理员/)
+ assert.match(page, /请求编号/)
+ assert.match(page, /requestIdFromServer: error\.requestId/)
+ assert.match(page, /this\.remoteFailureMessage\(error, '装柜完成失败'\)/)
+})
From 6572771227a99ae7bb61d3d0f2ff794f92a735db Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Tue, 25 Aug 2026 20:50:01 +0800
Subject: [PATCH 02/25] =?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 | 2 +-
api/auth.js | 11 ++-
api/http.js | 149 +++++++++++++++++++++++++++++++++
api/pda-loading.js | 84 ++++---------------
pages/login/login.vue | 28 ++++++-
pages/profile/profile.vue | 4 +-
stores/session.js | 51 ++++++++++-
styles/pda.scss | 14 ++++
tests/auth-session.test.mjs | 59 +++++++++++++
tests/online-session.test.mjs | 7 +-
tests/page-contract.test.mjs | 5 ++
tests/pda-loading-api.test.mjs | 13 +--
12 files changed, 341 insertions(+), 86 deletions(-)
create mode 100644 api/http.js
create mode 100644 tests/auth-session.test.mjs
diff --git a/README.md b/README.md
index c8636e1..74f7ccb 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@
- `pages/scanner-test/scanner-test`:Android 硬件扫描广播诊断页;
- `pages/picking/picking`:集装箱拣货完整交互。
-登录会话集中在 `stores/session.js`,拣货会话集中在 `stores/picking.js`;当前实现仍是内存态,不写入设备。产品契约要求未提交拣货草稿持久化到设备,并绑定作业员、任务和柜次,其他账号不能继承;成功提交或明确放弃后才清理。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。
+登录会话集中在 `stores/session.js` 并持久化到设备,拣货会话集中在 `stores/picking.js`。认证请求由 `api/http.js` 统一处理:Access Token 临近过期时使用 Refresh Token 轮换;并发请求共享一次刷新;401 刷新成功后只重放一次;Refresh Token 明确失效时清理会话和作业草稿并返回登录页,网络或服务异常不会误清会话。产品契约要求未提交拣货草稿持久化到设备,并绑定作业员、任务和柜次,其他账号不能继承;成功提交或明确放弃后才清理。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。
拣货原型会在本次登录会话内按箱保存本地草稿和最近扫码结果。已确认的正式流程是:进入任务即由后端标记“拣货中”;扫码只更新本地草稿,员工点击提交时才将整批明细写入后端,不逐码提交。允许部分拣货或跳拣,但每个未完成项必须填写异常原因,并挂起任务、阻断完成和 `LOADED`。当前草稿尚未持久化到设备,这是实现缺口,不代表允许离线作业。
diff --git a/api/auth.js b/api/auth.js
index 4ed5cb5..86e1969 100644
--- a/api/auth.js
+++ b/api/auth.js
@@ -1,11 +1,13 @@
import { API_ROOT } from '@/config/runtime.js'
-function authRequest(path, data) {
+function authRequest(path, data, options = {}) {
return new Promise((resolve, reject) => {
uni.request({
url: `${API_ROOT}${path}`,
method: 'POST',
data,
+ header: options.header,
+ timeout: options.timeout || 15000,
success(response) {
const body = response.data || {}
if (String(body.code ?? response.statusCode) !== '200') {
@@ -34,3 +36,10 @@ export function login(username, password) {
export function refresh(refreshToken) {
return authRequest('/auth/refresh', { refreshToken })
}
+
+export function logout(refreshToken, accessToken) {
+ return authRequest('/auth/logout', { refreshToken }, {
+ header: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
+ timeout: 5000
+ })
+}
diff --git a/api/http.js b/api/http.js
new file mode 100644
index 0000000..eb27260
--- /dev/null
+++ b/api/http.js
@@ -0,0 +1,149 @@
+import { refresh as refreshRequest } from '@/api/auth.js'
+import { isAccessTokenExpiring, session, signOut, updateTokenPair } from '@/stores/session.js'
+import { requireOnline } from '@/utils/network.js'
+
+let refreshPromise = null
+let redirectingToLogin = false
+
+function responseCode(response) {
+ return String(response?.data?.code ?? response?.statusCode ?? '')
+}
+
+function isUnauthorized(responseOrError) {
+ const statusCode = Number(responseOrError?.statusCode ?? responseOrError?.response?.statusCode ?? 0)
+ const code = String(responseOrError?.code ?? responseCode(responseOrError))
+ return statusCode === 401 || code === '401' || code === 'A0401'
+}
+
+function sessionExpiredError(source) {
+ const error = new Error('登录已过期,请重新登录')
+ error.code = source?.code || 'A0401'
+ error.statusCode = source?.statusCode || 401
+ return error
+}
+
+function redirectAfterSessionExpiry() {
+ if (redirectingToLogin) return
+ redirectingToLogin = true
+ if (typeof uni !== 'undefined' && typeof uni.showToast === 'function') {
+ uni.showToast({ title: '登录已过期,请重新登录', icon: 'none', duration: 2200 })
+ }
+ if (typeof uni !== 'undefined' && typeof uni.reLaunch === 'function') {
+ uni.reLaunch({
+ url: '/pages/login/login',
+ complete() { setTimeout(() => { redirectingToLogin = false }, 1000) }
+ })
+ }
+}
+
+function expireSession(source, observedRefreshToken = session.refreshToken) {
+ if (observedRefreshToken && session.refreshToken && observedRefreshToken !== session.refreshToken) return session.token
+ const hadSession = Boolean(session.currentUser || session.token || session.refreshToken)
+ signOut()
+ if (hadSession) redirectAfterSessionExpiry()
+ throw sessionExpiredError(source)
+}
+
+function networkError(error, fallback = '网络连接失败') {
+ const next = new Error(error?.errMsg || fallback)
+ next.network = true
+ return next
+}
+
+function rawRequest(options, accessToken) {
+ const baseUrl = session.apiBase || '/api/platform/v1'
+ const headers = { ...(options.header || {}) }
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`
+ if (session.requestId) headers['X-Request-Id'] = session.requestId
+ return new Promise((resolve, reject) => {
+ uni.request({
+ ...options,
+ url: `${baseUrl}${options.url}`,
+ header: headers,
+ success: resolve,
+ fail: (error) => reject(networkError(error))
+ })
+ })
+}
+
+async function refreshAccessToken() {
+ const observedRefreshToken = session.refreshToken
+ if (!observedRefreshToken) return expireSession(null, observedRefreshToken)
+ if (!refreshPromise) {
+ refreshPromise = (async () => {
+ if (session.refreshToken !== observedRefreshToken && session.token) return session.token
+ try {
+ const tokens = await refreshRequest(observedRefreshToken)
+ if (session.refreshToken !== observedRefreshToken) {
+ if (session.token) return session.token
+ throw sessionExpiredError()
+ }
+ updateTokenPair(tokens)
+ redirectingToLogin = false
+ return session.token
+ } catch (error) {
+ if (isUnauthorized(error)) return expireSession(error, observedRefreshToken)
+ throw error
+ }
+ })().finally(() => { refreshPromise = null })
+ }
+ return refreshPromise
+}
+
+async function validAccessToken() {
+ if (!session.token || isAccessTokenExpiring(session.token)) return refreshAccessToken()
+ return session.token
+}
+
+export function ensureAccessToken() {
+ return validAccessToken()
+}
+
+async function runAuthenticated(execute) {
+ requireOnline()
+ let accessToken = await validAccessToken()
+ let response = await execute(accessToken)
+ if (!isUnauthorized(response)) return response
+ accessToken = session.token && session.token !== accessToken ? session.token : await refreshAccessToken()
+ const activeRefreshToken = session.refreshToken
+ response = await execute(accessToken)
+ if (isUnauthorized(response)) expireSession(response, activeRefreshToken)
+ return response
+}
+
+export function apiRequest(options) {
+ return runAuthenticated((accessToken) => rawRequest(options, accessToken)).then((response) => {
+ const body = response.data || {}
+ const code = responseCode(response)
+ if (code !== '200') {
+ const error = new Error(body.message || '服务端请求失败')
+ error.code = body.code || response.statusCode
+ error.statusCode = response.statusCode
+ error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id']
+ error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
+ throw error
+ }
+ return body.data
+ })
+}
+
+export function apiDownload(options) {
+ return runAuthenticated((accessToken) => new Promise((resolve, reject) => {
+ const headers = { ...(options.header || {}) }
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`
+ if (session.requestId) headers['X-Request-Id'] = session.requestId
+ uni.downloadFile({ ...options, header: headers, success: resolve, fail: (error) => reject(networkError(error, '文件下载失败')) })
+ }))
+}
+
+export function apiUpload(options, onProgress) {
+ return runAuthenticated((accessToken) => new Promise((resolve, reject) => {
+ const headers = { ...(options.header || {}) }
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`
+ if (session.requestId) headers['X-Request-Id'] = session.requestId
+ const task = uni.uploadFile({ ...options, header: headers, success: resolve, fail: (error) => reject(networkError(error)) })
+ if (task && typeof task.onProgressUpdate === 'function' && typeof onProgress === 'function') task.onProgressUpdate(({ progress }) => onProgress(progress))
+ }))
+}
+
+export { isUnauthorized }
diff --git a/api/pda-loading.js b/api/pda-loading.js
index 7d7cfce..46b01da 100644
--- a/api/pda-loading.js
+++ b/api/pda-loading.js
@@ -1,5 +1,6 @@
import { session } from '@/stores/session.js'
import { requireOnline } from '@/utils/network.js'
+import { apiDownload, apiRequest, apiUpload } from '@/api/http.js'
const EVIDENCE_TYPES = Object.freeze([
'PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'
@@ -23,41 +24,7 @@ function debugLog(event, payload = {}) {
try { console.info(`[PDA] ${event}`, payload) } catch (_) { /* console may be unavailable on a device */ }
}
-function request(options) {
- requireOnline()
- const baseUrl = session.apiBase || '/api/platform/v1'
- const headers = { ...(options.header || {}) }
- if (session.token) headers.Authorization = `Bearer ${session.token}`
- if (session.requestId) headers['X-Request-Id'] = session.requestId
-
- return new Promise((resolve, reject) => {
- uni.request({
- ...options,
- url: `${baseUrl}${options.url}`,
- header: headers,
- success(response) {
- const body = response.data || {}
- const code = String(body.code ?? response.statusCode)
-
- if (code !== '200') {
- const error = new Error(body.message || '服务端请求失败')
- error.code = body.code || response.statusCode
- error.statusCode = response.statusCode
- error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id']
- error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
- reject(error)
- return
- }
- resolve(body.data)
- },
- fail(error) {
- const next = new Error(error.errMsg || '网络连接失败')
- next.network = true
- reject(next)
- }
- })
- })
-}
+const request = apiRequest
function idempotencyKey(operation, inboundId) {
return `pda-${operation}-${inboundId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
@@ -260,15 +227,8 @@ export function downloadEvidencePreview(previewUrl) {
const origin = baseUrl.match(/^(https?:\/\/[^/]+)/i)?.[1] || ''
url = `${origin}${url.startsWith('/') ? '' : '/'}${url}`
}
- const headers = {}
- if (session.token) headers.Authorization = `Bearer ${session.token}`
- if (session.requestId) headers['X-Request-Id'] = session.requestId
debugLog('evidence.preview.start', { previewPath: String(previewUrl), resolvedUrl: url })
- return new Promise((resolve, reject) => {
- uni.downloadFile({
- url,
- header: headers,
- success(response) {
+ return apiDownload({ url }).then((response) => {
const responseHeaders = response.header || {}
const contentType = responseHeaders['Content-Type'] || responseHeaders['content-type'] || ''
const requestId = responseHeaders['X-Request-Id'] || responseHeaders['x-request-id'] || ''
@@ -279,8 +239,7 @@ export function downloadEvidencePreview(previewUrl) {
error.statusCode = response.statusCode
error.requestId = requestId
error.traceId = traceId
- reject(error)
- return
+ throw error
}
if (contentType && !/^image\//i.test(contentType) && !/^application\/octet-stream/i.test(contentType)) {
const error = new Error('服务端未返回有效照片')
@@ -288,18 +247,12 @@ export function downloadEvidencePreview(previewUrl) {
error.contentType = contentType
error.requestId = requestId
error.traceId = traceId
- reject(error)
- return
+ throw error
}
- resolve(response.tempFilePath)
- },
- fail(error) {
- debugLog('evidence.preview.error', { previewPath: String(previewUrl), message: error.errMsg || '' })
- const next = new Error(error.errMsg || '装柜照片下载失败')
- next.network = true
- reject(next)
- }
- })
+ return response.tempFilePath
+ }).catch((error) => {
+ debugLog('evidence.preview.error', { previewPath: String(previewUrl), message: error.message || error.errMsg || '' })
+ throw error
})
}
@@ -331,28 +284,19 @@ export function uploadEvidenceFile(inboundId, evidenceType, localPath, tenantId,
if (!localPath) throw new Error('缺少待上传的照片')
const tenant = tenantQuery(tenantId)
const baseUrl = session.apiBase || '/api/platform/v1'
- const headers = {}
- if (session.token) headers.Authorization = `Bearer ${session.token}`
- if (session.requestId) headers['X-Request-Id'] = session.requestId
debugLog('evidence.upload.start', { inboundId, evidenceType, tenantId: tenant, hasLocalPath: Boolean(localPath) })
- return new Promise((resolve, reject) => {
- const task = uni.uploadFile({
+ return apiUpload({
url: `${baseUrl}/files/loading-evidence/${encodeURIComponent(tenant)}/${encodeURIComponent(inboundId)}?evidenceType=${encodeURIComponent(evidenceType)}`,
filePath: localPath,
name: 'file',
- header: headers,
- success(response) {
+ }, onProgress).then((response) => {
let body = response.data
try { body = typeof body === 'string' ? JSON.parse(body) : body } catch (_) { /* handled below */ }
const code = String(body?.code ?? response.statusCode)
debugLog('evidence.upload.response', { inboundId, evidenceType, statusCode: response.statusCode, code, message: body?.message || '', 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'] || '', fileId: body?.data?.fileId || null })
- if (code !== '200') { const error = new Error(body?.message || '文件上传失败'); error.code = body?.code || response.statusCode; reject(error); return }
- resolve(body?.data || body)
- },
- fail(error) { debugLog('evidence.upload.error', { inboundId, evidenceType, message: error.errMsg || '' }); const next = new Error(error.errMsg || '网络连接失败'); next.network = true; reject(next) }
- })
- if (task && typeof task.onProgressUpdate === 'function' && typeof onProgress === 'function') task.onProgressUpdate(({ progress }) => onProgress(progress))
- })
+ if (code !== '200') { const error = new Error(body?.message || '文件上传失败'); error.code = body?.code || response.statusCode; throw error }
+ return body?.data || body
+ }).catch((error) => { debugLog('evidence.upload.error', { inboundId, evidenceType, message: error.message || error.errMsg || '' }); throw error })
}
export function completeLoading(inboundId, payload, tenantId) {
diff --git a/pages/login/login.vue b/pages/login/login.vue
index adb5acf..06217e1 100644
--- a/pages/login/login.vue
+++ b/pages/login/login.vue
@@ -7,6 +7,7 @@
工号 / 用户名
密码
+ 记住账号
{{ error }}
@@ -15,12 +16,32 @@
diff --git a/stores/session.js b/stores/session.js
index bba1957..5dc5269 100644
--- a/stores/session.js
+++ b/stores/session.js
@@ -3,6 +3,7 @@ import { resetPickingSession } from '@/stores/picking.js'
import { PLATFORM_API_ROOT } from '@/config/runtime.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 })
@@ -24,10 +25,34 @@ export function decodeAccessToken(token) {
}
}
+export function isAccessTokenExpiring(token = session.token, skewSeconds = 30) {
+ try {
+ const expiresAt = Number(decodeAccessToken(token).exp || 0) * 1000
+ return !expiresAt || expiresAt <= Date.now() + skewSeconds * 1000
+ } catch (_) {
+ return true
+ }
+}
+
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, {
@@ -51,6 +76,7 @@ export function signIn(account, warehouse, options = {}) {
export function signInWithTokens(tokens) {
if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken')
+ if (!tokens?.refreshToken) throw new Error('登录响应缺少 refreshToken')
const claims = decodeAccessToken(tokens.accessToken)
if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端')
if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('登录状态已过期')
@@ -66,16 +92,39 @@ export function signInWithTokens(tokens) {
})
}
+export function updateTokenPair(tokens) {
+ if (!tokens?.accessToken) throw new Error('刷新响应缺少 accessToken')
+ if (!tokens?.refreshToken) throw new Error('刷新响应缺少 refreshToken')
+ const claims = decodeAccessToken(tokens.accessToken)
+ if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端')
+ if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('刷新后的登录状态已过期')
+ const previousUser = session.currentUser || {}
+ session.currentUser = {
+ ...previousUser,
+ id: String(claims.user_id ?? previousUser.id ?? ''),
+ name: claims.username || previousUser.name || '仓库作业员',
+ role: Array.isArray(claims.roles) ? claims.roles.join('、') : (previousUser.role || ''),
+ roles: claims.roles || previousUser.roles || []
+ }
+ session.token = tokens.accessToken
+ session.refreshToken = tokens.refreshToken
+ session.tenantId = claims.tenant_id == null ? session.tenantId : String(claims.tenant_id)
+ persistSession()
+ return session.token
+}
+
export function restoreSession() {
if (!storageAvailable()) return false
const saved = uni.getStorageSync(SESSION_KEY)
if (!saved?.token || !saved?.currentUser) return false
try {
const claims = decodeAccessToken(saved.token)
- if (claims.exp && claims.exp * 1000 <= Date.now()) return false
+ 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 })
return true
} catch (_) {
+ signOut()
return false
}
}
diff --git a/styles/pda.scss b/styles/pda.scss
index 51cb69b..f009902 100644
--- a/styles/pda.scss
+++ b/styles/pda.scss
@@ -133,6 +133,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;
diff --git a/tests/auth-session.test.mjs b/tests/auth-session.test.mjs
new file mode 100644
index 0000000..212eb9f
--- /dev/null
+++ b/tests/auth-session.test.mjs
@@ -0,0 +1,59 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
+
+test('authenticated PDA requests refresh once and replay once after unauthorized', async () => {
+ const http = await read('../api/http.js')
+ assert.match(http, /let refreshPromise = null/)
+ assert.match(http, /refreshPromise = \(async \(\) =>/)
+ assert.match(http, /session\.token && session\.token !== accessToken \? session\.token : await refreshAccessToken\(\)/)
+ assert.match(http, /response = await execute\(accessToken\)[\s\S]*?isUnauthorized\(response\)[\s\S]*?response = await execute\(accessToken\)/)
+ assert.match(http, /apiRequest/)
+ assert.match(http, /apiDownload/)
+ assert.match(http, /apiUpload/)
+})
+
+test('refresh rotation persists the complete token pair and preserves operator context', async () => {
+ const [session, auth] = await Promise.all([read('../stores/session.js'), read('../api/auth.js')])
+ assert.match(session, /export function updateTokenPair\(tokens\)/)
+ assert.match(session, /session\.refreshToken = tokens\.refreshToken/)
+ assert.match(session, /const previousUser = session\.currentUser \|\| \{\}/)
+ assert.match(session, /persistSession\(\)/)
+ assert.match(session, /isAccessTokenExpiring/)
+ assert.match(auth, /authRequest\('\/auth\/refresh', \{ refreshToken \}\)/)
+})
+
+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')])
+ 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, /暂时无法恢复登录状态/)
+})
+
+test('confirmed session expiry clears sensitive state and redirects only once', async () => {
+ const http = await read('../api/http.js')
+ assert.match(http, /observedRefreshToken !== session\.refreshToken/)
+ assert.match(http, /signOut\(\)[\s\S]*?redirectAfterSessionExpiry\(\)/)
+ assert.match(http, /if \(redirectingToLogin\) return/)
+ assert.match(http, /url: '\/pages\/login\/login'/)
+ assert.match(http, /if \(isUnauthorized\(error\)\) return expireSession/)
+})
+
+test('manual logout revokes the refresh token without delaying local cleanup', async () => {
+ const [profile, auth] = await Promise.all([read('../pages/profile/profile.vue'), read('../api/auth.js')])
+ assert.match(auth, /export function logout\(refreshToken, accessToken\)/)
+ assert.match(profile, /const refreshToken = session\.refreshToken; const accessToken = session\.token; signOut\(\); uni\.reLaunch/)
+ assert.match(profile, /logoutRequest\(refreshToken, accessToken\)\.catch/)
+})
+
+test('development login remembers only the account preference, never the entered password', async () => {
+ const [login, session] = await Promise.all([read('../pages/login/login.vue'), read('../stores/session.js')])
+ assert.match(login, /form: \{ userId: 'platform_admin', password: 'ChangeMe123!' \}/)
+ assert.match(session, /export function saveLoginPreference\(rememberAccount, account = ''\)/)
+ const savePreference = session.match(/export function saveLoginPreference[\s\S]*?\n\}/)?.[0] || ''
+ assert.match(savePreference, /account: rememberAccount/)
+ assert.doesNotMatch(savePreference, /password/)
+ assert.match(login, /if \(!this\.rememberAccount\) saveLoginPreference\(false\)/)
+})
diff --git a/tests/online-session.test.mjs b/tests/online-session.test.mjs
index b9ab1dd..7d6cd8e 100644
--- a/tests/online-session.test.mjs
+++ b/tests/online-session.test.mjs
@@ -5,10 +5,11 @@ import { readFile } from 'node:fs/promises'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('remote PDA mutations use the shared online gate', async () => {
- const [api, network, page] = await Promise.all([
- read('../api/pda-loading.js'), read('../utils/network.js'), read('../pages/picking/picking.vue')
+ const [api, http, network, page] = await Promise.all([
+ read('../api/pda-loading.js'), read('../api/http.js'), read('../utils/network.js'), read('../pages/picking/picking.vue')
])
- assert.match(api, /function request\(options\) \{[\s\S]*?requireOnline\(\)/)
+ assert.match(api, /const request = apiRequest/)
+ assert.match(http, /async function runAuthenticated\(execute\) \{[\s\S]*?requireOnline\(\)/)
assert.match(api, /uploadEvidenceFile[\s\S]*?requireOnline\('当前网络不可用,无法上传装柜照片'\)/)
assert.match(network, /uni\.onNetworkStatusChange/)
assert.match(network, /result\.networkType !== 'none'/)
diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs
index cad682c..0a8b258 100644
--- a/tests/page-contract.test.mjs
+++ b/tests/page-contract.test.mjs
@@ -31,7 +31,12 @@ test('login uses the real auth endpoint and relaunches to the modular workbench'
assert.match(auth, /\/auth\/login/)
assert.match(login, /loginRequest\(userId, this\.form\.password\)/)
assert.match(login, /signInWithTokens\(tokens\)/)
+ assert.match(login, /userId: 'platform_admin', password: 'ChangeMe123!'/)
+ assert.match(login, /记住账号/)
+ assert.match(login, /loadLoginPreference\(\)/)
+ assert.match(login, /saveLoginPreference\(this\.rememberAccount, userId\)/)
assert.match(session, /pda-auth-session/)
+ assert.match(session, /pda-login-account-preference/)
assert.match(session, /claims\.app_code !== 'PLATFORM'/)
assert.match(runtime, /import\.meta\.env\.VITE_API_BASE_URL/)
assert.doesNotMatch(runtime, /typeof\s+import\.meta/)
diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs
index c6eeb79..8ac2072 100644
--- a/tests/pda-loading-api.test.mjs
+++ b/tests/pda-loading-api.test.mjs
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
const source = await readFile(new URL('../api/pda-loading.js', import.meta.url), 'utf8')
+const http = await readFile(new URL('../api/http.js', import.meta.url), 'utf8')
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
test('PDA loading adapter keeps C04 read and write paths', () => {
@@ -17,7 +18,8 @@ test('PDA loading adapter keeps C04 read and write paths', () => {
assert.match(source, /'Idempotency-Key'/)
assert.match(source, /PHOTO_STEP_1[\s\S]*PHOTO_STEP_4[\s\S]*SEAL/)
for (const label of ['空仓照片', '半仓照片', '满仓照片', '锁仓照片', '带锁码照']) assert.match(source, new RegExp(label))
- assert.match(source, /uni\.uploadFile/)
+ assert.match(source, /apiUpload\(/)
+ assert.match(http, /uni\.uploadFile/)
assert.match(source, /\/files\/loading-evidence\//)
assert.match(source, /loading\/evidence\/pda\?tenantId=/)
assert.match(source, /loading\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/void\/pda\?tenantId=/)
@@ -27,8 +29,8 @@ test('PDA loading adapter keeps C04 read and write paths', () => {
})
test('PDA adapter does not treat non-200 response bodies or network failures as success', () => {
- assert.match(source, /if \(code !== '200'\)/)
- assert.match(source, /next\.network = true/)
+ assert.match(http, /if \(code !== '200'\)/)
+ assert.match(http, /next\.network = true/)
assert.match(source, /throw new Error\('缺少客户隔离标识 tenantId/)
})
@@ -81,8 +83,9 @@ test('PDA downloads authenticated evidence previews returned by the backend', ()
assert.match(source, /export function getEvidencePreviewUrl\(inboundId, evidenceId, tenantId\)/)
assert.match(source, /loading\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content\/pda\?tenantId=/)
assert.match(source, /export function downloadEvidencePreview\(previewUrl\)/)
- assert.match(source, /uni\.downloadFile\(/)
- assert.match(source, /headers\.Authorization = `Bearer \$\{session\.token\}`/)
+ assert.match(source, /apiDownload\(\{ url \}\)/)
+ assert.match(http, /uni\.downloadFile\(/)
+ assert.match(http, /headers\.Authorization = `Bearer \$\{accessToken\}`/)
assert.match(source, /response\.tempFilePath/)
assert.match(source, /evidence\.preview\.response/)
assert.match(source, /\^image\\\//)
From 69f8fdfcdbcc48504cf556466f4de2b2f26984d4 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Tue, 25 Aug 2026 20:52:11 +0800
Subject: [PATCH 03/25] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 3 +++
config/runtime.js | 8 +++++++-
tests/page-contract.test.mjs | 8 ++++++--
vite.config.js | 24 ++++++++++++++++++++++++
4 files changed, 40 insertions(+), 3 deletions(-)
create mode 100644 .env.example
create mode 100644 vite.config.js
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..471ef2b
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,3 @@
+VITE_API_BASE_URL=http://127.0.0.1:9000/api
+VITE_H5_API_BASE_URL=/api
+API_PROXY_TARGET=http://127.0.0.1:9000
diff --git a/config/runtime.js b/config/runtime.js
index c1edabb..e6c09d8 100644
--- a/config/runtime.js
+++ b/config/runtime.js
@@ -1,4 +1,10 @@
const configuredApiRoot = import.meta.env.VITE_API_BASE_URL || ''
+let selectedApiRoot = configuredApiRoot || '/api'
-export const API_ROOT = String(configuredApiRoot || '/api').replace(/\/$/, '')
+// H5 必须请求同源地址,由 Vite 开发服务器代理到国内 Gateway,避免浏览器 CORS。
+// #ifdef H5
+selectedApiRoot = import.meta.env.VITE_H5_API_BASE_URL || '/api'
+// #endif
+
+export const API_ROOT = String(selectedApiRoot).replace(/\/$/, '')
export const PLATFORM_API_ROOT = `${API_ROOT}/platform/v1`
diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs
index 0a8b258..f01869b 100644
--- a/tests/page-contract.test.mjs
+++ b/tests/page-contract.test.mjs
@@ -23,8 +23,8 @@ test('application uses the Vue 3 uni-app entry and no unused Android permissions
})
test('login uses the real auth endpoint and relaunches to the modular workbench', async () => {
- const [login, auth, session, runtime] = await Promise.all([
- read('../pages/login/login.vue'), read('../api/auth.js'), read('../stores/session.js'), read('../config/runtime.js')
+ const [login, auth, session, runtime, vite] = await Promise.all([
+ read('../pages/login/login.vue'), read('../api/auth.js'), read('../stores/session.js'), read('../config/runtime.js'), read('../vite.config.js')
])
for (const text of ['柏悦物流 · PDA作业端', '工号 / 用户名', '登录中']) assert.match(login, new RegExp(text))
assert.doesNotMatch(login, /DEMO_ACCOUNTS|快捷登录|作业仓库/)
@@ -39,7 +39,11 @@ test('login uses the real auth endpoint and relaunches to the modular workbench'
assert.match(session, /pda-login-account-preference/)
assert.match(session, /claims\.app_code !== 'PLATFORM'/)
assert.match(runtime, /import\.meta\.env\.VITE_API_BASE_URL/)
+ assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL \|\| '\/api'/)
assert.doesNotMatch(runtime, /typeof\s+import\.meta/)
+ assert.match(vite, /API_PROXY_TARGET/)
+ assert.match(vite, /'\/api'[\s\S]*?target: proxyTarget[\s\S]*?changeOrigin: true/)
+ assert.match(vite, /removeHeader\('origin'\)/)
assert.match(login, /uni\.reLaunch\(\{ url: '\/pages\/home\/home' \}\)/)
})
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..6f4b9d5
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,24 @@
+import { defineConfig, loadEnv } from 'vite'
+import uni from '@dcloudio/vite-plugin-uni'
+
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '')
+ const proxyTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
+
+ return {
+ plugins: [uni()],
+ server: {
+ proxy: {
+ '/api': {
+ target: proxyTarget,
+ changeOrigin: true,
+ configure(proxy) {
+ proxy.on('proxyReq', (proxyRequest) => {
+ proxyRequest.removeHeader('origin')
+ })
+ }
+ }
+ }
+ }
+ }
+})
From 35d0595d85766723c6274af4037b4a81fc0b384a Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 27 Aug 2026 11:39:14 +0800
Subject: [PATCH 04/25] =?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 | 186 +++++++++++++++++++++++++-------------------------
1 file changed, 93 insertions(+), 93 deletions(-)
diff --git a/manifest.json b/manifest.json
index 87618a9..2453bee 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,95 +1,95 @@
{
- "name": "柏悦物流 PDA",
- "appid": "__UNI__9BFFAF2",
- "description": "柏悦物流 PDA 作业端",
- "versionName": "0.0.1",
- "versionCode": 1,
- "transformPx": false,
- "app-plus": {
- /* 5+App特有相关 */
- "usingComponents": true,
- "nvueCompiler": "uni-app",
- "nvueStyleCompiler": "uni-app",
- "splashscreen": {
- "alwaysShowBeforeRender": true,
- "waiting": true,
- "autoclose": true,
- "delay": 0
- },
- "modules": {
- "Barcode": {},
- "Bluetooth": {},
- "Camera": {},
- "Geolocation": {},
- "UIWebview": {}
- },
- /* 模块配置 */
- "distribute": {
- /* 应用发布信息 */
- "android": {
- /* android打包配置 */
- "permissions": []
- },
- "ios": {
- "dSYMs": false
- },
- /* ios打包配置 */
- "sdkConfigs": {
- "geolocation": {
- "system": {
- "__platform__": ["ios", "android"]
- }
- }
- },
- "icons": {
- "android": {
- "hdpi": "static/app-icons/72x72.png",
- "xhdpi": "static/app-icons/96x96.png",
- "xxhdpi": "static/app-icons/144x144.png",
- "xxxhdpi": "static/app-icons/192x192.png"
- },
- "ios": {
- "appstore": "static/app-icons/1024x1024.png",
- "ipad": {
- "app": "static/app-icons/76x76.png",
- "app@2x": "static/app-icons/152x152.png",
- "notification": "static/app-icons/20x20.png",
- "notification@2x": "static/app-icons/40x40.png",
- "proapp@2x": "static/app-icons/167x167.png",
- "settings": "static/app-icons/29x29.png",
- "settings@2x": "static/app-icons/58x58.png",
- "spotlight": "static/app-icons/40x40.png",
- "spotlight@2x": "static/app-icons/80x80.png"
- },
- "iphone": {
- "app@2x": "static/app-icons/120x120.png",
- "app@3x": "static/app-icons/180x180.png",
- "notification@2x": "static/app-icons/40x40.png",
- "notification@3x": "static/app-icons/60x60.png",
- "settings@2x": "static/app-icons/58x58.png",
- "settings@3x": "static/app-icons/87x87.png",
- "spotlight@2x": "static/app-icons/80x80.png",
- "spotlight@3x": "static/app-icons/120x120.png"
- }
- }
- }
- }
- },
- /* SDK配置 */
- "quickapp": {},
- /* 快应用特有相关 */
- "mp-weixin": {
- /* 小程序特有相关 */
- "appid": "",
- "setting": {
- "urlCheck": false
- },
- "usingComponents": true
- },
- "vueVersion": "3",
- "h5": {
- "devServer": {
- "port": 5175
- }
- }
+ "name" : "柏悦物流 PDA",
+ "appid" : "__UNI__9BFFAF2",
+ "description" : "柏悦物流 PDA 作业端",
+ "versionName" : "0.0.1",
+ "versionCode" : 1,
+ "transformPx" : false,
+ "app-plus" : {
+ /* 5+App特有相关 */
+ "usingComponents" : true,
+ "nvueCompiler" : "uni-app",
+ "nvueStyleCompiler" : "uni-app",
+ "splashscreen" : {
+ "alwaysShowBeforeRender" : true,
+ "waiting" : true,
+ "autoclose" : true,
+ "delay" : 0
+ },
+ "modules" : {
+ "Barcode" : {},
+ "Bluetooth" : {},
+ "Camera" : {},
+ "Geolocation" : {},
+ "UIWebview" : {}
+ },
+ /* 模块配置 */
+ "distribute" : {
+ /* 应用发布信息 */
+ "android" : {
+ /* android打包配置 */
+ "permissions" : []
+ },
+ "ios" : {
+ "dSYMs" : false
+ },
+ /* ios打包配置 */
+ "sdkConfigs" : {
+ "geolocation" : {
+ "system" : {
+ "__platform__" : [ "ios", "android" ]
+ }
+ }
+ },
+ "icons" : {
+ "android" : {
+ "hdpi" : "static/app-icons/72x72.png",
+ "xhdpi" : "static/app-icons/96x96.png",
+ "xxhdpi" : "static/app-icons/144x144.png",
+ "xxxhdpi" : "static/app-icons/192x192.png"
+ },
+ "ios" : {
+ "appstore" : "static/app-icons/1024x1024.png",
+ "ipad" : {
+ "app" : "static/app-icons/76x76.png",
+ "app@2x" : "static/app-icons/152x152.png",
+ "notification" : "static/app-icons/20x20.png",
+ "notification@2x" : "static/app-icons/40x40.png",
+ "proapp@2x" : "static/app-icons/167x167.png",
+ "settings" : "static/app-icons/29x29.png",
+ "settings@2x" : "static/app-icons/58x58.png",
+ "spotlight" : "static/app-icons/40x40.png",
+ "spotlight@2x" : "static/app-icons/80x80.png"
+ },
+ "iphone" : {
+ "app@2x" : "static/app-icons/120x120.png",
+ "app@3x" : "static/app-icons/180x180.png",
+ "notification@2x" : "static/app-icons/40x40.png",
+ "notification@3x" : "static/app-icons/60x60.png",
+ "settings@2x" : "static/app-icons/58x58.png",
+ "settings@3x" : "static/app-icons/87x87.png",
+ "spotlight@2x" : "static/app-icons/80x80.png",
+ "spotlight@3x" : "static/app-icons/120x120.png"
+ }
+ }
+ }
+ }
+ },
+ /* SDK配置 */
+ "quickapp" : {},
+ /* 快应用特有相关 */
+ "mp-weixin" : {
+ /* 小程序特有相关 */
+ "appid" : "",
+ "setting" : {
+ "urlCheck" : false
+ },
+ "usingComponents" : true
+ },
+ "vueVersion" : "3",
+ "h5" : {
+ "devServer" : {
+ "port" : 5175
+ }
+ }
}
From c704032d661391af63080c7b9eb392feaa96f502 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Thu, 27 Aug 2026 13:50:44 +0800
Subject: [PATCH 05/25] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
api/pda-loading.js | 2 +-
pages/home/home.vue | 9 ++--
pages/login/login.vue | 2 +-
pages/picking/picking.vue | 31 +++++++-------
pages/profile/profile.vue | 2 +-
pages/tasks/tasks.vue | 25 ++++-------
styles/pda.scss | 60 +++++++++++++++------------
tests/app-update.test.mjs | 3 +-
tests/auth-session.test.mjs | 5 ++-
tests/page-contract.test.mjs | 20 +++++++--
tests/picking-evidence-stage.test.mjs | 28 ++++++++-----
tests/production-ui.test.mjs | 25 +++++++++++
12 files changed, 128 insertions(+), 84 deletions(-)
create mode 100644 tests/production-ui.test.mjs
diff --git a/api/pda-loading.js b/api/pda-loading.js
index 46b01da..ccbe673 100644
--- a/api/pda-loading.js
+++ b/api/pda-loading.js
@@ -156,7 +156,7 @@ export function normalizePdaOrder(row = {}) {
cabinetNo: row.cabinetNo ?? row.loading?.cabinetNo ?? row.container?.cabinetNo ?? row.containerNo ?? '',
currentContainerNo: row.currentContainerNo ?? row.containerNo ?? row.container?.containerNo ?? row.loadingPlan?.containerNo ?? row.loading?.containerNo ?? null,
warehouseId: row.warehouseId ?? row.warehouse?.id ?? row.loadingPlan?.warehouseId ?? null,
- displayNo: row.orderNo ?? row.inboundCode ?? row.order?.orderNo ?? '业务单号待同步'
+ displayNo: row.orderNo ?? row.inboundCode ?? row.order?.orderNo ?? ''
}
}
diff --git a/pages/home/home.vue b/pages/home/home.vue
index cfcc05a..fa74fef 100644
--- a/pages/home/home.vue
+++ b/pages/home/home.vue
@@ -8,8 +8,8 @@
@@ -21,7 +21,6 @@
import PortalHeader from '@/components/PortalHeader.vue'
import { MODULES } from '@/data/demo-data.js'
import { session, requireSession } from '@/stores/session.js'
- import { getPickingOverview } from '@/stores/picking.js'
const ICON_TYPES = {
'到货收货': 'download', '商品上架': 'upload', '入库质检': 'checkbox',
@@ -39,8 +38,8 @@
export default {
components: { PortalHeader },
- data() { return { user: session.currentUser, groups: HOME_GROUPS, overview: getPickingOverview(), menuOpen: false } },
- onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() },
+ data() { return { user: session.currentUser, groups: HOME_GROUPS, menuOpen: false } },
+ onShow() { if (!requireSession()) return; this.user = session.currentUser },
onBackPress() {
if (!this.menuOpen) return false
this.$refs.portalHeader.closeMenu()
diff --git a/pages/login/login.vue b/pages/login/login.vue
index 06217e1..ec3b29c 100644
--- a/pages/login/login.vue
+++ b/pages/login/login.vue
@@ -20,7 +20,7 @@
import { loadLoginPreference, saveLoginPreference, session, restoreSession, signInWithTokens } from '@/stores/session.js'
import { requireOnline } from '@/utils/network.js'
export default {
- data() { return { form: { userId: 'platform_admin', password: 'ChangeMe123!' }, rememberAccount: true, error: '', submitting: false } },
+ data() { return { form: { userId: '', password: '' }, rememberAccount: true, error: '', submitting: false } },
async onLoad() {
const preference = loadLoginPreference()
this.rememberAccount = preference.rememberAccount
diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue
index 0ee5978..808a293 100644
--- a/pages/picking/picking.vue
+++ b/pages/picking/picking.vue
@@ -11,10 +11,10 @@
-
+
@@ -33,10 +33,8 @@
-
+
- 请先处理并提交全部待拣明细;任务增量更新后,已拍证据会按当前柜次保留。
-
@@ -124,7 +122,7 @@
-
+
装柜证据空仓、半仓、满仓、锁仓及带锁码照
{{ remoteSyncState.label }}
@@ -624,10 +622,6 @@
}
},
selectStage(stage) {
- if (stage === 'evidence' && !this.loadingStageReady) {
- this.showToast('请先完成新增待拣明细', 'warning')
- return
- }
this.activeStage = stage
},
debugEvidence(event, payload = {}) {
@@ -1160,7 +1154,6 @@
const completedBoxes = { ...this.completedBoxes }
delete completedBoxes[this.currentBoxIndex]
this.completedBoxes = completedBoxes
- this.activeStage = 'picking'
this.completionSummary = { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 }
}
const addedItemIds = this.currentBox.items.map((item) => String(item.id)).filter((id) => !previousItemIds.has(id))
@@ -1255,8 +1248,7 @@
const next = { evidenceType: slot.type, fileName: path.split('/').pop() || path, localPath: path, uploadStatus: 'PENDING' }
this.evidenceFiles = { ...this.evidenceFiles, [slot.type]: [...(this.evidenceFiles[slot.type] || []), next] }
this.rememberEvidenceDraft(identity)
- if (this.loadingStageReady) this.queueEvidenceUpload(slot.type, path)
- else this.showToast('照片已暂存,装柜状态同步后自动上传', 'success')
+ this.queueEvidenceUpload(slot.type, path)
}, fail: (error) => {
this.cameraPicking = false
if (!this.isEvidenceContextActive(contextId, identity)) return
@@ -1265,7 +1257,6 @@
} })
},
async queueEvidenceUpload(type, path) {
- if (!this.loadingStageReady) return
if (!this.evidenceIdentity?.inboundId || !isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return }
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
@@ -1285,6 +1276,11 @@
this.showToast('带锁码照已上传,请输入封签号并点击保存封签', 'success')
return
}
+ if (!this.loadingStageReady) {
+ this.rememberEvidenceDraft(identity)
+ this.showToast('照片已上传,拣货完成后自动保存', 'success')
+ return
+ }
await this.saveEvidence(type, null, path)
},
async flushPendingEvidence() {
@@ -1298,7 +1294,7 @@
}
},
async retryEvidence(type) {
- if (!this.requireEvidenceStage()) return
+ if (!this.requireEvidenceCapture()) return
const item = this.evidenceQueueEntry(type)
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
@@ -1316,6 +1312,11 @@
this.showToast('带锁码照已上传,请输入封签号并点击保存封签', 'success')
return
}
+ if (!this.loadingStageReady) {
+ this.rememberEvidenceDraft(identity)
+ this.showToast('照片已上传,拣货完成后自动保存', 'success')
+ return
+ }
await this.saveEvidence(type)
},
async saveEvidence(type, operationId = null, localPath = null) {
diff --git a/pages/profile/profile.vue b/pages/profile/profile.vue
index 4f28743..c0821ab 100644
--- a/pages/profile/profile.vue
+++ b/pages/profile/profile.vue
@@ -1,5 +1,5 @@
- {{ user.name.slice(0, 1) }}{{ user.name }}{{ user.role }} · {{ user.id }}作业仓库{{ user.warehouse }}当前班次08:00–17:00应用版本{{ appVersion }}退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。设置功能将在后续版本开放
+ {{ user.name.slice(0, 1) }}{{ user.name }}{{ user.role }} · {{ user.id }}作业仓库{{ user.warehouse }}当前班次08:00–17:00应用版本{{ appVersion }}退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。设置功能将在后续版本开放
diff --git a/pages/login/login.vue b/pages/login/login.vue
index ec3b29c..626277a 100644
--- a/pages/login/login.vue
+++ b/pages/login/login.vue
@@ -19,6 +19,7 @@
import { ensureAccessToken } from '@/api/http.js'
import { loadLoginPreference, saveLoginPreference, session, restoreSession, signInWithTokens } from '@/stores/session.js'
import { requireOnline } from '@/utils/network.js'
+ import { formatApiError } from '@/utils/user-facing.js'
export default {
data() { return { form: { userId: '', password: '' }, rememberAccount: true, error: '', submitting: false } },
async onLoad() {
@@ -55,7 +56,7 @@
saveLoginPreference(this.rememberAccount, userId)
uni.reLaunch({ url: '/pages/home/home' })
} catch (error) {
- this.error = error.network ? '当前网络不可用,请联网后重试' : (error.message || '登录失败')
+ this.error = error.network ? '当前网络不可用,请联网后重试' : formatApiError(error, '登录失败')
} finally {
this.submitting = false
}
diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue
index b09b3ad..00a7b77 100644
--- a/pages/picking/picking.vue
+++ b/pages/picking/picking.vue
@@ -14,7 +14,7 @@
@@ -25,7 +25,7 @@
:class="{ active: index === currentBoxIndex, completed: completedBoxes[index] }"
@tap="requestBoxSwitch(index)"
>
- {{ box.currentContainerNo || '集装箱号同步中' }}柜次号 {{ box.cabinetNo || '同步中' }} · {{ box.orderNo }}
+ 集装箱号 {{ displayBusinessValue(box.currentContainerNo) }}柜次号 {{ displayBusinessValue(box.cabinetNo) }} · {{ businessReference(box) }}
已完成
有草稿
@@ -78,7 +78,7 @@
:class="itemState(item)"
>
- {{ item.inboundCode || item.orderNo || '箱级拣货明细' }}
+ {{ itemBusinessReference(item) }}
已拣 ✓
@@ -190,7 +190,7 @@
@tap="selectBoxFromList(entry)"
>
- {{ entry.box.boxNo }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }}
+ 集装箱号 {{ displayBusinessValue(entry.box.currentContainerNo || entry.box.boxNo) }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }}
{{ boxListDetail(entry) }}
@@ -299,7 +299,7 @@
✓
- 集装箱号 {{ currentBox.currentContainerNo }} · 柜次号 {{ currentBox.cabinetNo }} · 订单 {{ currentBox.orderNo }}
+ 集装箱号 {{ displayBusinessValue(currentBox.currentContainerNo) }} · 柜次号 {{ displayBusinessValue(currentBox.cabinetNo) }} · {{ businessReference(currentBox) }}
装柜照片已拍 {{ evidenceCapturedTypeCount }}/{{ evidenceSlots.length }} 类
{{ sealNo ? '封签号已填写' : '封签号未填写' }}
@@ -347,6 +347,7 @@
import { stableOperationId } from '@/utils/idempotency.js'
import { stableTaskOperationIdentity } from '@/utils/task-identity.js'
import { COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion } from '@/utils/loading-completion-state.js'
+ import { businessReference, displayBusinessValue, formatApiError, sanitizeUserMessage } from '@/utils/user-facing.js'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import PopupHeader from '@/components/PopupHeader.vue'
@@ -396,7 +397,7 @@
},
computed: {
currentBox() {
- return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', items: [] }
+ return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', inboundCode: '', items: [] }
},
summary() {
return getPickSummary(this.currentBox.items, this.itemStates, this.partialQuantities)
@@ -519,7 +520,7 @@
// canonical server detail decides whether this task is complete.
this.completedBoxes = {}
this.boxes = [{
- boxNo: '同步中', cabinetNo: null, currentContainerNo: null, orderNo: '',
+ boxNo: '', cabinetNo: null, currentContainerNo: null, orderNo: '', inboundCode: '',
taskId: String(options.taskId), loadingPlanId: null, containerBookingId: null,
inboundId: null, inboundVersion: null,
warehouseId: options.warehouseId ? String(options.warehouseId) : null,
@@ -561,6 +562,13 @@
return false
},
methods: {
+ displayBusinessValue,
+ businessReference,
+ itemBusinessReference(item) {
+ if (String(item?.inboundCode ?? '').trim()) return `入仓码 ${item.inboundCode}`
+ if (String(item?.orderNo ?? '').trim()) return `订单号 ${item.orderNo}`
+ return '业务编号未关联'
+ },
isSelectableTask(task) {
return Boolean(task?.recordStatus === 'ACTIVE'
&& !task?.mergedIntoContainerBookingId
@@ -575,10 +583,11 @@
},
boxFromTask(task, existing = null) {
return {
- boxNo: String(task.currentContainerNo || existing?.boxNo || task.cabinetNo || '同步中'),
+ boxNo: String(task.currentContainerNo || existing?.boxNo || ''),
cabinetNo: task.cabinetNo ? String(task.cabinetNo) : (existing?.cabinetNo || null),
currentContainerNo: task.currentContainerNo ? String(task.currentContainerNo) : (existing?.currentContainerNo || null),
- orderNo: String(task.displayNo || task.orderNo || existing?.orderNo || ''),
+ orderNo: String(task.orderNo || existing?.orderNo || ''),
+ inboundCode: String(task.inboundCode || existing?.inboundCode || ''),
taskId: String(task.taskId),
loadingPlanId: task.loadingPlanId || existing?.loadingPlanId || null,
containerBookingId: task.containerBookingId || existing?.containerBookingId || null,
@@ -647,14 +656,7 @@
return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409
},
remoteFailureMessage(error, fallback = '操作失败') {
- const raw = String(error?.message || '').trim()
- const message = /^internal server error$/i.test(raw)
- ? '服务端处理异常,请联系管理员'
- : (raw || fallback)
- const code = String(error?.code || '').trim()
- const reference = String(error?.requestId || error?.traceId || '').trim()
- const meta = [code ? `错误码:${code}` : '', reference ? `请求编号:${reference}` : ''].filter(Boolean)
- return meta.length ? `${message}(${meta.join(';')})` : message
+ return formatApiError(error, fallback)
},
async refreshAfterTaskConflict(message) {
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
@@ -1118,7 +1120,8 @@
const firstItem = this.currentBox.items[0]
const evidenceItem = this.currentBox.items.find((item) => String(item.inboundId || '') === String(task.inboundId))
if (!evidenceItem) throw new Error('任务明细与入库单关联不一致,请返回刷新')
- this.currentBox.orderNo = firstItem.orderNo || firstItem.inboundCode || this.currentBox.orderNo
+ this.currentBox.orderNo = firstItem.orderNo || task.orderNo || this.currentBox.orderNo
+ this.currentBox.inboundCode = firstItem.inboundCode || task.inboundCode || this.currentBox.inboundCode
this.currentBox.inboundId = task.inboundId
this.currentBox.tenantId = evidenceItem.tenantId || null
const serverStates = createItemStates(this.currentBox.items)
@@ -1209,7 +1212,7 @@
await this.flushPendingEvidence()
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
- this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : (error.message || '装柜证据加载失败')
+ this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : this.remoteFailureMessage(error, '装柜证据加载失败')
}
},
async loadEvidencePreviews(contextId, identity) {
@@ -1348,10 +1351,10 @@
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
if (this.isTaskConflict(error)) {
- await this.refreshAfterTaskConflict(error.message || '任务内容已更新,请完成新增待拣项后重试')
+ await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '任务内容已更新,请完成新增待拣项后重试'))
return
}
- this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败')
+ this.remoteError = error.network ? '网络失败,证据未提交,请重试' : this.remoteFailureMessage(error, '证据保存失败')
this.showToast('证据保存失败', 'error')
}
},
@@ -1385,10 +1388,10 @@
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
if (this.isTaskConflict(error)) {
- await this.refreshAfterTaskConflict(error.message || '任务内容已更新,请刷新后重试')
+ await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '任务内容已更新,请刷新后重试'))
return
}
- this.remoteError = error.network ? '网络失败,照片未作废,请重试' : (error.message || '照片作废失败')
+ this.remoteError = error.network ? '网络失败,照片未作废,请重试' : this.remoteFailureMessage(error, '照片作废失败')
this.showToast('照片作废失败', 'error')
}
},
@@ -1445,7 +1448,7 @@
return
}
if (this.isTaskConflict(error)) {
- await this.refreshAfterTaskConflict(error.message || '订舱、装柜关联或证据状态已变化,请刷新后重试')
+ await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '订舱、装柜关联或证据状态已变化,请刷新后重试'))
return
}
this.debugSubmit('complete.error', { message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
@@ -1562,11 +1565,11 @@
const processed = draft
? Object.values(draft.itemStates).filter((status) => status !== ITEM_STATUS.PENDING).length
: 0
- return `${entry.box.orderNo} · 已处理${processed}件 · 待拣${entry.box.items.length - processed}件`
+ return `${businessReference(entry.box)} · 已处理${processed}件 · 待拣${entry.box.items.length - processed}件`
}
const picked = entry.box.items.filter((item) => item.finalState === ITEM_STATUS.PICKED).length
const partial = entry.box.items.filter((item) => item.finalState === ITEM_STATUS.PARTIAL).length
- return `${entry.box.orderNo} · 拣出${picked}件${partial ? ` · 部分拣${partial}件` : ''}`
+ return `${businessReference(entry.box)} · 拣出${picked}件${partial ? ` · 部分拣${partial}件` : ''}`
},
selectBoxFromList(entry) {
if (this.boxListType === 'done' || entry.index === this.currentBoxIndex) return
@@ -1651,7 +1654,7 @@
await this.refreshAfterTaskConflict(conflictMessage)
return
}
- this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败')
+ this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : this.remoteFailureMessage(error, '拣货完成失败')
this.showToast('拣货完成未提交,请查看错误提示', 'error')
return
} finally { this.submitting = false }
diff --git a/pages/tasks/tasks.vue b/pages/tasks/tasks.vue
index 103a037..6f392a3 100644
--- a/pages/tasks/tasks.vue
+++ b/pages/tasks/tasks.vue
@@ -14,14 +14,14 @@
暂无待处理的拣货任务
{{ notice }}
-