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 @@ 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 }} - @@ -37,6 +37,7 @@ import { session, requireSession } from '@/stores/session.js' import { clearPickingDraft } from '@/stores/picking.js' import { isOnline, watchNetwork } from '@/utils/network.js' + import { businessReference, displayBusinessValue, formatApiError } from '@/utils/user-facing.js' export default { components: { PortalHeader }, @@ -45,6 +46,8 @@ onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() }, onUnload() { if (this._stopNetwork) this._stopNetwork(); clearTimeout(this._toastTimer) }, methods: { + displayBusinessValue, + businessReference, statusLabel(status) { if (status === 'PROCESSING') return '完成处理中' if (status === 'COMPLETION_FAILED') return '完成失败' @@ -73,7 +76,7 @@ this.tasks = records.filter((task) => this.isTaskVisible(task)) if (records.length > this.tasks.length) this.notice = '部分任务暂不可作业,请稍后刷新' } catch (error) { - this.error = error.network ? '当前网络不可用,请联网后刷新' : (error.message || '任务加载失败') + this.error = error.network ? '当前网络不可用,请联网后刷新' : formatApiError(error, '任务加载失败') } finally { this.loading = false } }, enterTask(task) { @@ -104,7 +107,7 @@ .task-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } .task-container-info { display: flex; min-width: 0; flex-direction: column; } .task-label { color: #667085; font-size: 10px; line-height: 15px; } -.task-code { overflow: hidden; margin-top: 1px; color: #12233f; font-size: 17px; font-weight: 700; line-height: 23px; text-overflow: ellipsis; white-space: nowrap; } +.task-code { margin-top: 1px; color: #12233f; font-size: 17px; font-weight: 700; line-height: 23px; overflow-wrap: anywhere; } .task-status { flex-shrink: 0; padding: 4px 7px; border-radius: 99px; color: #52616f; font-size: 10px; line-height: 16px; background: #eef2f6; } .task-status.progress { color: #155eef; background: #eff4ff; } .task-status.evidence { color: #9a5b13; background: #fff4e5; } @@ -112,10 +115,10 @@ .task-status.failed { color: #b42318; background: #fff1f0; } .task-identifiers { display: flex; flex-wrap: wrap; gap: 5px 8px; margin-top: 8px; } .task-cabinet, .task-order { padding: 3px 7px; border-radius: 5px; color: #475467; font-size: 11px; line-height: 17px; background: #f2f4f7; } -.task-order { overflow: hidden; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; } +.task-order { max-width: 100%; overflow-wrap: anywhere; white-space: normal; } .task-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; padding-top: 9px; border-top: 1px solid #edf1f5; } .task-meta-group { display: flex; min-width: 0; flex-direction: column; gap: 2px; } -.task-warehouse { overflow: hidden; color: #667085; font-size: 10px; line-height: 15px; text-overflow: ellipsis; white-space: nowrap; } +.task-warehouse { color: #667085; font-size: 10px; line-height: 15px; overflow-wrap: anywhere; } .task-pending { color: #344054; font-size: 12px; font-weight: 600; line-height: 17px; } .task-enter { display: flex; min-height: 40px; flex-shrink: 0; align-items: center; gap: 5px; padding-left: 10px; color: #155eef; font-size: 12px; font-weight: 600; } .task-enter-arrow { font-size: 22px; font-weight: 400; line-height: 22px; } diff --git a/styles/pda.scss b/styles/pda.scss index d496297..7e0014e 100644 --- a/styles/pda.scss +++ b/styles/pda.scss @@ -289,14 +289,14 @@ justify-content: space-between; box-sizing: border-box; width: 100%; - height: 48px; - padding: 0 14px; + min-height: 48px; + padding: 8px 14px; border: 2px solid $primary; border-radius: 12px; color: $primary; font-size: 14px; font-weight: 500; - line-height: 44px; + line-height: 20px; text-align: left; background: $card; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); @@ -304,11 +304,10 @@ .box-select-summary { min-width: 0; - overflow: hidden; flex: 1; - line-height: 44px; - text-overflow: ellipsis; - white-space: nowrap; + line-height: 20px; + overflow-wrap: anywhere; + white-space: normal; } .box-chevron { @@ -365,7 +364,7 @@ .box-dropdown { position: absolute; - top: 53px; + top: calc(100% + 5px); left: 0; right: 0; overflow: hidden; @@ -390,6 +389,8 @@ border-bottom: 0; } + .box-option > view { min-width: 0; flex: 1; } + .box-option.active { color: $primary; font-weight: 600; @@ -398,8 +399,8 @@ .box-option.completed { color: #596675; background: #f3f7f6; } .box-option-number, .box-option-order { display: block; } - .box-option-number { font-size: 13px; font-weight: 700; } - .box-option-order { margin-top: 3px; color: $text-secondary; font-size: 11px; } + .box-option-number { font-size: 13px; font-weight: 700; overflow-wrap: anywhere; } + .box-option-order { margin-top: 3px; color: $text-secondary; font-size: 11px; line-height: 17px; overflow-wrap: anywhere; } .box-option-state { padding: 5px 7px; border-radius: 5px; color: #08786c; font-size: 11px; background: $success-light; } .box-option-state.draft { color: #865b0b; background: $warning-light; } @@ -535,9 +536,11 @@ } .pick-item-sku { - flex-shrink: 0; + min-width: 0; + flex: 1; font-size: 15px; font-weight: 700; + overflow-wrap: anywhere; } .pick-item-meta { @@ -951,8 +954,8 @@ flex-direction: column; } - .box-list-number { font-size: 13px; font-weight: 600; } - .box-list-detail { margin-top: 4px; color: $text-secondary; font-size: 12px; line-height: 18px; } + .box-list-number { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; } + .box-list-detail { margin-top: 4px; color: $text-secondary; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; } .box-list-tag { flex-shrink: 0; @@ -1164,7 +1167,7 @@ } .complete-title { font-size: 20px; font-weight: 700; } - .complete-desc { margin-top: 6px; color: $text-secondary; font-size: 12px; } + .complete-desc { margin-top: 6px; color: $text-secondary; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; } .complete-evidence-status { display: flex; margin-top: 14px; padding: 10px 12px; align-items: center; justify-content: space-between; gap: 8px; border-radius: 9px; color: #285d9e; font-size: 12px; font-weight: 600; background: #edf4ff; } .complete-stats { diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs index 085d17d..5d2858d 100644 --- a/tests/page-contract.test.mjs +++ b/tests/page-contract.test.mjs @@ -63,8 +63,9 @@ test('picking task list uses remote loading orders and never falls back to demo assert.match(tasks, /\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'PROCESSING', 'COMPLETION_FAILED'\]\.includes\(task\.workflowStatus\)/) assert.match(tasks, /\['AWAITING_REVIEW', 'COMPLETION_FAILED'\]\.includes\(task\.workflowStatus\) \|\| \(task\.assignmentVersion !== null/) assert.match(tasks, /暂无待处理的拣货任务/) - assert.match(tasks, /class="task-label">集装箱号<\/text>\{\{ task\.currentContainerNo \}\}/) - assert.match(tasks, /柜次号 \{\{ task\.cabinetNo \}\}/) + assert.match(tasks, /class="task-label">集装箱号<\/text>\{\{ displayBusinessValue\(task\.currentContainerNo\) \}\}/) + assert.match(tasks, /柜次号 \{\{ displayBusinessValue\(task\.cabinetNo\) \}\}/) + assert.match(tasks, /\{\{ businessReference\(task\) \}\}/) assert.match(tasks, /button v-for="task in tasks"[\s\S]*?class="task-card"[\s\S]*?@tap="enterTask\(task\)"/) assert.match(tasks, /records\.filter\(\(task\) => this\.isTaskVisible\(task\)\)/) assert.match(tasks, /isTaskReady\(task\)[\s\S]*?task\.recordStatus === 'ACTIVE'[\s\S]*?!task\.mergedIntoContainerBookingId/) @@ -291,7 +292,9 @@ test('shared visual contracts preserve accessible PDA targets', async () => { assert.match(tasks, /\.task-card:last-child\s*\{[^}]*margin-bottom:\s*0;/) assert.match(styles, /\.work-content\s*\{[^}]*padding:\s*1px 0 104px;/) assert.match(picking, /class="box-select-summary"/) - assert.match(styles, /\.box-select-summary\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/) + assert.match(styles, /\.box-select-summary\s*\{[^}]*min-width:\s*0;[^}]*overflow-wrap:\s*anywhere;[^}]*white-space:\s*normal;/) + assert.doesNotMatch(styles, /\.box-select-summary\s*\{[^}]*(text-overflow:\s*ellipsis|white-space:\s*nowrap)/) + assert.match(styles, /\.box-dropdown\s*\{[^}]*top:\s*calc\(100% \+ 5px\);/) assert.match(styles, /\.submit-section\s*\{[\s\S]*?bottom:\s*8px;[\s\S]*?width:\s*calc\(100% - 20px\);/) assert.match(styles, /\.submit-btn\s*\{[\s\S]*?flex:\s*1;[\s\S]*?height:\s*56px;/) assert.match(styles, /\.home-group-mark\s*\{[^}]*margin-right:\s*7px;/) diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs index bdeabc8..46b75aa 100644 --- a/tests/picking-evidence-stage.test.mjs +++ b/tests/picking-evidence-stage.test.mjs @@ -105,7 +105,7 @@ test('completed detail overrides stale local state and an empty batch refreshes test('final completion preserves backend gate errors and refreshes after an atomic A0409', () => { const complete = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' - assert.match(complete, /this\.isTaskConflict\(error\)[\s\S]*?await this\.refreshAfterTaskConflict\(error\.message \|\| '订舱、装柜关联或证据状态已变化,请刷新后重试'\)/) + assert.match(complete, /this\.isTaskConflict\(error\)[\s\S]*?await this\.refreshAfterTaskConflict\(sanitizeUserMessage\(error\.message, '订舱、装柜关联或证据状态已变化,请刷新后重试'\)\)/) assert.doesNotMatch(complete, /拖车|trucking/i) const save = page.match(/async saveEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' assert.match(save, /this\.isTaskConflict\(error\)[\s\S]*?refreshAfterTaskConflict/) @@ -268,9 +268,8 @@ test('picking mutations stay closed until the canonical task has been claimed', 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, /formatApiError\(error, fallback\)/) + assert.match(page, /import \{ businessReference, displayBusinessValue, formatApiError, sanitizeUserMessage \}/) assert.match(page, /requestIdFromServer: error\.requestId/) assert.match(page, /this\.remoteFailureMessage\(error, '装柜完成失败'\)/) }) diff --git a/tests/picking-refresh-route.test.mjs b/tests/picking-refresh-route.test.mjs index 6e33be2..84f7006 100644 --- a/tests/picking-refresh-route.test.mjs +++ b/tests/picking-refresh-route.test.mjs @@ -53,7 +53,7 @@ test('task detail restores before and after claim and exposes S0500 metadata', a assert.match(http, /error\.code = body\.code \|\| response\.statusCode/) assert.match(http, /error\.requestId = body\.requestId/) assert.match(http, /error\.traceId = body\.traceId/) - assert.match(page, /const code = String\(error\?\.code \|\| ''\)\.trim\(\)/) - assert.match(page, /错误码:\$\{code\}/) + assert.match(page, /formatApiError\(error, fallback\)/) + assert.match(page, /@\/utils\/user-facing\.js/) assert.match(sync, /this\.remoteFailureMessage\(error, '任务同步失败'\)/) }) diff --git a/tests/production-ui.test.mjs b/tests/production-ui.test.mjs index 77f4b84..602fc97 100644 --- a/tests/production-ui.test.mjs +++ b/tests/production-ui.test.mjs @@ -11,8 +11,8 @@ test('picking task list exposes only operator-facing production information', as ]) assert.match(tasks, /共 \$\{tasks\.length\} 个待处理任务/) - assert.match(tasks, /v-if="task\.displayNo"/) - assert.match(tasks, /v-if="task\.warehouseName"/) + assert.match(tasks, /\{\{ businessReference\(task\) \}\}/) + assert.match(tasks, /displayBusinessValue\(task\.warehouseName, '未关联'\)/) assert.doesNotMatch(tasks, /接口返回|当前接口|apiEnvironment|rawTaskCount|账号:|未命名订单|缺少仓库信息|服务端就绪校验|console\.info/) assert.doesNotMatch(adapter, /业务单号待同步/) }) diff --git a/tests/user-facing-identifiers.test.mjs b/tests/user-facing-identifiers.test.mjs new file mode 100644 index 0000000..0e420f3 --- /dev/null +++ b/tests/user-facing-identifiers.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { businessReference, displayBusinessValue, formatApiError, sanitizeUserMessage } from '../utils/user-facing.js' + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') + +test('business identifiers never fall back to an internal id', () => { + assert.equal(displayBusinessValue(null), '—') + assert.equal(displayBusinessValue('', '未关联'), '未关联') + assert.equal(businessReference({ orderNo: 'SO-2026-001', inboundCode: 'RK-2026-002', taskId: '2092889048731414530' }), '订单号 SO-2026-001 · 入仓码 RK-2026-002') + assert.equal(businessReference({ taskId: '2092889048731414530', inboundId: '2092889048731414531' }), '业务编号未关联') +}) + +test('operator errors hide internal ids but retain request and trace evidence', () => { + const error = { + message: 'taskId: 2092889048731414530 对应 loadingPlanId=2092889048731414531 处理失败', + code: 'S0500', + requestId: 'dbde0d81-7798-4d22-a12e-1731d7ec9bb1', + traceId: '5b0ba4f3ed5236b34997dd82162a5138' + } + const message = formatApiError(error, '任务处理失败') + assert.doesNotMatch(message, /209288904873141453[01]|taskId|loadingPlanId/) + assert.match(message, /错误码:S0500/) + assert.match(message, /requestId:dbde0d81/) + assert.match(message, /traceId:5b0ba4f3/) + assert.equal(sanitizeUserMessage('operation 2092889048731414530 failed'), 'operation 内部标识已隐藏 failed') +}) + +test('priority PDA templates do not interpolate internal ids', async () => { + const pages = await Promise.all([ + read('../pages/login/login.vue'), + read('../pages/tasks/tasks.vue'), + read('../pages/picking/picking.vue'), + read('../pages/history/history.vue') + ]) + for (const page of pages) { + const template = page.match(/