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] =?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\\\//)