diff --git a/AGENTS.md b/AGENTS.md index e67df4c..f464dab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ - 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。 - 明确使用的工具链和发布流程后,补充准确的依赖版本、安装、调试、测试和打包命令,并同步更新 `README.md` 与本文件。 - 在 `package.json` 与锁文件正式补齐前,`vite.config.js` 只证明目标配置,不证明依赖可安装;维护者只能运行仓库当前可执行的 `node --test tests/*.test.mjs`,不得从海外 PDA 或全局缓存猜测国内 PDA 的 uni-app/Vite 版本。 -- App API 根只读 `VITE_API_BASE_URL`;H5 固定优先使用 `VITE_H5_API_BASE_URL` 的同源 `/api` 并由 `vite.config.js` 将其代理到 `API_PROXY_TARGET`。环境地址不写入源码,切换构建环境后不得沿用登录缓存中的旧 API 地址。 +- App API 根只读 `VITE_API_BASE_URL`;H5 固定优先使用 `VITE_H5_API_BASE_URL=/gateway-api` 并由 `vite.config.js` 同源代理、重写为 Gateway `/api`,避免截获项目自身 `api/` 前端模块。环境地址不写入源码,切换构建环境后不得沿用登录缓存中的旧 API 地址。 - 国内 Android App 启动后仅在 `APP-PLUS` 调用 `utils/app-update.js`,通过独立 `VITE_PDA_UPDATE_URL` 检查更新;更新服务和下载地址必须为 HTTPS。APK 以整数 `versionCode` 判新,WGT 以 `versionName` 判新;强制更新不可跳过,WGT 安装后立即重启。国内 AppID、包名、签名、版本和更新通道不得复用海外 PDA。 ## 作业流程原则 @@ -49,13 +49,16 @@ | 目标 | 入口 | 继续查询 | |---|---|---| | 页面注册与启动 | `pages.json`、`main.js`、`App.vue` | 全局样式和应用生命周期 | -| API 环境与 H5 开发代理 | `config/runtime.js`、`.env.example`、`vite.config.js` | App `VITE_API_BASE_URL`;H5 同源 `VITE_H5_API_BASE_URL=/api`,代理上游 `API_PROXY_TARGET`;会话恢复不得覆盖当前构建地址 | +| API 环境与 H5 开发代理 | `config/runtime.js`、`.env.example`、`vite.config.js` | App `VITE_API_BASE_URL`;H5 同源 `VITE_H5_API_BASE_URL=/gateway-api`,代理上游 `API_PROXY_TARGET` 并重写为 `/api`;会话恢复不得覆盖当前构建地址 | +| 部分拣货布局与库位展示 | `pages/picking/picking.vue`、`styles/pda.scss` | 库位为空时不渲染空标签或残留分隔符;部分拣货数量必须小于需求且不超过服务端库存/库位可用量,合同 `tests/partial-layout.test.mjs`、`tests/picking-location-display.test.mjs` | | Android APK/WGT 在线更新 | `App.vue`、`utils/app-update.js`、`.env.example`、`manifest.json` | `VITE_PDA_UPDATE_URL` 使用国内独立 HTTPS 通道;合同 `tests/app-update.test.mjs`,H5 不执行更新逻辑 | | 登录与会话 | `pages/login/`、`stores/session.js` | 远程鉴权适配和会话清理 | | 工作台与模块入口 | `pages/home/`、`pages/module/`、`pages/operation/` | `data/demo-data.js` 与页面禁用规则 | | 待处理任务、服务端就绪与领取 | `pages/tasks/tasks.vue` | `api/pda-loading.js` 的 `listLoadingOrders`、`getContainerPickingTask`、`claimContainerPickingTask`;合并任务和缺少柜号/仓库/版本的任务不可进入 | +| 业务编号展示与错误脱敏 | `utils/user-facing.js`、`tests/user-facing-identifiers.test.mjs` | 任务和作业页优先显示真实集装箱号,缺失时显示柜次号,再缺失显示“集装箱号 —”;不得展示 taskId、inboundId、orderId、loadingPlanId、containerBookingId 或长整型内部 ID,错误仍保留 code、requestId、traceId | | 拣货任务与箱码扫码 | `pages/picking/`、`stores/picking.js` | `api/pda-loading.js`、`utils/scan-broadcast.js`、`components/ScanTargetInput.vue`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml` | | 装柜取证与完成 | `pages/picking/picking.vue` 的 `evidence` 阶段 | 正式边界是 `api/pda-loading.js` 的证据查询、预览、上传、作废和 `completeContainerPickingTask` 全部按当前任务与仓库进入;不得保留按入仓单/客户作用域的旧证据调用。上传队列 `utils/evidence-upload-queue.js`,合同 `tests/pda-loading-api.test.mjs`、`tests/picking-evidence-stage.test.mjs` | +| 装柜取证与完成 | `pages/picking/picking.vue` 的 `evidence` 阶段 | 正式边界是 `api/pda-loading.js` 的证据查询、预览、上传、作废和 `completeContainerPickingTask` 全部按当前任务与仓库进入;不得保留按入仓单/客户作用域的旧证据调用。上传队列 `utils/evidence-upload-queue.js`,合同 `tests/pda-loading-api.test.mjs`、`tests/picking-evidence-stage.test.mjs`、`tests/seal-save-idempotency.test.mjs` | | 扫描诊断 | `pages/scanner-test/` | `utils/scan-broadcast.js` 与广播 Action/Extra | | 作业记录与人员 | `pages/history/`、`pages/profile/` | session 与本地敏感数据清理 | | 页面与真实 API 合同 | `tests/page-contract.test.mjs`、`tests/pda-loading-api.test.mjs` | 覆盖部分拣货页面、提交门禁、库位不足反馈和国内 PDA 真实接口路径;统一由 `node --test tests/*.test.mjs` 执行 | diff --git a/README.md b/README.md index 832c676..188e728 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..3e34e93 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') { @@ -27,10 +29,17 @@ function authRequest(path, data) { }) } -export function login(username, password) { - return authRequest('/auth/login', { username, password }) +export function login(loginId, password) { + return authRequest('/auth/login', { loginId, 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..5f62af9 100644 --- a/api/pda-loading.js +++ b/api/pda-loading.js @@ -1,5 +1,9 @@ import { session } from '@/stores/session.js' import { requireOnline } from '@/utils/network.js' +import { apiDownload, apiRequest, apiUpload } from '@/api/http.js' +import { resolveEvidencePreviewUrl } from '@/utils/api-url.js' + +export { resolveEvidencePreviewUrl } from '@/utils/api-url.js' const EVIDENCE_TYPES = Object.freeze([ 'PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL' @@ -15,53 +19,14 @@ const EVIDENCE_SLOTS = Object.freeze([ { type: 'SEAL', label: '带锁码照' } ]) -// C02 supplies inboundId/inboundVersion and loading.pickingVersion. C04 supplies -// inbound-scoped task and evidence operations. No local task is promoted to a -// server success when any required identifier or version is absent. +// Picking and cabinet evidence are both scoped by the canonical PDA task. The +// task warehouse is always sent as an authorization boundary and never guessed. 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) - } - }) - }) -} - -function idempotencyKey(operation, inboundId) { - return `pda-${operation}-${inboundId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -} +const request = apiRequest function stableIdempotencyKey(operationId, suffix = '') { const raw = `pda-${operationId}${suffix ? `-${suffix}` : ''}` @@ -74,12 +39,6 @@ function stableIdempotencyKey(operationId, suffix = '') { return `${prefix}-${(hash >>> 0).toString(36)}-${raw.length}`.slice(0, 128) } -function tenantQuery(tenantId) { - const value = tenantId ?? session.tenantId - if (!value) throw new Error('缺少客户隔离标识 tenantId,无法请求现场任务') - return String(value) -} - export function isRemoteReady(inboundId) { return Boolean(session.token && (session.tenantId || inboundId)) } @@ -189,7 +148,10 @@ 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 ?? '业务单号待同步' + warehouseName: row.warehouseName ?? row.warehouse?.name ?? row.loadingPlan?.warehouseName ?? '', + orderNo: row.orderNo ?? row.order?.orderNo ?? '', + inboundCode: row.inboundCode ?? row.inbound?.inboundCode ?? '', + displayNo: row.orderNo ?? row.order?.orderNo ?? row.inboundCode ?? row.inbound?.inboundCode ?? '' } } @@ -205,6 +167,9 @@ export function normalizePickingTask(task = {}) { mergedIntoContainerBookingId: task.mergedIntoContainerBookingId ?? task.container?.mergedIntoContainerBookingId ?? null, cabinetNo: task.cabinetNo ?? task.loading?.cabinetNo ?? task.containerNo ?? '', currentContainerNo: task.currentContainerNo ?? task.containerNo ?? null, + orderNo: task.orderNo ?? task.order?.orderNo ?? '', + inboundCode: task.inboundCode ?? task.inbound?.inboundCode ?? '', + warehouseName: task.warehouseName ?? task.warehouse?.name ?? '', pickingVersion: task.pickingVersion ?? task.version ?? null, inboundVersion: task.inboundVersion ?? null } @@ -214,61 +179,26 @@ export function normalizeInventorySnapshot(snapshot = {}) { return normalizePickingTask({ ...snapshot, version: snapshot.taskVersion ?? snapshot.version }) } -export function getPickingTask(inboundId, tenantId) { +export function getEvidence(taskId, warehouseId) { + if (!taskId) throw new Error('缺少装柜任务标识,无法加载装柜证据') + if (!warehouseId) throw new Error('缺少任务仓库标识,无法加载装柜证据') return request({ - url: `/inbounds/${encodeURIComponent(inboundId)}/loading/picking?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, + url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=${encodeURIComponent(warehouseId)}`, method: 'GET' }) } -export function startPicking(inboundId, payload, tenantId) { - return request({ - url: `/inbounds/${encodeURIComponent(inboundId)}/loading/picking/start?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, - method: 'POST', - header: { 'Idempotency-Key': idempotencyKey('start-picking', inboundId) }, - data: payload - }) -} - -export function completePicking(inboundId, payload, tenantId) { - return request({ - url: `/inbounds/${encodeURIComponent(inboundId)}/loading/picking/complete?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, - method: 'POST', - header: { 'Idempotency-Key': idempotencyKey('complete-picking', inboundId) }, - data: payload - }) -} - -export function getEvidence(inboundId, tenantId) { - return request({ - url: `/inbounds/${encodeURIComponent(inboundId)}/loading/evidence/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, - method: 'GET' - }) -} - -export function getEvidencePreviewUrl(inboundId, evidenceId, tenantId) { - if (!inboundId || !evidenceId) return '' - return `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/loading/evidence/${encodeURIComponent(evidenceId)}/content/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}` +export function getEvidencePreviewUrl(taskId, evidenceId, warehouseId) { + if (!taskId || !evidenceId || !warehouseId) return '' + return `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/content?warehouseId=${encodeURIComponent(warehouseId)}` } export function downloadEvidencePreview(previewUrl) { requireOnline('当前网络不可用,无法查看装柜照片') if (!previewUrl) throw new Error('装柜照片地址尚未同步') - const baseUrl = session.apiBase || '/api/platform/v1' - let url = String(previewUrl) - if (!/^https?:\/\//i.test(url) && /^https?:\/\//i.test(baseUrl)) { - 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 + const url = resolveEvidencePreviewUrl(previewUrl, session.apiBase || '/api/platform/v1') 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 +209,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,80 +217,65 @@ 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 }) } -export function upsertEvidence(inboundId, payload, tenantId) { - if (!EVIDENCE_TYPES.includes(payload.evidenceType)) throw new Error('不支持的装柜证据类型') - return request({ - url: `/inbounds/${encodeURIComponent(inboundId)}/loading/evidence/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, - method: 'POST', - header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId || `evidence-${inboundId}-${payload.evidenceType}-${payload.fileId || 'seal'}`) }, - data: payload - }) -} - -export function voidEvidence(inboundId, evidenceId, payload = {}, tenantId) { - if (!inboundId || !evidenceId) throw new Error('缺少装柜证据标识,无法作废') +export function voidEvidence(taskId, evidenceId, warehouseId, payload = {}) { + if (!taskId || !evidenceId) throw new Error('缺少装柜任务或证据标识,无法作废') + if (!warehouseId) throw new Error('缺少任务仓库标识,无法作废装柜证据') if (!String(payload.reason || '').trim()) throw new Error('作废原因不能为空') return request({ - url: `/inbounds/${encodeURIComponent(inboundId)}/loading/evidence/${encodeURIComponent(evidenceId)}/void/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, + url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/void`, method: 'POST', - header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId || `void-evidence-${inboundId}-${evidenceId}-${payload.expectedVersion ?? 0}`) }, - data: payload + data: { warehouseId, expectedVersion: payload.expectedVersion ?? 0, reason: String(payload.reason).trim() } }) } /** Upload a local photo to the file service and return its durable fileId. */ -export function uploadEvidenceFile(inboundId, evidenceType, localPath, tenantId, onProgress) { +export function uploadEvidenceFile(taskId, warehouseId, payload, localPath, onProgress) { requireOnline('当前网络不可用,无法上传装柜照片') + if (!taskId) throw new Error('缺少装柜任务标识,无法上传装柜照片') + if (!warehouseId) throw new Error('缺少任务仓库标识,无法上传装柜照片') + const evidenceType = payload?.evidenceType if (!EVIDENCE_TYPES.includes(evidenceType)) throw new Error('不支持的装柜证据类型') if (!localPath) throw new Error('缺少待上传的照片') - const tenant = tenantQuery(tenantId) + if (payload.expectedVersion == null) throw new Error('缺少装柜证据版本,无法上传装柜照片') + if (evidenceType === 'SEAL' && !String(payload.sealNo || '').trim()) throw new Error('带锁码照必须填写封签号') 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({ - url: `${baseUrl}/files/loading-evidence/${encodeURIComponent(tenant)}/${encodeURIComponent(inboundId)}?evidenceType=${encodeURIComponent(evidenceType)}`, + debugLog('evidence.upload.start', { taskId, evidenceType, hasLocalPath: Boolean(localPath) }) + const query = [ + `warehouseId=${encodeURIComponent(warehouseId)}`, + `expectedVersion=${encodeURIComponent(payload.expectedVersion)}`, + `evidenceType=${encodeURIComponent(evidenceType)}` + ] + if (payload.sealNo) query.push(`sealNo=${encodeURIComponent(String(payload.sealNo).trim())}`) + if (payload.remark) query.push(`remark=${encodeURIComponent(payload.remark)}`) + return apiUpload({ + url: `${baseUrl}/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?${query.join('&')}`, filePath: localPath, name: 'file', - header: headers, - success(response) { + header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId || `evidence-${taskId}-${evidenceType}-${payload.expectedVersion}`) } + }, 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)) - }) -} - -export function completeLoading(inboundId, payload, tenantId) { - return request({ - url: `/inbounds/${encodeURIComponent(inboundId)}/loading/complete?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, - method: 'POST', - header: { 'Idempotency-Key': idempotencyKey('complete-loading', inboundId) }, - data: payload - }) + debugLog('evidence.upload.response', { taskId, 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 + 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 || body + }).catch((error) => { debugLog('evidence.upload.error', { taskId, evidenceType, message: error.message || error.errMsg || '' }); throw error }) } /** Complete the cabinet PDA task after the operator has captured all evidence. */ 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 + } + } } diff --git a/pages/history/history.vue b/pages/history/history.vue index 3c88bd2..a40977b 100644 --- a/pages/history/history.vue +++ b/pages/history/history.vue @@ -1,7 +1,7 @@ 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 adb5acf..626277a 100644 --- a/pages/login/login.vue +++ b/pages/login/login.vue @@ -7,6 +7,7 @@ + {{ error }} @@ -15,12 +16,33 @@ diff --git a/pages/tasks/tasks.vue b/pages/tasks/tasks.vue index d11e74d..37cf4de 100644 --- a/pages/tasks/tasks.vue +++ b/pages/tasks/tasks.vue @@ -5,24 +5,22 @@ 待处理任务 - {{ loading ? '正在同步' : `接口返回 ${rawTaskCount} 个,可处理 ${tasks.length} 个` }} - {{ apiEnvironment }} + {{ loading ? '正在同步任务' : `共 ${tasks.length} 个待处理任务` }} {{ error }} 正在加载真实任务... - 暂无可领取的拣货任务。请确认预装已补充集装箱号、已关联放舱订舱且预装已确认。 + 暂无待处理的拣货任务 {{ notice }} - @@ -38,27 +36,26 @@ import { session, requireSession } from '@/stores/session.js' import { clearPickingDraft } from '@/stores/picking.js' import { isOnline, watchNetwork } from '@/utils/network.js' + import { containerReference, displayBusinessValue, formatApiError } from '@/utils/user-facing.js' export default { components: { PortalHeader }, - data() { return { user: session.currentUser, tasks: [], rawTaskCount: 0, loading: false, error: '', notice: '', online: true, toast: { visible: false, message: '', type: '' } } }, - computed: { - apiEnvironment() { - const apiBase = String(session.apiBase || '') - const host = apiBase.match(/^https?:\/\/([^/]+)/i)?.[1] - return `当前接口:${host || apiBase || '未配置'} · 账号:${session.currentUser?.name || session.currentUser?.id || '未知'}` - } - }, + data() { return { user: session.currentUser, tasks: [], loading: false, error: '', notice: '', online: true, toast: { visible: false, message: '', type: '' } } }, onLoad() { if (!requireSession()) return; this._stopNetwork = watchNetwork((value) => { this.online = value }) }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() }, onUnload() { if (this._stopNetwork) this._stopNetwork(); clearTimeout(this._toastTimer) }, methods: { + displayBusinessValue, + containerReference, statusLabel(status) { + if (status === 'PROCESSING') return '完成处理中' + if (status === 'COMPLETION_FAILED') return '完成失败' if (status === 'AWAITING_REVIEW') return '待上传证据/确认装柜' return status === 'IN_PROGRESS' ? '拣货中' : '待领取' }, taskActionLabel(task) { if (!this.isTaskReady(task)) return '任务暂不可进入' + if (task.workflowStatus === 'COMPLETION_FAILED') return '重试完成' return task.workflowStatus === 'AWAITING_REVIEW' ? '继续装柜取证' : '进入并开始拣货' }, isMergedTask(task) { return task?.recordStatus === 'MERGED' || Boolean(task?.mergedIntoContainerBookingId) }, @@ -66,25 +63,24 @@ const operatorId = session.currentUser?.id || session.currentUser?.userId || session.currentUser?.accountId clearPickingDraft({ operatorId, taskId: task.taskId, loadingPlanId: task.loadingPlanId, containerBookingId: task.containerBookingId, warehouseId: task.warehouseId }) }, - isTaskReady(task) { return Boolean(task.recordStatus === 'ACTIVE' && !task.mergedIntoContainerBookingId && task.taskId && task.loadingPlanId && task.containerBookingId && task.cabinetNo && task.currentContainerNo && task.warehouseId && task.version !== null && task.version !== undefined && Number.isInteger(Number(task.version)) && (task.workflowStatus === 'AWAITING_REVIEW' || (task.assignmentVersion !== null && task.assignmentVersion !== undefined && Number.isInteger(Number(task.assignmentVersion)))) && ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW'].includes(task.workflowStatus)) }, + isTaskReady(task) { return Boolean(task.recordStatus === 'ACTIVE' && !task.mergedIntoContainerBookingId && task.taskId && task.loadingPlanId && task.containerBookingId && task.cabinetNo && task.currentContainerNo && task.warehouseId && task.version !== null && task.version !== undefined && Number.isInteger(Number(task.version)) && (['AWAITING_REVIEW', 'COMPLETION_FAILED'].includes(task.workflowStatus) || (task.assignmentVersion !== null && task.assignmentVersion !== undefined && Number.isInteger(Number(task.assignmentVersion)))) && ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETION_FAILED'].includes(task.workflowStatus)) }, + isTaskVisible(task) { return Boolean(task.recordStatus === 'ACTIVE' && !task.mergedIntoContainerBookingId && task.taskId && ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'PROCESSING', 'COMPLETION_FAILED'].includes(task.workflowStatus)) }, async loadTasks() { if (!isOnline()) { this.error = '当前网络不可用,请联网后刷新'; return } - this.loading = true; this.error = ''; this.notice = ''; this.rawTaskCount = 0 + this.loading = true; this.error = ''; this.notice = '' try { const result = await listLoadingOrders({ page: 1, size: 50 }) const records = result.records || [] - this.rawTaskCount = records.length records.filter((task) => this.isMergedTask(task)).forEach((task) => this.clearMergedTaskDraft(task)) - this.tasks = records.filter((task) => this.isTaskReady(task)) - try { console.info('[PDA] pending tasks', { apiBase: session.apiBase, operatorId: session.currentUser?.id, received: records.length, visible: this.tasks.length }) } catch (_) {} - if (records.length > this.tasks.length) this.notice = '部分任务尚未完成服务端就绪校验,本机已隐藏,请稍后刷新' + 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) { if (!this.isTaskReady(task)) { this.showToast('任务尚未满足后端进入条件', 'error'); return } - uni.navigateTo({ url: `/pages/picking/picking?cabinetNo=${encodeURIComponent(task.cabinetNo)}¤tContainerNo=${encodeURIComponent(task.currentContainerNo)}&warehouseId=${encodeURIComponent(task.warehouseId)}&taskId=${encodeURIComponent(task.taskId)}&orderNo=${encodeURIComponent(task.displayNo || '')}` }) + uni.navigateTo({ url: `/pages/picking/picking?taskId=${encodeURIComponent(task.taskId)}&warehouseId=${encodeURIComponent(task.warehouseId)}` }) }, showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2200) } } @@ -97,7 +93,6 @@ .task-heading { display: flex; min-width: 0; flex-direction: column; } .task-title { color: #12233f; font-size: 18px; font-weight: 700; line-height: 25px; } .task-summary { margin-top: 2px; color: #667085; font-size: 11px; line-height: 16px; } -.task-environment { margin-top: 1px; color: #98a2b3; font-size: 10px; line-height: 15px; } .task-refresh { flex-shrink: 0; margin: 0; min-width: 76px; height: 44px; border: 0; border-radius: 9px; background: #1a73e8; color: #fff; font-size: 14px; line-height: 44px; } .task-refresh[disabled] { opacity: .6; } .task-error, .task-state { padding: 20px 14px; border-radius: 10px; background: #fff; color: #667085; font-size: 13px; line-height: 20px; text-align: center; } @@ -110,17 +105,15 @@ .task-card[disabled] { opacity: .62; } .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; } -.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-status.processing { color: #155eef; background: #eff4ff; } +.task-status.failed { color: #b42318; background: #fff1f0; } .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/stores/picking.js b/stores/picking.js index 2373c30..af2fb25 100644 --- a/stores/picking.js +++ b/stores/picking.js @@ -19,6 +19,7 @@ function createInitialState() { let state = createInitialState() const STORAGE_PREFIX = 'pda-picking-draft' +const COMPLETION_PREFIX = 'pda-loading-completion' function storageAvailable() { return typeof uni !== 'undefined' && typeof uni.getStorageSync === 'function' && typeof uni.setStorageSync === 'function' @@ -80,6 +81,34 @@ export function clearPickingDraft(identity = {}) { if (key && typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(key) } +function completionKey(identity = {}) { + const identityKey = stableTaskIdentityKey(identity) + return identityKey ? `${COMPLETION_PREFIX}:${identityKey}` : '' +} + +export function saveLoadingCompletionAttempt(identity = {}, attempt = {}) { + const key = completionKey(identity) + if (!key || !storageAvailable() || !attempt.operationId) return + uni.setStorageSync(key, { + operationId: String(attempt.operationId), + expectedTaskVersion: Number(attempt.expectedTaskVersion), + remark: attempt.remark || '', + updatedAt: new Date().toISOString() + }) +} + +export function restoreLoadingCompletionAttempt(identity = {}) { + const key = completionKey(identity) + if (!key || !storageAvailable()) return null + const saved = uni.getStorageSync(key) + return saved?.operationId && Number.isInteger(Number(saved.expectedTaskVersion)) ? clone(saved) : null +} + +export function clearLoadingCompletionAttempt(identity = {}) { + const key = completionKey(identity) + if (key && typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(key) +} + export function resetPickingSession() { state = createInitialState() } diff --git a/stores/session.js b/stores/session.js index 6795060..d8e1b1e 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, { @@ -52,6 +77,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('登录状态已过期') @@ -67,16 +93,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 - Object.assign(session, saved, { apiBase: PLATFORM_API_ROOT }) + 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..7e0014e 100644 --- a/styles/pda.scss +++ b/styles/pda.scss @@ -24,8 +24,9 @@ letter-spacing: 0; } - button::after { - border: 0; + button::after, + uni-button::after { + border: 0 !important; } button:focus-visible, @@ -133,6 +134,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; @@ -212,8 +227,7 @@ .pick-item-row1, .pick-item-row2, .submit-info, - .box-list-item, - .partial-input-row { + .box-list-item { display: flex; align-items: center; } @@ -275,20 +289,30 @@ 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); } + .box-select-summary { + min-width: 0; + flex: 1; + line-height: 20px; + overflow-wrap: anywhere; + white-space: normal; + } + .box-chevron { + flex-shrink: 0; + margin-left: 8px; font-size: 12px; transition: transform 160ms ease-out; } @@ -334,23 +358,13 @@ line-height: 12px; } - .evidence-stage-hint { - margin-top: 10px; - padding: 8px 10px; - border-left: 3px solid #d39a25; - color: #795b16; - font-size: 11px; - line-height: 17px; - background: #fff8e7; - } - .box-chevron.open { transform: rotate(180deg); } .box-dropdown { position: absolute; - top: 53px; + top: calc(100% + 5px); left: 0; right: 0; overflow: hidden; @@ -375,6 +389,8 @@ border-bottom: 0; } + .box-option > view { min-width: 0; flex: 1; } + .box-option.active { color: $primary; font-weight: 600; @@ -383,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; } @@ -400,8 +416,6 @@ gap: 10px; } - .scan-hardware-tip { display: block; margin-top: 9px; color: #41668f; font-size: 11px; font-weight: 600; line-height: 17px; } - .last-scan { display: flex; align-items: center; @@ -426,31 +440,37 @@ .progress-section { margin: 0 12px 10px; - padding: 13px 14px; + padding: 10px 12px; border-radius: 12px; background: $card; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .progress-header { - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: 10px; + gap: 8px; } .progress-title { + flex-shrink: 0; font-size: 14px; font-weight: 700; } .progress-stats { color: $text-secondary; - font-size: 12px; + font-size: 11px; text-align: right; + white-space: nowrap; } - .progress-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 7px; } - .box-tasks-btn { height: 48px; padding: 0 12px; border: 1px solid #cdd9e7; border-radius: 8px; color: #3f5f81; font-size: 12px; line-height: 46px; background: #f7faff; } + .progress-actions { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: flex-end; gap: 8px; } + .box-tasks-btn { display: flex; height: 40px; flex-shrink: 0; align-items: center; gap: 3px; padding: 0 9px 0 11px; border: 0; border-radius: 999px; color: #245c9c; font-size: 11px; line-height: 40px; background: #edf5ff; } + .box-tasks-btn::after { border: 0; } + .box-tasks-btn:active { color: #0f4f9b; background: #dcecff; } + .box-tasks-count { color: $primary; font-weight: 700; } + .box-tasks-arrow { margin-left: 1px; color: $primary; font-size: 16px; line-height: 16px; } .progress-number { color: $success; @@ -459,8 +479,8 @@ .progress-bar-bg { overflow: hidden; - height: 7px; - margin-top: 10px; + height: 5px; + margin-top: 8px; border-radius: 999px; background: #e5e7eb; } @@ -516,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 { @@ -577,9 +599,10 @@ .pick-item-actions { display: grid; - grid-template-columns: 1.15fr 1fr 0.78fr; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; - margin-top: 12px; + width: 100%; + margin-top: 14px; } .processed-actions { @@ -587,18 +610,30 @@ } .pick-btn { + display: flex; + box-sizing: border-box; + width: 100%; height: 48px; - padding: 0 6px; - border-radius: 7px; + min-width: 0; + margin: 0; + padding: 0 8px; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 10px; font-size: 13px; - font-weight: 600; - line-height: 48px; + font-weight: 700; + line-height: 18px; + white-space: nowrap; + transition: transform 120ms ease-out, box-shadow 120ms ease-out, background-color 120ms ease-out; } - .pick-btn-confirm { color: #ffffff; background: $success; } - .pick-btn-partial { border: 1px solid #75a5ef; color: #4a7dff; background: #eef5ff; } - .pick-btn-skip { color: $skip; background: #eef0f3; } - .pick-btn-undo { border: 1px solid #d8dde4; color: $text-secondary; background: #ffffff; } + .pick-btn::after { border: 0; } + .pick-btn:active { transform: translateY(1px); box-shadow: none; } + .pick-btn-confirm { border-color: #0d8f84; color: #ffffff; background: $success; box-shadow: 0 3px 8px rgba(13, 148, 136, 0.22); } + .pick-btn-partial { border-color: #75a5ef; color: #2467d4; background: #eef5ff; box-shadow: 0 2px 6px rgba(74, 125, 255, 0.12); } + .pick-btn-skip { border-color: #d8dee7; color: #536174; background: #f5f7fa; } + .pick-btn-undo { border-color: #efb7b7; color: #b42323; background: #fff5f5; box-shadow: 0 2px 6px rgba(180, 35, 35, 0.08); } .empty-state { display: flex; @@ -738,22 +773,23 @@ .portal-content { padding: 12px 14px 24px; } - .home-menu { padding: 12px 12px 24px; } - .home-group + .home-group { margin-top: 18px; } - .home-group-title { display: flex; align-items: center; margin-bottom: 10px; color: #445166; font-size: 16px; line-height: 24px; } - .home-group-mark { width: 4px; height: 20px; flex-shrink: 0; margin-right: 10px; background: #1769e8; } + .home-menu { padding: 10px 10px 18px; } + .home-group + .home-group { margin-top: 11px; } + .home-group-title { display: flex; align-items: center; margin-bottom: 6px; color: #27364a; font-size: 14px; font-weight: 700; line-height: 20px; } + .home-group-mark { width: 3px; height: 16px; flex-shrink: 0; margin-right: 7px; border-radius: 2px; background: #1769e8; } .home-group-disabled .home-group-title { color: #7b8795; } .home-group-disabled .home-group-mark { background: #aeb8c4; } - .home-operation-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } - .home-operation { position: relative; display: flex; box-sizing: border-box; min-width: 0; height: 96px; padding: 10px 5px 9px; flex-direction: column; align-items: center; justify-content: center; border: 1px solid #dce3eb; border-radius: 4px; color: #334155; background: #ffffff; } - .home-operation:active { border-color: #9bbcf2; background: #edf4ff; } - .home-operation-primary { border-color: #8cb5f5; color: #135ee8; background: #edf4ff; } - .home-operation-disabled, .home-operation-disabled[disabled] { border-color: #e1e6ec; color: #8b97a5; opacity: 1; background: #f1f3f6; } - .home-operation-disabled:active { border-color: #e1e6ec; background: #f1f3f6; } - .home-operation-icon { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; font-size: 20px; font-weight: 700; } - .home-operation-name { display: block; width: 100%; margin-top: 3px; overflow: hidden; font-size: 14px; line-height: 20px; text-align: center; text-overflow: ellipsis; white-space: nowrap; } - .home-operation-pending { color: #52657c; font-size: 11px; line-height: 16px; } - .home-operation-unavailable { color: #929dab; font-size: 11px; line-height: 16px; } + .home-operation-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } + .home-operation { position: relative; display: flex; box-sizing: border-box; width: 100%; min-width: 0; min-height: 78px; margin: 0; padding: 6px 3px 5px; align-self: stretch; flex-direction: column; align-items: center; justify-content: center; border: 1px solid #e0e6ee; border-radius: 7px; color: #3b4b60; background: #ffffff; box-shadow: 0 1px 2px rgba(38, 54, 74, 0.05); } + .home-operation:active { border-color: #77a7f2; background: #edf4ff; transform: translateY(1px); } + .home-operation-primary { border-color: #6d9ff0; color: #0d58d3; background: #f3f7ff; box-shadow: 0 2px 6px rgba(19, 94, 232, 0.14); } + .home-operation-disabled, .home-operation-disabled[disabled] { border-color: #e3e8ef; color: #7f8b99; opacity: 1; background: #fafbfc; box-shadow: none; } + .home-operation-disabled:active { border-color: #e3e8ef; background: #fafbfc; transform: none; } + .home-operation-icon { display: flex; align-items: center; justify-content: center; width: 30px; height: 26px; flex: 0 0 26px; font-size: 20px; font-weight: 700; } + .home-operation-name { display: block; width: 100%; min-height: 30px; margin-top: 1px; font-size: 12px; font-weight: 600; line-height: 15px; text-align: center; overflow-wrap: anywhere; white-space: normal; } + .home-operation-meta { display: inline-flex; min-height: 14px; margin-top: 2px; padding: 0 5px; align-items: center; justify-content: center; border-radius: 7px; font-size: 9px; font-weight: 600; line-height: 14px; white-space: nowrap; } + .home-operation-pending { color: #0d58d3; background: #dfeaff; } + .home-operation-unavailable { color: #778494; background: #edf0f4; } .header-menu-sheet { box-sizing: border-box; width: 100vw; max-width: 480px; } .header-menu-content { padding-top: 1px; } @@ -859,8 +895,11 @@ .profile-row { display: flex; align-items: center; justify-content: space-between; min-height: 52px; border-bottom: 1px solid $border; font-size: 13px; } .profile-row:last-child { border-bottom: 0; } .profile-value { color: $text-secondary; font-size: 12px; } - .profile-action, .logout-action { box-sizing: border-box; width: 100%; height: 50px; margin-top: 14px; padding: 0 15px; border-radius: 11px; font-size: 13px; line-height: 50px; text-align: left; background: #ffffff; } - .profile-action { display: flex; justify-content: space-between; color: $text; } + .profile-row-action { box-sizing: border-box; width: 100%; height: 52px; margin: 0; padding: 0; border-radius: 0; color: $text; line-height: normal; text-align: left; background: transparent; } + .profile-row-action::after { border: 0; } + .profile-row-action:active { background: #f7f9fc; } + .profile-row-arrow { color: $text-secondary; font-size: 18px; } + .logout-action { box-sizing: border-box; width: 100%; height: 50px; margin-top: 14px; padding: 0 15px; border-radius: 11px; font-size: 13px; line-height: 50px; background: #ffffff; } .logout-action { color: $danger; text-align: center; } .logout-dialog-card { box-sizing: border-box; width: calc(100vw - 48px); max-width: 320px; overflow: hidden; border-radius: 12px; background: #ffffff; box-shadow: 0 18px 50px rgba(0, 0, 0, 0.24); } .logout-dialog-title { display: block; padding: 24px 22px 0; color: $danger; font-size: 17px; font-weight: 700; text-align: center; } @@ -915,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; @@ -1005,61 +1044,81 @@ .partial-info { display: flex; - flex-direction: column; - padding: 12px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; border-radius: 9px; background: $primary-light; } - .partial-info-sku { color: $primary; font-size: 15px; font-weight: 700; } - .partial-info-desc { margin-top: 4px; color: $text-secondary; font-size: 12px; } - .partial-info-qty { margin-top: 7px; font-size: 13px; } - .partial-info-qty text { font-size: 16px; font-weight: 700; } + .partial-info-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; } + .partial-info-sku { overflow: hidden; color: $primary; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } + .partial-info-desc { overflow: hidden; margin-top: 2px; color: $text-secondary; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } + .partial-info-qty { display: flex; flex-shrink: 0; align-items: baseline; gap: 3px; color: $text-secondary; font-size: 11px; } + .partial-info-qty-label { margin-right: 1px; } + .partial-info-qty-number { color: $text; font-size: 18px; font-weight: 700; } - .partial-input-row { - gap: 9px; - margin-top: 14px; - } + .partial-field { margin-top: 12px; } .partial-input-label { - flex-shrink: 0; - font-size: 13px; + display: block; + margin-bottom: 6px; + color: $text-secondary; + font-size: 12px; + font-weight: 600; } + .partial-input-control { display: flex; height: 50px; align-items: center; overflow: hidden; border: 2px solid $primary; border-radius: 9px; background: #ffffff; } .partial-input { box-sizing: border-box; flex: 1; min-width: 0; - height: 48px; - padding: 0 12px; - border: 2px solid $primary; - border-radius: 8px; + height: 46px; + padding: 0 8px 0 14px; + border: 0; font-size: 18px; font-weight: 700; - text-align: center; + text-align: left; } + .partial-input-placeholder { color: #98a2b3; font-size: 14px; font-weight: 400; } - .partial-input-unit { flex-shrink: 0; color: $text-secondary; font-size: 13px; } + .partial-input-unit { flex-shrink: 0; padding: 0 14px 0 8px; color: $text-secondary; font-size: 13px; } .partial-quick-row { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - margin-top: 12px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: stretch; + gap: 6px; + margin-top: 10px; } .partial-quick-btn { + box-sizing: border-box; + display: flex; + width: 100%; height: 48px; - padding: 0 6px; + margin: 0; + padding: 4px 2px; + align-items: center; + justify-content: center; + flex-direction: column; border: 1px solid #b9c9df; border-radius: 7px; color: #436080; - font-size: 12px; - line-height: 48px; + font-size: 11px; + line-height: 16px; background: #f4f8fc; } + .partial-quick-btn.selected { border-color: $primary; color: $primary; background: $primary-light; } + .partial-quick-ratio, .partial-quick-value { display: block; } + .partial-quick-ratio { font-weight: 700; } + .partial-quick-value { color: $text-secondary; font-size: 10px; } + .partial-quick-btn.selected .partial-quick-value { color: #356cae; } .partial-validation { display: block; margin-top: 10px; color: $danger; font-size: 12px; line-height: 18px; } + .partial-modal .modal-actions { margin-top: 12px; } + .partial-modal .modal-cancel-btn, .partial-modal .modal-confirm-btn { box-sizing: border-box; width: 100%; margin: 0; } .modal-confirm-btn[disabled] { color: #8a9099; background: #e4e6eb; opacity: 1; } .decision-desc { display: block; color: $text-secondary; font-size: 13px; line-height: 20px; text-align: center; } .submit-decision-modal { padding: 18px 16px 16px; } @@ -1077,8 +1136,6 @@ .incomplete-reason { box-sizing: border-box; min-height: 48px; padding: 10px 12px; border: 1px solid $border; border-radius: 8px; color: $text; font-size: 13px; line-height: 20px; text-align: left; background: #ffffff; } .incomplete-reason.selected { border-color: $danger; color: #a51d1d; background: $danger-light; } .danger-confirm { background: $danger; } - .all-complete-btn { width: 100%; height: 48px; margin-top: 20px; border-radius: 9px; color: #ffffff; font-size: 14px; font-weight: 700; line-height: 48px; background: $primary; } - .complete-modal { box-sizing: border-box; width: calc(100vw - 32px); @@ -1110,7 +1167,8 @@ } .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 { display: flex; @@ -1138,8 +1196,8 @@ .red { color: $danger; } .loading-evidence-section { - margin: 8px 12px 92px; - padding: 14px; + margin: 8px 12px 32px; + padding: 12px; border: 1px solid #d8e4f4; border-radius: 10px; background: $card; @@ -1152,28 +1210,30 @@ .sync-state.ready { color: $success; background: #e8f8ef; } .sync-state.error { color: $danger; background: #fff0f0; } .sync-state.loading { color: #8a5a00; background: #fff7df; } - .evidence-grid { display: flex; flex-direction: column; margin-top: 12px; } - .evidence-card { margin-bottom: 12px; padding: 10px; border: 1px solid #d7e1ed; border-radius: 9px; background: #f8fafc; } + .evidence-grid { display: flex; flex-direction: column; margin-top: 10px; } + .evidence-card { margin-bottom: 8px; padding: 7px 9px; border: 1px solid #d7e1ed; border-radius: 9px; background: #f8fafc; } .evidence-card:last-child { margin-bottom: 0; } .evidence-card.filled { border-color: #a7d6ba; background: #f4fbf6; } - .evidence-card-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; } - .evidence-card-title { display: flex; min-width: 0; align-items: center; gap: 7px; } + .evidence-card-header { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: 10px; } + .evidence-card-main { display: flex; min-width: 0; align-items: center; gap: 9px; } + .evidence-step { display: flex; width: 28px; height: 28px; flex-shrink: 0; align-items: center; justify-content: center; border-radius: 50%; color: #47617f; font-size: 11px; font-weight: 700; background: #e8eef6; } + .evidence-step.complete { color: #ffffff; background: $success; } + .evidence-card-title { display: flex; min-width: 0; flex-direction: column; gap: 1px; } .evidence-label { display: block; color: $text; font-size: 12px; font-weight: 700; } - .evidence-count { padding: 2px 6px; border-radius: 99px; color: $text-secondary; font-size: 10px; line-height: 16px; background: #e9eef5; } - .evidence-count.complete { color: #087443; background: #dff4e7; } + .evidence-count { color: $text-secondary; font-size: 10px; line-height: 15px; } + .evidence-count.complete { color: #087443; } .evidence-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 9px; } .evidence-photo-item { position: relative; min-width: 0; padding: 4px; border: 1px solid #dbe5ef; border-radius: 8px; background: #ffffff; } .evidence-thumbnail { display: flex; width: 100%; height: 68px; align-items: center; justify-content: center; border-radius: 6px; color: $text-secondary; font-size: 9px; line-height: 13px; text-align: center; background: #e7edf5; } .evidence-thumbnail-placeholder { box-sizing: border-box; padding: 6px; } .evidence-file { display: block; overflow: hidden; margin-top: 4px; padding: 0 2px; color: $text-secondary; font-size: 9px; line-height: 14px; text-overflow: ellipsis; white-space: nowrap; } .evidence-void-btn { position: absolute; top: 8px; right: 8px; min-width: 40px; height: 40px; padding: 0 7px; border: 0; border-radius: 20px; color: #ffffff; font-size: 10px; line-height: 40px; background: rgba(185, 28, 28, 0.9); } - .evidence-empty-hint { display: block; margin-top: 7px; color: $text-secondary; font-size: 10px; line-height: 15px; } .evidence-upload-state { display: block; margin-top: 5px; color: $text-secondary; font-size: 10px; line-height: 15px; } .evidence-upload-state.uploading, .evidence-upload-state.pending { color: #174d91; } .evidence-upload-state.failed { color: $danger; } .evidence-upload-state.succeeded { color: $success; } .evidence-btn { min-height: 40px; margin-top: 8px; padding: 0 9px; border: 1px solid #9db7d9; border-radius: 7px; color: #174d91; font-size: 12px; line-height: 40px; background: #ffffff; } - .evidence-add-btn { flex-shrink: 0; min-width: 78px; min-height: 40px; margin-top: 0; line-height: 40px; } + .evidence-add-btn { flex-shrink: 0; min-width: 76px; min-height: 42px; margin-top: 0; line-height: 42px; } .evidence-retry-btn { min-height: 40px; margin-top: 6px; padding: 0 9px; border: 1px solid $danger; border-radius: 7px; color: $danger; font-size: 12px; line-height: 40px; background: #fff5f4; } .seal-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; } .seal-input { flex: 1; height: 42px; box-sizing: border-box; padding: 0 10px; border: 1px solid $border; border-radius: 7px; font-size: 12px; background: #f8fafc; } @@ -1213,11 +1273,12 @@ @media (max-width: 340px) { .login-page { padding-right: 16px; padding-left: 16px; } + .home-menu { padding-right: 7px; padding-left: 7px; } + .home-operation-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; } + .home-operation { min-height: 76px; } .header-title { font-size: 18px; } .box-select-btn { padding: 0 10px; font-size: 12px; } .pick-item { padding-right: 10px; padding-left: 10px; } - .pick-item-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .pick-btn-confirm { grid-column: 1 / -1; } .processed-actions { grid-template-columns: 1fr; } .evidence-photo-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .submit-section { gap: 8px; width: calc(100% - 12px); padding-right: 8px; padding-left: 8px; } diff --git a/tests/api-url.test.mjs b/tests/api-url.test.mjs new file mode 100644 index 0000000..e54ae9b --- /dev/null +++ b/tests/api-url.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { resolveEvidencePreviewUrl } from '../utils/api-url.js' + +const previewPath = '/api/platform/v1/inbounds/100/loading/evidence/200/content/pda?tenantId=300' + +test('evidence preview uses the H5 gateway proxy prefix after reopening a task', () => { + assert.equal( + resolveEvidencePreviewUrl(previewPath, '/gateway-api/platform/v1'), + '/gateway-api/platform/v1/inbounds/100/loading/evidence/200/content/pda?tenantId=300' + ) +}) + +test('evidence preview uses the configured absolute gateway origin on a PDA', () => { + assert.equal( + resolveEvidencePreviewUrl(previewPath, 'https://test.example.com/api/platform/v1'), + 'https://test.example.com/api/platform/v1/inbounds/100/loading/evidence/200/content/pda?tenantId=300' + ) +}) + +test('absolute evidence preview URLs remain unchanged', () => { + const absoluteUrl = 'https://files.example.com/evidence/200.png' + assert.equal(resolveEvidencePreviewUrl(absoluteUrl, '/gateway-api/platform/v1'), absoluteUrl) +}) diff --git a/tests/app-update.test.mjs b/tests/app-update.test.mjs index 8cedb08..7b0d8e6 100644 --- a/tests/app-update.test.mjs +++ b/tests/app-update.test.mjs @@ -35,7 +35,8 @@ test('profile exposes manual App update checking and the runtime version', async const profile = await read('../pages/profile/profile.vue') assert.match(profile, /应用版本[\s\S]*?\{\{ appVersion \}\}/) - assert.match(profile, /@tap="checkUpdate">检查更新/) + assert.match(profile, /profile-list[\s\S]*?应用版本[\s\S]*?profile-row-action" @tap="checkUpdate"[\s\S]*?检查更新[\s\S]*?profile-row-action" @tap="showSettings"[\s\S]*?系统设置/) + assert.doesNotMatch(profile, /class="profile-action"/) assert.match(profile, /plus\.runtime\.version/) assert.match(profile, /checkForAppUpdate\(\{ manual: true \}\)/) assert.match(profile, /#ifndef APP-PLUS[\s\S]*?请在 PDA App 中检查更新/) diff --git a/tests/auth-session.test.mjs b/tests/auth-session.test.mjs new file mode 100644 index 0000000..e6a1108 --- /dev/null +++ b/tests/auth-session.test.mjs @@ -0,0 +1,60 @@ +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[\s\S]*?const accessToken = session\.token[\s\S]*?signOut\(\)[\s\S]*?uni\.reLaunch/) + assert.match(profile, /logoutRequest\(refreshToken, accessToken\)\.catch/) +}) + +test('production login starts empty and remembers only the account preference', async () => { + const [login, session] = await Promise.all([read('../pages/login/login.vue'), read('../stores/session.js')]) + assert.match(login, /form: \{ userId: '', password: '' \}/) + assert.doesNotMatch(login, /platform_admin|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/evidence-layout.test.mjs b/tests/evidence-layout.test.mjs new file mode 100644 index 0000000..65e8ba8 --- /dev/null +++ b/tests/evidence-layout.test.mjs @@ -0,0 +1,21 @@ +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('empty evidence slots stay compact and completed slots expand with photos', async () => { + const [page, styles] = await Promise.all([ + read('../pages/picking/picking.vue'), + read('../styles/pda.scss') + ]) + + assert.match(page, /每类至少 1 张,拍照后自动上传,可继续追加/) + assert.match(page, /v-for="\(slot, slotIndex\) in evidenceSlots"/) + assert.match(page, /class="evidence-step"[\s\S]*?slotIndex \+ 1/) + assert.match(page, /evidenceEntries\(slot\.type\)\.length \? '✓'/) + assert.doesNotMatch(page, /class="evidence-empty-hint"/) + assert.match(styles, /\.evidence-card-header \{[^}]*min-height: 44px/) + assert.match(styles, /\.evidence-add-btn \{[^}]*min-height: 42px/) + assert.match(styles, /\.evidence-photo-grid \{[^}]*grid-template-columns/) +}) diff --git a/tests/h5-button-reset.test.mjs b/tests/h5-button-reset.test.mjs new file mode 100644 index 0000000..0ad2cfe --- /dev/null +++ b/tests/h5-button-reset.test.mjs @@ -0,0 +1,10 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const styles = await readFile(new URL('../styles/pda.scss', import.meta.url), 'utf8') + +test('H5 uni-button removes the framework pseudo-element border globally', () => { + assert.match(styles, /button::after,\s*uni-button::after\s*\{[^}]*border:\s*0 !important;/) + assert.match(styles, /button:focus-visible,[\s\S]*?outline:\s*3px solid/) +}) diff --git a/tests/loading-completion-state.test.mjs b/tests/loading-completion-state.test.mjs new file mode 100644 index 0000000..096764e --- /dev/null +++ b/tests/loading-completion-state.test.mjs @@ -0,0 +1,61 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + COMPLETION_PHASE, + completionPresentation, + refreshCompletionAttempt, + resolveCompletionPhase, + shouldReconcileCompletion +} from '../utils/loading-completion-state.js' + +test('accepted completion is processing and blocks every repeated mutation', () => { + for (const facts of [ + { taskStatus: 'COMPLETING' }, + { commandStatus: 'PROCESSING' }, + { taskStatus: 'COMPLETING', commandStatus: 'PROCESSING' } + ]) { + const phase = resolveCompletionPhase(facts) + assert.equal(phase, COMPLETION_PHASE.PROCESSING) + assert.deepEqual(completionPresentation(phase), { label: '完成处理中', action: '', blocked: true }) + } +}) + +test('submission and uncertain-result reconciliation block duplicate completion immediately', () => { + assert.equal(resolveCompletionPhase({ submitting: true }), COMPLETION_PHASE.SUBMITTING) + assert.equal(resolveCompletionPhase({ reconciling: true }), COMPLETION_PHASE.RECONCILING) + assert.equal(completionPresentation(COMPLETION_PHASE.SUBMITTING).blocked, true) + assert.equal(completionPresentation(COMPLETION_PHASE.RECONCILING).blocked, true) +}) + +test('completion failure exposes a readable stage and retry entry', () => { + const phase = resolveCompletionPhase({ taskStatus: 'COMPLETION_FAILED' }) + assert.equal(phase, COMPLETION_PHASE.FAILED) + assert.deepEqual(completionPresentation(phase, '费用结算'), { + label: '完成失败:费用结算', action: '重试完成', blocked: true, retryable: true + }) +}) + +test('retry reuses the original operation id and request-hashed task version', () => { + const attempt = refreshCompletionAttempt( + { operationId: 'complete-task-8-v3', expectedTaskVersion: 3, commandId: 'cmd-old' }, + { taskVersion: 7, commandId: 'cmd-latest', commandStatus: 'COMPLETION_FAILED' } + ) + assert.equal(attempt.operationId, 'complete-task-8-v3') + assert.equal(attempt.expectedTaskVersion, 3) + assert.equal(attempt.commandId, 'cmd-latest') + assert.throws(() => refreshCompletionAttempt({}, { taskVersion: 7 }), /缺少原完成操作标识/) + assert.throws(() => refreshCompletionAttempt({ operationId: 'stable-id' }), /缺少服务端最新任务版本/) +}) + +test('network and timeout failures reconcile before any retry while business conflicts stay intact', () => { + assert.equal(shouldReconcileCompletion({ network: true }), true) + assert.equal(shouldReconcileCompletion({ timeout: true }), true) + assert.equal(shouldReconcileCompletion({ code: 'REQUEST_TIMEOUT' }), true) + assert.equal(shouldReconcileCompletion({ code: 'A0409', apiErrorMeta: { requestId: 'req-1' } }), false) +}) + +test('completed facts win over stale processing facts', () => { + const phase = resolveCompletionPhase({ taskStatus: 'COMPLETED', commandStatus: 'PROCESSING' }) + assert.equal(phase, COMPLETION_PHASE.COMPLETED) + assert.equal(completionPresentation(phase).label, '装柜已完成') +}) 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 631b66b..24d4714 100644 --- a/tests/page-contract.test.mjs +++ b/tests/page-contract.test.mjs @@ -29,9 +29,17 @@ test('login uses the real auth endpoint and relaunches to the modular workbench' for (const text of ['柏悦物流 · PDA作业端', '工号 / 用户名', '登录中']) assert.match(login, new RegExp(text)) assert.doesNotMatch(login, /DEMO_ACCOUNTS|快捷登录|作业仓库/) assert.match(auth, /\/auth\/login/) + assert.match(auth, /authRequest\('\/auth\/login', \{ loginId, password \}\)/) + assert.doesNotMatch(auth, /authRequest\('\/auth\/login', \{ username, password \}\)/) assert.match(login, /loginRequest\(userId, this\.form\.password\)/) assert.match(login, /signInWithTokens\(tokens\)/) + assert.match(login, /form: \{ userId: '', password: '' \}/) + assert.doesNotMatch(login, /platform_admin|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.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL \|\| '\/gateway-api'/) @@ -53,13 +61,14 @@ test('picking task list uses remote loading orders and never falls back to demo assert.match(tasks, /进入并开始拣货/) assert.match(tasks, /AWAITING_REVIEW[\s\S]*?待上传证据\/确认装柜/) assert.match(tasks, /task\.workflowStatus === 'AWAITING_REVIEW' \? '继续装柜取证'/) - assert.match(tasks, /\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW'\]\.includes\(task\.workflowStatus\)/) - assert.match(tasks, /task\.workflowStatus === 'AWAITING_REVIEW' \|\| \(task\.assignmentVersion !== null/) - assert.match(tasks, /暂无可领取的拣货任务/) - assert.match(tasks, /class="task-label">集装箱号<\/text>\{\{ task\.currentContainerNo \}\}/) - assert.match(tasks, /柜次号 \{\{ task\.cabinetNo \}\}/) + assert.match(tasks, /\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETION_FAILED'\]\.includes\(task\.workflowStatus\)/) + 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-code">\{\{ containerReference\(task\) \}\}/) + assert.doesNotMatch(tasks, /task-identifiers|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\.isTaskReady\(task\)\)/) + assert.match(tasks, /records\.filter\(\(task\) => this\.isTaskVisible\(task\)\)/) assert.match(tasks, /isTaskReady\(task\)[\s\S]*?task\.recordStatus === 'ACTIVE'[\s\S]*?!task\.mergedIntoContainerBookingId/) assert.match(tasks, /records\.filter\(\(task\) => this\.isMergedTask\(task\)\)\.forEach\(\(task\) => this\.clearMergedTaskDraft\(task\)\)/) assert.match(tasks, /clearMergedTaskDraft\(task\)[\s\S]*?clearPickingDraft\(\{ operatorId, taskId: task\.taskId, loadingPlanId: task\.loadingPlanId, containerBookingId: task\.containerBookingId/) @@ -74,11 +83,14 @@ test('workbench, module, history and profile are independent sources', async () const [home, module, history, profile] = await Promise.all([ read('../pages/home/home.vue'), read('../pages/module/module.vue'), read('../pages/history/history.vue'), read('../pages/profile/profile.vue') ]) - for (const text of ['首页', '库内作业', 'home-operation-grid', 'home-operation-primary', 'getPickingOverview', 'uni-icons']) assert.match(home, new RegExp(text)) + for (const text of ['首页', '库内作业', 'home-operation-grid', 'home-operation-primary', '开始作业', 'uni-icons']) assert.match(home, new RegExp(text)) + assert.doesNotMatch(home, /getPickingOverview|pendingBoxCount|箱待拣/) assert.doesNotMatch(home, /BottomNav|status-strip|task-card/) for (const text of ['业务模块', '业务功能', '第一版本仅开放']) assert.match(module, new RegExp(text)) assert.match(home, /:disabled="!operation\.available"/) assert.match(home, /home-operation-disabled/) + assert.match(home, /home-operation-meta home-operation-pending/) + assert.match(home, /home-operation-meta home-operation-unavailable/) assert.match(home, /openOperation\(operation\) \{ if \(operation\.available\) uni\.navigateTo/) assert.doesNotMatch(home, /pages\/operation\/operation\?module=/) assert.match(module, /:disabled="!operation\.available"/) @@ -116,7 +128,7 @@ test('non-picking operations remain complete static task pages', async () => { test('picking page keeps scan, draft and risky transition contracts', async () => { const [page, scanner] = await Promise.all([read('../pages/picking/picking.vue'), read('../utils/scan-broadcast.js')]) - for (const text of ['确认拣出', '部分拣货', '跳拣', '提交拣货完成', '下一箱', '最近扫码']) assert.match(page, new RegExp(text)) + for (const text of ['确认拣出', '部分拣货', '跳拣', '提交拣货完成', '去拍照', '最近扫码']) assert.match(page, new RegExp(text)) assert.match(page, /saveCurrentBoxDraft\(\)[\s\S]*?this\.boxDrafts =/) assert.match(page, /loadBoxState\(index\)[\s\S]*?const draft = this\.boxDrafts\[index\]/) assert.match(page, /requestBoxSwitch\(index\)[\s\S]*?this\.openOverlay\('switchBoxConfirm'\)/) @@ -134,9 +146,10 @@ test('picking page keeps scan, draft and risky transition contracts', async () = assert.match(page, /lastScanResult\.boxCode/) assert.match(page, /boxCode: entry\.boxCode \?\? entry\.sku \?\? ''/) assert.doesNotMatch(page, /lastScanResult\.sku|entry\.sku\s*}}/) - const processScanMethod = page.match(/processScannedValue\(scanValue, source = 'broadcast'\) \{[\s\S]*?\n\t\t\t\},\n\t\t\tpickItem/) - assert.ok(processScanMethod) - assert.doesNotMatch(processScanMethod[0], /this\.scanValue = ''/) + const processScanStart = page.indexOf("processScannedValue(scanValue, source = 'broadcast') {") + const processScanEnd = page.indexOf('\n\t\t\tevidenceEntries', processScanStart) + assert.ok(processScanStart >= 0 && processScanEnd > processScanStart) + assert.doesNotMatch(page.slice(processScanStart, processScanEnd), /this\.scanValue = ''/) assert.match(scanner, /android\.intent\.action\.SCANRESULT/) assert.match(scanner, /SCAN_BROADCAST_EXTRA = 'value'/) assert.match(scanner, /registerReceiver\(receiver, filter\)/) @@ -203,10 +216,10 @@ test('business dialogs use uni-ui popup components consistently', async () => { assert.match(profile, /]*:is-mask-click="true"[^>]*@change="handleOverlayChange\\('${name}', \\$event\\)"`)) } - assert.equal((picking.match(/ { assert.match(app, /@import '@\/styles\/pda\.scss'/) assert.doesNotMatch(styles, /<\/?style\b/) for (const className of ['pick-btn', 'partial-quick-btn']) assert.match(styles, new RegExp(`\\.${className}\\s*\\{[\\s\\S]*?height:\\s*48px;`)) + assert.match(styles, /\.pick-item-actions\s*\{[^}]*grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\);[^}]*width:\s*100%;/) + assert.match(styles, /\.pick-btn\s*\{[^}]*width:\s*100%;[^}]*margin:\s*0;[^}]*align-items:\s*center;[^}]*justify-content:\s*center;/) + assert.match(styles, /\.pick-btn::after\s*\{[^}]*border:\s*0;/) + assert.match(styles, /\.processed-actions\s*\{[^}]*grid-template-columns:\s*1fr;/) + assert.match(styles, /\.pick-btn-undo\s*\{[^}]*border-color:\s*#efb7b7;[^}]*color:\s*#b42323;[^}]*background:\s*#fff5f5;/) assert.doesNotMatch(styles, /\.scan-btn\s*\{/) assert.match(styles, /\.modal-cancel-btn,[\s\S]*?height:\s*48px;/) assert.match(styles, /\.modal-sheet\s*\{[\s\S]*?padding:\s*20px 18px 18px;/) assert.doesNotMatch(styles, /\.modal-sheet\s*\{[\s\S]*?padding:[^;]*env\(/) assert.match(styles, /\.submit-decision-modal\s*\{[^}]*padding:\s*18px 16px 16px;/) - assert.match(styles, /\.evidence-card\s*\{[^}]*margin-bottom:\s*12px;/) + assert.match(styles, /\.evidence-card\s*\{[^}]*margin-bottom:\s*8px;/) assert.match(styles, /\.evidence-card:last-child\s*\{[^}]*margin-bottom:\s*0;/) assert.match(tasks, /\.task-card\s*\{[^}]*margin:\s*0 0 12px;/) 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-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*10px;/) + assert.match(styles, /\.home-group-mark\s*\{[^}]*margin-right:\s*7px;/) + assert.match(styles, /\.home-operation-grid\s*\{[^}]*grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\);/) + assert.match(styles, /\.home-operation\s*\{[^}]*width:\s*100%;[^}]*min-height:\s*78px;[^}]*align-self:\s*stretch;/) + assert.match(styles, /\.home-operation-name\s*\{[^}]*min-height:\s*30px;[^}]*white-space:\s*normal;/) + assert.doesNotMatch(styles, /\.home-operation-name\s*\{[^}]*(text-overflow:\s*ellipsis|-webkit-line-clamp|white-space:\s*nowrap)/) + assert.match(styles, /\.home-operation-meta\s*\{[^}]*border-radius:\s*7px;[^}]*white-space:\s*nowrap;/) + assert.match(styles, /@media \(max-width:\s*340px\)\s*\{[\s\S]*?\.home-operation-grid\s*\{[^}]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\);/) assert.match(styles, /\.header-menu-item\s*\{[^}]*width:\s*calc\(100% - 32px\);[^}]*margin:\s*0 16px;/) assert.match(styles, /\.header-menu-close\s*\{[^}]*width:\s*calc\(100% - 32px\);/) assert.match(picking, /aria-modal="true"/) diff --git a/tests/partial-layout.test.mjs b/tests/partial-layout.test.mjs new file mode 100644 index 0000000..87672ff --- /dev/null +++ b/tests/partial-layout.test.mjs @@ -0,0 +1,25 @@ +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('partial picking uses a compact quantity panel with four equal quick choices', async () => { + const [page, styles] = await Promise.all([ + read('../pages/picking/picking.vue'), + read('../styles/pda.scss') + ]) + + assert.match(page, /partial-info-copy[\s\S]*?partial-info-qty-number/) + assert.match(page, /partial-input-label">实际拣出数量[\s\S]*?partial-input-control/) + assert.match(page, /placeholder="请输入数量"/) + assert.match(page, /placeholder-class="partial-input-placeholder"/) + assert.match(page, /partial-quick-btn"[\s\S]*?:class="\{ selected: String\(partialInput\) === String\(option\.value\) \}"/) + assert.match(page, /partial-quick-ratio[\s\S]*?partial-quick-value/) + assert.match(styles, /\.partial-quick-row \{[^}]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\)[^}]*align-items: stretch/) + assert.match(styles, /\.partial-quick-btn \{[^}]*width: 100%;[^}]*height: 48px;[^}]*margin: 0;/) + assert.match(styles, /\.partial-input-control \{[^}]*height: 50px;[^}]*border: 2px solid \$primary/) + assert.match(styles, /\.partial-input-placeholder \{[^}]*font-size: 14px;[^}]*font-weight: 400;/) + assert.match(styles, /\.partial-modal \.modal-actions \{[^}]*margin-top: 12px/) + assert.match(styles, /\.partial-modal \.modal-cancel-btn, \.partial-modal \.modal-confirm-btn \{[^}]*width: 100%;[^}]*margin: 0;/) +}) diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs index c6eeb79..f5d19c3 100644 --- a/tests/pda-loading-api.test.mjs +++ b/tests/pda-loading-api.test.mjs @@ -3,33 +3,32 @@ 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', () => { +test('PDA loading adapter uses only task-scoped cabinet evidence paths', () => { for (const path of [ - '/first-leg/pda-loading/tasks/pending', - '/loading/picking?tenantId=', - '/loading/picking/start?tenantId=', - '/loading/picking/complete?tenantId=', - '/loading/evidence/pda?tenantId=', - '/loading/complete?tenantId=' + '/first-leg/pda-loading/tasks/pending' ]) assert.match(source, new RegExp(path.replace(/[?]/g, '\\?'))) + assert.match(source, /first-leg\/pda-loading\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence/) 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, /\/files\/loading-evidence\//) - assert.match(source, /loading\/evidence\/pda\?tenantId=/) - assert.match(source, /loading\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/void\/pda\?tenantId=/) + assert.match(source, /apiUpload\(/) + assert.match(http, /uni\.uploadFile/) + assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\?\$\{query\.join\('&'\)\}/) + assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/void/) + assert.doesNotMatch(source, /inbounds\/\$\{encodeURIComponent\(inboundId\)\}\/loading\/evidence/) + assert.doesNotMatch(source, /loading\/evidence\/pda\?tenantId=/) assert.match(source, /export function completeContainerPickingTask\(taskId, payload = \{\}\)/) assert.match(source, /first-leg\/pda-loading\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/complete/) assert.match(source, /expectedTaskVersion/) }) 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(source, /throw new Error\('缺少客户隔离标识 tenantId/) + assert.match(http, /if \(code !== '200'\)/) + assert.match(http, /next\.network = true/) + assert.match(source, /throw new Error\('缺少装柜任务标识,无法上传装柜照片/) }) test('C02 identifiers and separate picking/inbound versions are normalized explicitly', () => { @@ -37,14 +36,14 @@ test('C02 identifiers and separate picking/inbound versions are normalized expli assert.match(source, /inboundVersion: row\.inboundVersion/) assert.match(source, /pickingVersion: row\.pickingVersion \?\? row\.loading\?\.pickingVersion/) assert.match(source, /pickingVersion: task\.pickingVersion \?\? task\.version/) - assert.match(page, /expectedVersion: Number\(this\.pickingVersion\)/) + assert.match(page, /version: Number\(this\.pickingVersion\)/) assert.match(page, /ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates/) assert.match(page, /taskItemId: item\.id \?\? item\.itemId/) assert.match(page, /submit\.batch/) assert.match(page, /item\.itemStatus === 'PICKED'/) assert.match(page, /item\.itemStatus === 'PARTIAL'/) assert.match(page, /!pickingCompleted && !summary\.canSubmit/) - assert.match(page, /this\.loadingVersion = this\.remotePick\.inboundVersion \?\? this\.remotePick\.loadingVersion/) + assert.doesNotMatch(page, /loadingVersion/) assert.match(page, /状态尚未同步,请刷新重试/) }) @@ -56,14 +55,14 @@ test('PDA normalizes the frozen active and merged container identity fields', () }) test('picking page exposes five evidence slots, retry and remote completion states', () => { - for (const text of ['PHOTO_STEP_1', 'PHOTO_STEP_4', 'SEAL', '网络失败', '确认装柜完成', 'loadingVersion']) { + for (const text of ['PHOTO_STEP_1', 'PHOTO_STEP_4', 'SEAL', '网络失败', '确认装柜完成']) { assert.match(`${source}\n${page}`, new RegExp(text)) } assert.match(page, /createEvidenceUploadQueue/) assert.match(page, /retryEvidence/) assert.match(page, /uploadEvidenceFile/) assert.match(page, /submitting \|\| \(!pickingCompleted && !summary\.canSubmit\)/) - assert.match(page, /completePicking\(/) + assert.doesNotMatch(page, /completePicking\(/) assert.match(page, /sourceType: \['camera'\]/) assert.doesNotMatch(page, /sourceType: \['camera', 'album'\]/) assert.match(page, /evidenceEntries\(slot\.type\)/) @@ -71,21 +70,36 @@ test('picking page exposes five evidence slots, retry and remote completion stat assert.match(page, /evidenceComplete/) }) -test('PDA evidence binding unwraps the nested loading action response before rendering saved files', () => { - assert.match(page, /evidenceItems\(result\)[\s\S]*?Array\.isArray\(result\?\.evidences\?\.evidences\)[\s\S]*?result\.evidences\.evidences/) - assert.match(page, /this\.evidenceFiles = this\.evidenceItems\(result\)[\s\S]*?\.reduce/) - assert.match(page, /cacheEvidencePreview\(current\.fileId, current\.localPath/) +test('PDA evidence binding normalizes string ids from the task-scoped DTO', () => { + assert.match(page, /evidenceItems\(result\)[\s\S]*?id: String\(item\.evidenceId \?\? item\.id/) + assert.match(page, /fileId: String\(item\.fileId/) + assert.match(page, /applyEvidenceResponse\(result, type, localPath, identity\)[\s\S]*?this\.evidenceFiles = remoteItems[\s\S]*?\.reduce/) + assert.match(page, /cacheEvidencePreview\(saved\.fileId, localPath/) }) -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=/) +test('PDA downloads authenticated task-scoped evidence previews', () => { + assert.match(source, /export function getEvidencePreviewUrl\(taskId, evidenceId, warehouseId\)/) + assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content\?warehouseId=/) 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\\\//) + assert.match(source, /resolveEvidencePreviewUrl/) + assert.match(source, /const url = resolveEvidencePreviewUrl\(previewUrl, session\.apiBase/) +}) + +test('cabinet evidence never selects an inbound owner or builds a container route in the PDA', () => { + const evidenceAdapter = source.match(/export function getEvidence\(taskId, warehouseId\)[\s\S]*?export function completeContainerPickingTask/)?.[0] || '' + for (const field of ['inboundId', 'tenantId', 'containerBookingId']) assert.doesNotMatch(evidenceAdapter, new RegExp(field)) + assert.match(page, /captureEvidenceIdentity\(\) \{[\s\S]*?taskId:/) + assert.match(page, /uploadEvidenceFile\(item\.taskId, item\.warehouseId, item, item\.localPath/) + assert.match(page, /queue\.add\(\{ taskId: identity\.taskId, warehouseId: identity\.warehouseId, evidenceType: type, expectedVersion/) + assert.doesNotMatch(page, /evidenceIdentity\?\.inboundId|identity\.inboundId/) + assert.doesNotMatch(page, /currentBox\.warehouseId \|\| session\.currentUser/) + assert.match(source, /warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/) }) test('PDA cabinet picking adapter claims online and submits one atomic batch', () => { @@ -120,7 +134,7 @@ test('PDA cabinet picking adapter claims online and submits one atomic batch', ( assert.match(page, /库存快照未同步,请刷新任务后重试/) assert.match(page, /拣货完成未提交,请查看错误提示/) assert.match(page, /if \(remoteTask\.workflowStatus === 'UNASSIGNED'\)[\s\S]*claimContainerPickingTask/) - assert.match(page, /this\.loadingVersion = this\.remotePick\.inboundVersion \?\? this\.remotePick\.loadingVersion/) + assert.doesNotMatch(page, /loadingVersion/) assert.match(page, /taskRequestId/) assert.match(page, /resetEvidenceState/) assert.match(page, /availablePickQuantity/) diff --git a/tests/picking-compact-layout.test.mjs b/tests/picking-compact-layout.test.mjs new file mode 100644 index 0000000..9c604de --- /dev/null +++ b/tests/picking-compact-layout.test.mjs @@ -0,0 +1,21 @@ +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('picking header omits persistent scanner help and keeps progress compact', async () => { + const [page, styles] = await Promise.all([ + read('../pages/picking/picking.vue'), + read('../styles/pda.scss') + ]) + + assert.doesNotMatch(page, /支持硬件广播扫码,也可点击输入后按“完成”确认/) + assert.doesNotMatch(styles, /\.scan-hardware-tip/) + assert.match(page, /已处理[\s\S]*?summary\.processed[\s\S]*?summary\.total[\s\S]*?>柜次<[\s\S]*?box-tasks-count[\s\S]*?completedCount[\s\S]*?boxes\.length[\s\S]*?box-tasks-arrow/) + assert.match(styles, /\.progress-section \{[^}]*padding: 10px 12px/) + assert.match(styles, /\.progress-actions \{[^}]*align-items: center;[^}]*gap: 8px/) + assert.match(styles, /\.box-tasks-btn \{[^}]*height: 40px;[^}]*border: 0;[^}]*border-radius: 999px/) + assert.match(styles, /\.box-tasks-count \{[^}]*font-weight: 700;/) + assert.match(styles, /\.progress-bar-bg \{[^}]*height: 5px;[^}]*margin-top: 8px/) +}) diff --git a/tests/picking-demo-readiness.test.mjs b/tests/picking-demo-readiness.test.mjs new file mode 100644 index 0000000..62d4c25 --- /dev/null +++ b/tests/picking-demo-readiness.test.mjs @@ -0,0 +1,75 @@ +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') +const [page, tasks, api, http, completion, scan] = await Promise.all([ + read('../pages/picking/picking.vue'), + read('../pages/tasks/tasks.vue'), + read('../api/pda-loading.js'), + read('../api/http.js'), + read('../utils/loading-completion-state.js'), + read('../utils/scan-broadcast.js') +]) + +test('demo claim supports the PDA broadcast and normalized manual input without duplicate picks', () => { + assert.match(page, /remoteTask\.workflowStatus === 'UNASSIGNED'[\s\S]*?claimContainerPickingTask\(this\.remoteTaskId, this\.assignmentVersion, operationId\)/) + assert.match(page, /this\.remotePick\.workflowStatus !== 'IN_PROGRESS'[\s\S]*?任务领取尚未完成/) + assert.match(scan, /android\.intent\.action\.SCANRESULT/) + assert.match(scan, /SCAN_BROADCAST_EXTRA = 'value'/) + assert.match(scan, /getStringExtra\(SCAN_BROADCAST_EXTRA\)/) + assert.match(page, /handleHardwareScan\(value\)[\s\S]*?processScannedValue\(value, 'broadcast'\)/) + assert.match(page, /handleManualSubmit\(value\)[\s\S]*?trim\(\)\.toUpperCase\(\)[\s\S]*?processScannedValue\(normalizedValue, 'manual'\)/) + assert.match(page, /result\.type === 'processed'[\s\S]*?重复扫描[\s\S]*?return/) +}) + +test('demo batch freezes inventory and submits PICK, SKIP and PARTIAL under one stable key', () => { + const submit = page.match(/async performSubmit\(incompleteReason = ''\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + for (const action of ['PICK', 'SKIP', 'PARTIAL']) assert.match(submit, new RegExp(`type: '${action}'`)) + assert.match(submit, /const submitId = this\.submitId \|\| stableOperationId\('submit'/) + assert.match(submit, /this\.submitId = submitId/) + assert.match(submit, /const inventorySnapshot = await this\.refreshInventorySnapshot\(\)[\s\S]*?submitContainerPickingBatch/) + assert.match(api, /'Idempotency-Key': stableIdempotencyKey\(submitOperationId\)/) + assert.match(api, /inventorySnapshotToken, items/) + assert.match(page, /value >= this\.partialTargetItem\.qty[\s\S]*?部分拣货必须少于/) + assert.match(page, /value > available[\s\S]*?超过库存\/库位可用量/) + assert.match(page, /available < Number\(item\.qty \|\| 0\)[\s\S]*?库存\/库位可用量不足/) +}) + +test('demo session and network failures never turn into local success', () => { + assert.match(http, /requireOnline\(\)/) + assert.match(http, /let refreshPromise = null/) + assert.match(http, /session\.token && session\.token !== accessToken \? session\.token : await refreshAccessToken\(\)/) + assert.match(http, /if \(code !== '200'\)[\s\S]*?error\.requestId[\s\S]*?error\.traceId[\s\S]*?throw error/) + const submit = page.match(/async performSubmit\(incompleteReason = ''\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + const failedReturn = submit.indexOf("this.showToast('拣货完成未提交,请查看错误提示', 'error')") + const localFinalize = submit.indexOf('const result = submitStates') + assert.ok(failedReturn >= 0 && localFinalize > failedReturn, 'backend failure must return before local completion') + assert.match(submit, /网络失败,拣货完成未提交,请重试/) +}) + +test('demo evidence requires all five server-backed types and keeps seal upload explicit', () => { + for (const type of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { + assert.match(api, new RegExp(`'${type}'`)) + } + assert.match(page, /this\.evidenceSlots\.every\(\(slot\) => this\.validEvidenceEntries\(slot\.type\)\.length > 0\)/) + assert.match(page, /请填写封签号,保存后上传/) + assert.match(page, /sealSaving \? '上传中…' : '保存并上传'/) + assert.match(page, /await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/) + assert.match(api, /if \(code !== '200'\)[\s\S]*?throw error/) +}) + +test('demo completion reuses its command, restores processing, and refreshes the list on return', () => { + assert.match(page, /restoreLoadingCompletionAttempt\(this\.draftIdentity\(\)\)/) + assert.match(page, /const operationId = savedAttempt\?\.operationId \|\| stableOperationId\('complete'/) + assert.match(page, /saveLoadingCompletionAttempt\(this\.draftIdentity\(\), completionAttempt\)[\s\S]*?completeContainerPickingTask/) + assert.match(completion, /PROCESSING_STATUSES = new Set\(\['COMPLETING', 'PROCESSING'\]\)/) + assert.match(completion, /statuses\.some\(\(status\) => PROCESSING_STATUSES\.has\(status\)\)/) + assert.match(page, /phase === COMPLETION_PHASE\.PROCESSING[\s\S]*?后台处理中[\s\S]*?navigateBackOr\('\/pages\/tasks\/tasks'\)/) + assert.match(page, /shouldReconcileCompletion\(error\)[\s\S]*?提交结果待确认[\s\S]*?navigateBackOr\('\/pages\/tasks\/tasks'\)/) + assert.match(tasks, /onShow\(\)[\s\S]*?this\.loadTasks\(\)/) + assert.match(tasks, /status === 'PROCESSING'[\s\S]*?完成处理中/) + assert.match(tasks, /isTaskReady\(task\)[\s\S]*?\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETION_FAILED'\]/) + assert.match(tasks, /isTaskVisible\(task\)[\s\S]*?\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'PROCESSING', 'COMPLETION_FAILED'\]/) + assert.doesNotMatch(tasks, /isTaskVisible\(task\)[\s\S]*?'COMPLETED'/) +}) diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs index 313a40a..77d5450 100644 --- a/tests/picking-evidence-stage.test.mjs +++ b/tests/picking-evidence-stage.test.mjs @@ -5,10 +5,12 @@ import { readFile } from 'node:fs/promises' const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8') test('picking and photo workspaces are independently selectable', () => { - assert.match(page, /v-if="activeStage === 'evidence' && loadingStageReady" class="loading-evidence-section"/) + assert.match(page, /v-if="activeStage === 'evidence'" class="loading-evidence-section"/) + assert.match(page, /selectStage\(stage\) \{[\s\S]*?this\.activeStage = stage/) + assert.doesNotMatch(page, /:disabled="!loadingStageReady"/) assert.match(page, /isPickingCompleteTask\(task\)[\s\S]*?AWAITING_REVIEW[\s\S]*?COMPLETED/) assert.match(page, /pickingCompleted\(\)[\s\S]*?this\.remoteTaskId \|\| this\.currentBox\.taskId[\s\S]*?if \(!this\.remotePick\) return false[\s\S]*?hasServerPending[\s\S]*?isPickingCompleteTask\(this\.remotePick\) && !hasServerPending/) - assert.match(page, /请先处理并提交全部待拣明细/) + assert.doesNotMatch(page, /任务增量更新后|evidence-stage-hint/) }) test('a server task never inherits a completed marker from the previously opened local cabinet', () => { @@ -19,14 +21,23 @@ test('a server task never inherits a completed marker from the previously opened assert.match(page, /submitStatusHint\(\)[\s\S]*?if \(this\.pickingCompleted\) return '拣货已完成,请继续装柜取证'/) const submit = page.match(/async submitPicking\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' assert.doesNotMatch(submit, /completedBoxes/) + assert.match(page, /v-else-if="pickingOperationReady" class="pick-item-actions processed-actions"/) + assert.doesNotMatch(page, /v-else-if="!(?:pickingCompleted|completedBoxes\[currentBoxIndex\])"/) }) -test('loading evidence is not requested before picking completion', () => { - assert.match(page, /if \(this\.loadingStageReady\) \{[\s\S]*?await this\.loadLoadingEvidence\(\)/) +test('picking completion leads to current cabinet evidence instead of an all-complete summary', () => { + assert.match(page, /装柜照片已拍 \{\{ evidenceCapturedTypeCount \}\}\/\{\{ evidenceSlots\.length \}\} 类/) + assert.match(page, /evidenceNextButtonText\(\)[\s\S]*?'查看装柜取证'[\s\S]*?'去拍照'/) + assert.match(page, /continueToEvidence\(\)[\s\S]*?this\.activeStage = 'evidence'[\s\S]*?this\.scrollToTop\(\)/) + assert.doesNotMatch(page, /查看全部完成|allCompletePopup|nextBox\(\)/) +}) + +test('loading evidence is requested by task id as soon as the task is claimed', () => { + assert.match(page, /this\.evidenceIdentity = this\.captureEvidenceIdentity\(\)[\s\S]*?await this\.loadLoadingEvidence\(\)/) const loader = page.match(/async loadLoadingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' - assert.match(loader, /if \(!this\.loadingStageReady/) + assert.match(loader, /if \(!this\.evidenceIdentity\?\.taskId/) + assert.doesNotMatch(loader, /if \(!this\.loadingStageReady/) assert.match(loader, /await getEvidence\(/) - assert.match(page, /this\.completedBoxes = \{[\s\S]*?this\.loadLoadingEvidence\(\)/) }) test('operators receive actionable refresh guidance without internal version wording', () => { @@ -39,19 +50,19 @@ test('operators receive actionable refresh guidance without internal version wor assert.doesNotMatch(complete, /completeLoading\(inboundId/) }) -test('AWAITING_REVIEW alone cannot request or mutate server evidence without a server loading version', () => { - assert.match(page, /loadingStageReady\(\)[\s\S]*?this\.pickingCompleted[\s\S]*?this\.loadingVersion !== null[\s\S]*?Number\.isFinite/) +test('AWAITING_REVIEW uses the canonical task version for final completion', () => { + assert.match(page, /loadingStageReady\(\)[\s\S]*?this\.pickingCompleted[\s\S]*?this\.evidenceIdentity\.taskId[\s\S]*?this\.evidenceIdentity\.warehouseId[\s\S]*?this\.pickingVersion !== null[\s\S]*?Number\.isFinite/) assert.match(page, /拣货已完成,装柜状态尚未同步,请刷新重试/) - assert.match(page, /if \(!this\.loadingStageReady \|\| !this\.evidenceIdentity/) - assert.doesNotMatch(page, /if \(this\.pickingCompleted\) await this\.loadLoadingEvidence\(\)/) + assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?requireEvidenceStage/) + assert.match(page, /requireEvidenceMutation\(\)[\s\S]*?requireEvidenceCapture/) }) -test('dynamic pending items withdraw evidence until the server task is picking-complete again', () => { +test('dynamic pending items do not block switching between picking and evidence', () => { assert.match(page, /class="stage-switch" role="tablist"/) assert.match(page, /@tap="selectStage\('picking'\)"/) assert.match(page, /@tap="selectStage\('evidence'\)"/) - assert.match(page, /selectStage\(stage\) \{[\s\S]*?stage === 'evidence' && !this\.loadingStageReady[\s\S]*?请先完成新增待拣明细[\s\S]*?return[\s\S]*?this\.activeStage = stage/) - assert.match(page, /class="stage-tab"[\s\S]*?:disabled="!loadingStageReady"[\s\S]*?装柜取证/) + assert.doesNotMatch(page, /stage === 'evidence' && !this\.loadingStageReady/) + assert.doesNotMatch(page, /请先完成新增待拣明细/) assert.match(page, /activeStage === 'picking'/) assert.match(page, /:disabled="submitting \|\| !loadingStageReady \|\| pickingVersion === null/) }) @@ -65,7 +76,8 @@ test('incremental refresh merges new pending items without overwriting submitted assert.match(page, /const previousTaskVersion = this\.pickingVersion/) assert.match(page, /const previousItemIds = new Set/) assert.match(page, /previousEvidenceState[\s\S]*?isSameEvidenceIdentity\(previousEvidenceState\.identity, this\.evidenceIdentity\)[\s\S]*?this\.evidenceFiles = \{ \.\.\.this\.evidenceFiles, \.\.\.previousEvidenceState\.files \}/) - assert.match(page, /if \(!this\.pickingCompleted\)[\s\S]*?delete completedBoxes\[this\.currentBoxIndex\][\s\S]*?this\.activeStage = 'picking'/) + assert.match(page, /if \(!this\.pickingCompleted\)[\s\S]*?delete completedBoxes\[this\.currentBoxIndex\]/) + assert.doesNotMatch(page, /delete completedBoxes\[this\.currentBoxIndex\][\s\S]{0,160}?this\.activeStage = 'picking'/) assert.match(page, /refresh\.incremental[\s\S]*?addedItemIds/) }) @@ -83,7 +95,7 @@ test('incremental batch submits only current server-pending items and refreshes test('completed detail overrides stale local state and an empty batch refreshes into evidence instead of submitting', () => { assert.match(page, /if \(item\.itemStatus === 'PICKED'\) serverStates\[item\.id\] = ITEM_STATUS\.PICKED/) assert.match(page, /if \(!this\.pickingCompleted\) this\.restoreRemoteDraft\(\)/) - assert.match(page, /if \(this\.loadingStageReady\) \{[\s\S]*?this\.activeStage = 'evidence'[\s\S]*?await this\.loadLoadingEvidence\(\)/) + assert.match(page, /await this\.loadLoadingEvidence\(\)[\s\S]*?if \(this\.loadingStageReady\) this\.activeStage = 'evidence'/) const submit = page.match(/async submitPicking\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' assert.match(submit, /if \(this\.pickingCompleted\)[\s\S]*?await this\.syncRemoteTask\(\)[\s\S]*?this\.activeStage = 'evidence'[\s\S]*?return/) const perform = page.match(/async performSubmit\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' @@ -91,37 +103,46 @@ test('completed detail overrides stale local state and an empty batch refreshes assert.ok(perform.indexOf('if (!actions.length)') < perform.indexOf('submitContainerPickingBatch(')) }) +test('successful batch submission automatically refreshes canonical item status before completion gates', () => { + const perform = page.match(/async performSubmit\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + const submitIndex = perform.indexOf('await submitContainerPickingBatch(') + const refreshIndex = perform.indexOf('await this.syncRemoteTask()', submitIndex) + const gateIndex = perform.indexOf('if (!this.pickingCompleted)', refreshIndex) + assert.ok(submitIndex >= 0 && refreshIndex > submitIndex, 'canonical detail should refresh after the accepted batch') + assert.ok(gateIndex > refreshIndex, 'completion should be evaluated only after the automatic refresh') + assert.match(perform, /拣货已提交,正在刷新最新状态/) + assert.match(perform, /拣货已提交,但最新任务状态尚未同步,请点击刷新任务/) + assert.match(perform, /if \(!this\.pickingCompleted\)[\s\S]*?return/) +}) + 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/) - const flush = page.match(/async flushPendingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' - assert.match(flush, /!entry\.id && entry\.localPath/) - assert.match(flush, /if \(item\.fileId && item\.type !== 'SEAL'\) await this\.saveEvidence\(item\.type, null, item\.path\)/) - assert.match(flush, /else if \(item\.fileId\) continue/) - assert.match(flush, /else await this\.queueEvidenceUpload\(item\.type, item\.path\)/) }) -test('seal photo upload waits for an explicit seal-number save', () => { - const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' - const retry = page.match(/async retryEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' - const save = page.match(/async saveEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' - assert.match(queue, /if \(type === 'SEAL'\)[\s\S]*?rememberEvidenceDraft\(identity\)[\s\S]*?return[\s\S]*?saveEvidence\(type, null, path\)/) - assert.match(retry, /if \(type === 'SEAL'\)[\s\S]*?rememberEvidenceDraft\(identity\)[\s\S]*?return[\s\S]*?saveEvidence\(type\)/) - assert.match(save, /type === 'SEAL' && !String\(this\.sealNo \|\| ''\)\.trim\(\)[\s\S]*?请输入封签号后点击保存封签/) - assert.match(page, /async saveSealNo\(\)[\s\S]*?await this\.saveEvidence\('SEAL'/) - assert.match(page, /hasUnboundUploadedEvidence\(slot\.type\)[\s\S]*?照片已上传,请输入封签号并保存/) +test('seal photo upload waits for an explicit seal-number save without waiting for picking completion', () => { + const chooser = page.match(/chooseEvidence\(slot\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(chooser, /slot\.type === 'SEAL'[\s\S]*?照片已拍摄,请输入封签号并保存上传[\s\S]*?return/) + assert.match(page, /async saveSealNo\(\)[\s\S]*?await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/) + const saver = page.match(/async saveSealNo\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.doesNotMatch(saver, /if \(!this\.loadingStageReady\)/) + assert.match(page, /sealReadyToSave\(\)[\s\S]*?hasPendingLocalEvidence\('SEAL'\)/) + assert.match(page, /slot\.type === 'SEAL' \? '请填写封签号,保存后上传' : '照片待自动上传'/) + assert.match(page, /带锁码照上传成功,封签号已保存/) }) -test('evidence identity is bound to task, inbound, loading plan and container booking', () => { - assert.match(page, /captureEvidenceIdentity\(\)[\s\S]*?taskId:[\s\S]*?inboundId:[\s\S]*?loadingPlanId:[\s\S]*?containerBookingId:/) - assert.match(page, /isSameEvidenceIdentity\(left, right\)[\s\S]*?taskId[\s\S]*?inboundId[\s\S]*?loadingPlanId[\s\S]*?containerBookingId/) - assert.match(page, /this\.currentBox\.inboundId = task\.inboundId/) +test('cabinet evidence identity is task-scoped and never selects an inbound owner', () => { + const capture = page.match(/captureEvidenceIdentity\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + const same = page.match(/isSameEvidenceIdentity\(left, right\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(capture, /taskId: this\.remoteTaskId \|\| this\.currentBox\.taskId/) + assert.match(capture, /warehouseId: this\.currentBox\.warehouseId/) + assert.doesNotMatch(capture, /inboundId|loadingPlanId|containerBookingId/) + assert.match(same, /left\?\.taskId[\s\S]*?right\?\.taskId/) + assert.doesNotMatch(same, /inboundId|loadingPlanId|containerBookingId/) assert.match(page, /String\(detailTaskId \|\| ''\) !== String\(requestedTaskId\)/) - assert.match(page, /!task\.inboundId \|\| !task\.loadingPlanId \|\| !task\.containerBookingId/) - assert.match(page, /evidenceItem[\s\S]*?String\(task\.inboundId\)/) + assert.match(page, /!task\.loadingPlanId \|\| !task\.containerBookingId/) + assert.doesNotMatch(page, /const evidenceItem|String\(task\.inboundId\)/) assert.match(page, /this\.evidenceIdentity = this\.captureEvidenceIdentity\(\)/) }) @@ -130,26 +151,32 @@ test('merged or non-active cabinet details are rejected before claim and clear s assert.match(sync, /if \(this\.isMergedContainerRecord\(task\)\)[\s\S]*?this\.clearMergedTask\(task, previousIdentity\)[\s\S]*?return[\s\S]*?task\.recordStatus !== 'ACTIVE'[\s\S]*?claimContainerPickingTask/) const clear = page.match(/clearMergedTask\(task, previousIdentity\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' assert.match(clear, /taskId: previousIdentity\?\.taskId \?\? task\?\.taskId/) - assert.match(clear, /inboundId: previousIdentity\?\.inboundId \?\? task\?\.inboundId/) assert.match(clear, /loadingPlanId: previousIdentity\?\.loadingPlanId \?\? task\?\.loadingPlanId/) assert.match(clear, /containerBookingId: previousIdentity\?\.containerBookingId \?\? task\?\.containerBookingId/) assert.match(clear, /clearPickingDraft\(staleIdentity\)/) assert.match(clear, /this\.evidenceDrafts = Object\.fromEntries/) assert.match(clear, /this\.evidencePreviewCache = Object\.fromEntries/) - for (const reset of ['remoteLoading = false', 'submitting = false', 'remoteTaskId = null', 'loadingVersion = null', 'scanHistory = []', 'currentBox.containerBookingId = null', 'currentBox.currentContainerNo = null', 'resetEvidenceState()']) assert.match(clear, new RegExp(reset.replace(/[.()[\]]/g, '\\$&'))) + for (const reset of ['remoteLoading = false', 'submitting = false', 'remoteTaskId = null', 'scanHistory = []', 'currentBox.containerBookingId = null', 'currentBox.currentContainerNo = null', 'resetEvidenceState()']) assert.match(clear, new RegExp(reset.replace(/[.()[\]]/g, '\\$&'))) }) -test('server evidence mutations remain closed before the loading stage is ready', () => { +test('photos upload, bind, reload and void before picking completion', () => { assert.match(page, /requireEvidenceStage\(\)[\s\S]*?if \(!this\.pickingCompleted\)[\s\S]*?请先提交拣货完成[\s\S]*?if \(!this\.loadingStageReady\)/) const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' - assert.match(queue, /if \(!this\.loadingStageReady\) return/) - for (const method of ['retryEvidence', 'saveEvidence', 'voidEvidencePhoto', 'completeLoadingTask']) { - const body = page.match(new RegExp(`(?:async )?${method}\\([^)]*\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] || '' - assert.match(body, /if \(!this\.requireEvidenceStage\(\)\)(?: return| \{)/, `${method} should guard the evidence stage`) - } + assert.match(queue, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, path, identity\)/) + assert.doesNotMatch(queue, /if \(!this\.loadingStageReady\)/) + assert.match(page, /chooseEvidence\(slot\)[\s\S]*?this\.queueEvidenceUpload\(slot\.type, path\)/) + const retry = page.match(/async retryEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(retry, /if \(!this\.requireEvidenceCapture\(\)\) return/) + assert.match(retry, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, item\.localPath, identity\)/) + assert.doesNotMatch(retry, /if \(!this\.loadingStageReady\)/) + assert.match(page, /async loadLoadingEvidence\(\) \{[\s\S]*?!this\.evidenceIdentity\?\.taskId[\s\S]*?!this\.evidenceIdentity\?\.warehouseId[\s\S]*?getEvidence\(identity\.taskId, identity\.warehouseId\)/) + assert.match(page, /await this\.loadLoadingEvidence\(\)[\s\S]*?if \(this\.loadingStageReady\) this\.activeStage = 'evidence'/) + assert.match(page, /async voidEvidencePhoto\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceMutation\(\)\) return/) + assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceStage\(\)\) return/) + assert.doesNotMatch(page, /照片已上传,拣货完成后自动保存/) }) -test('refresh preserves local photos for the same evidence identity and flushes them after loading readiness', () => { +test('refresh preserves local photos and flushes them as soon as evidence identity is ready', () => { assert.match(page, /this\.rememberEvidenceDraft\(\)/) assert.match(page, /this\.restoreEvidenceDraft\(\)/) assert.match(page, /async flushPendingEvidence\(\)[\s\S]*?queueEvidenceUpload/) @@ -158,7 +185,7 @@ test('refresh preserves local photos for the same evidence identity and flushes test('evidence photos render thumbnails and support full-screen preview', () => { assert.match(page, /class="evidence-thumbnail" :src="evidencePreviewSource\(photo\)"/) - assert.match(page, /class="work-content" :class="\{ 'evidence-active': activeStage === 'evidence' && loadingStageReady \}"/) + assert.match(page, /class="work-content" :class="\{ 'evidence-active': activeStage === 'evidence' \}"/) assert.match(page, /class="evidence-photo-grid"/) assert.match(page, /class="evidence-count"/) assert.match(page, /@tap="previewEvidencePhoto\(photo, slot\.type\)"/) @@ -166,7 +193,7 @@ test('evidence photos render thumbnails and support full-screen preview', () => assert.match(page, /previewEvidencePhoto\(photo, type\)[\s\S]*?uni\.previewImage\([\s\S]*?current[\s\S]*?urls/) assert.match(page, /cacheEvidencePreview\(fileId, localPath/) assert.match(page, /withCachedEvidencePreview\(photo[\s\S]*?evidencePreviewCache/) - assert.match(page, /item\.previewUrl \|\| getEvidencePreviewUrl\(identity\.inboundId, item\.id, this\.currentBox\.tenantId\)/) + assert.match(page, /item\.previewUrl \|\| getEvidencePreviewUrl\(identity\.taskId, item\.id, identity\.warehouseId\)/) assert.match(page, /['"]list\.loaded['"]/) assert.match(page, /loadEvidencePreviews\(contextId, identity\)[\s\S]*?downloadEvidencePreview\(item\.previewUrl\)/) assert.match(page, /['"]preview\.downloaded['"]/) @@ -177,7 +204,6 @@ test('switching cabinets invalidates old sync and evidence upload state', () => const selector = page.match(/selectBox\(index\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' assert.match(selector, /this\.taskRequestId \+= 1/) assert.match(selector, /this\.pickingVersion = null/) - assert.match(selector, /this\.loadingVersion = null/) assert.match(selector, /this\.submitting = false/) assert.match(selector, /this\.resetEvidenceState\(\)/) assert.match(page, /!this\.isEvidenceContextActive\(contextId, identity\) \|\| queue !== this\.evidenceQueue/) @@ -213,7 +239,6 @@ test('returning from an image preview does not refresh the picking task', () => test('evidence save, void and loading completion ignore stale cabinet responses', () => { for (const [method, awaitedCall] of [ - ['saveEvidence', 'upsertEvidence'], ['voidEvidencePhoto', 'voidEvidence'], ['completeLoadingTask', 'completeContainerPickingTask'] ]) { @@ -232,14 +257,22 @@ test('completed server tasks restore evidence stage without being claimed again' assert.match(page, /remoteTask\.workflowStatus !== 'AWAITING_REVIEW' && this\.assignmentVersion === null/) assert.match(page, /if \(remoteTask\.workflowStatus === 'UNASSIGNED'\) \{[\s\S]*?claimContainerPickingTask/) assert.match(page, /else if \(!isPickingCompleteTask\(remoteTask\)\)/) - assert.match(page, /if \(this\.loadingStageReady\) \{[\s\S]*?this\.activeStage = 'evidence'[\s\S]*?await this\.loadLoadingEvidence\(\)/) + assert.match(page, /await this\.loadLoadingEvidence\(\)[\s\S]*?if \(this\.loadingStageReady\) this\.activeStage = 'evidence'/) }) test('picking mutations stay closed until the canonical task has been claimed', () => { - assert.match(page, /pickingOperationReady\(\)[\s\S]*?workflowStatus === 'IN_PROGRESS'[\s\S]*?remoteTaskId[\s\S]*?inboundId[\s\S]*?loadingPlanId[\s\S]*?containerBookingId/) + assert.match(page, /pickingOperationReady\(\)[\s\S]*?workflowStatus === 'IN_PROGRESS'[\s\S]*?remoteTaskId[\s\S]*?loadingPlanId[\s\S]*?containerBookingId/) assert.match(page, /requirePickingOperation\(\)[\s\S]*?任务尚未领取完成/) for (const method of ['processScannedValue', 'pickItem', 'openSkip', 'openPartial', 'submitPicking']) { const body = page.match(new RegExp(`${method}\\([^)]*\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] || '' 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, /formatApiError\(error, fallback\)/) + assert.match(page, /import \{ containerReference, formatApiError, sanitizeUserMessage \}/) + assert.match(page, /requestIdFromServer: error\.requestId/) + assert.match(page, /this\.remoteFailureMessage\(error, '装柜完成失败'\)/) +}) diff --git a/tests/picking-location-display.test.mjs b/tests/picking-location-display.test.mjs new file mode 100644 index 0000000..d1cbade --- /dev/null +++ b/tests/picking-location-display.test.mjs @@ -0,0 +1,11 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8') + +test('missing picking location does not render an empty badge or dangling separator', () => { + assert.match(page, /\{\{ item\.location \}\}<\/text>/) + assert.match(page, /item\.location \? `\$\{item\.sku\} · \$\{item\.location\}` : item\.sku/) + assert.doesNotMatch(page, /\{\{ item\.location \}\}/) +}) diff --git a/tests/picking-refresh-route.test.mjs b/tests/picking-refresh-route.test.mjs new file mode 100644 index 0000000..84f7006 --- /dev/null +++ b/tests/picking-refresh-route.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('direct H5 refresh restores the page user before rendering picking content', async () => { + const page = await read('../pages/picking/picking.vue') + + assert.match(page, //) + assert.match(page, /onLoad\(options = \{\}\) \{[\s\S]*?if \(!requireSession\(\)\) return[\s\S]*?this\.currentUser = session\.currentUser/) + assert.match(page, /async onShow\(\) \{[\s\S]*?if \(!requireSession\(\)\) return[\s\S]*?this\.currentUser = session\.currentUser/) +}) + +test('picking route carries only stable task and warehouse identity', async () => { + const [tasks, page] = await Promise.all([ + read('../pages/tasks/tasks.vue'), + read('../pages/picking/picking.vue') + ]) + const navigation = tasks.match(/uni\.navigateTo\(\{ url: `\/pages\/picking\/picking\?[^`]+` \}\)/)?.[0] || '' + const onLoad = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\n\t\t\},\n\t\t\tasync onShow/)?.[0] || '' + + assert.match(navigation, /\?taskId=\$\{encodeURIComponent\(task\.taskId\)\}&warehouseId=\$\{encodeURIComponent\(task\.warehouseId\)\}/) + assert.doesNotMatch(navigation, /cabinetNo|currentContainerNo|orderNo|inboundId|tenantId/) + assert.match(onLoad, /taskId: String\(options\.taskId\)/) + assert.match(onLoad, /warehouseId: options\.warehouseId \? String\(options\.warehouseId\) : null/) + assert.doesNotMatch(onLoad, /options\.(cabinetNo|currentContainerNo|orderNo|inboundId|inboundVersion|tenantId)/) +}) + +test('target task detail keeps 19 digit identifiers as strings from route through request', async () => { + const [adapter, tasks, page] = await Promise.all([ + read('../api/pda-loading.js'), + read('../pages/tasks/tasks.vue'), + read('../pages/picking/picking.vue') + ]) + const taskId = '2092889048731414530' + const warehouseId = '193000000000200002' + assert.equal(`/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(warehouseId)}`, + '/first-leg/pda-loading/tasks/2092889048731414530?warehouseId=193000000000200002') + assert.match(adapter, /tasks\/\$\{encodeURIComponent\(taskId\)\}\?warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/) + assert.match(tasks, /taskId=\$\{encodeURIComponent\(task\.taskId\)\}&warehouseId=\$\{encodeURIComponent\(task\.warehouseId\)\}/) + assert.match(page, /taskId: String\(options\.taskId\)/) + assert.match(page, /warehouseId: options\.warehouseId \? String\(options\.warehouseId\) : null/) + assert.doesNotMatch(page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\n\t\t\},/)?.[0] || '', /Number\((?:options\.)?(?:taskId|warehouseId)/) +}) + +test('task detail restores before and after claim and exposes S0500 metadata', async () => { + const [http, page] = await Promise.all([read('../api/http.js'), read('../pages/picking/picking.vue')]) + const sync = page.match(/async syncRemoteTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(sync, /remoteTask\.workflowStatus === 'UNASSIGNED'[\s\S]*?claimContainerPickingTask/) + assert.match(sync, /remoteTask\.workflowStatus === 'IN_PROGRESS'[\s\S]*?assigneeId/) + assert.match(sync, /normalizePickingTask\(\{ \.\.\.task, \.\.\.\(claimed \|\| \{\}\) \}\)/) + 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, /formatApiError\(error, fallback\)/) + assert.match(page, /@\/utils\/user-facing\.js/) + assert.match(sync, /this\.remoteFailureMessage\(error, '任务同步失败'\)/) +}) diff --git a/tests/picking-store.test.mjs b/tests/picking-store.test.mjs index 2155633..2d08a26 100644 --- a/tests/picking-store.test.mjs +++ b/tests/picking-store.test.mjs @@ -29,3 +29,11 @@ test('mutable real container number is not part of draft identity or restore val assert.doesNotMatch(taskIdentitySource, /containerNo|currentContainerNo|boxNo/) assert.doesNotMatch(restoreGuard, /containerNo|currentContainerNo|boxNo/) }) + +test('completion retry persists the exact accepted operation id and request version', () => { + assert.match(source, /pda-loading-completion/) + assert.match(source, /saveLoadingCompletionAttempt/) + assert.match(source, /restoreLoadingCompletionAttempt/) + assert.match(source, /operationId: String\(attempt\.operationId\)/) + assert.match(source, /expectedTaskVersion: Number\(attempt\.expectedTaskVersion\)/) +}) diff --git a/tests/production-ui.test.mjs b/tests/production-ui.test.mjs new file mode 100644 index 0000000..b2f8919 --- /dev/null +++ b/tests/production-ui.test.mjs @@ -0,0 +1,25 @@ +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('picking task list exposes only operator-facing production information', async () => { + const [tasks, adapter] = await Promise.all([ + read('../pages/tasks/tasks.vue'), + read('../api/pda-loading.js') + ]) + + assert.match(tasks, /共 \$\{tasks\.length\} 个待处理任务/) + assert.match(tasks, /\{\{ containerReference\(task\) \}\}/) + assert.match(tasks, /displayBusinessValue\(task\.warehouseName, '未关联'\)/) + assert.doesNotMatch(tasks, /接口返回|当前接口|apiEnvironment|rawTaskCount|账号:|未命名订单|缺少仓库信息|服务端就绪校验|console\.info/) + assert.doesNotMatch(adapter, /业务单号待同步/) +}) + +test('production login contains no built-in account or password', async () => { + const login = await read('../pages/login/login.vue') + + assert.match(login, /form: \{ userId: '', password: '' \}/) + assert.doesNotMatch(login, /platform_admin|ChangeMe123!/) +}) diff --git a/tests/seal-save-idempotency.test.mjs b/tests/seal-save-idempotency.test.mjs new file mode 100644 index 0000000..1dba9ae --- /dev/null +++ b/tests/seal-save-idempotency.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8') + +test('saving a seal is single-flight and submits the pending photo through the atomic evidence command', () => { + assert.match(page, /:disabled="sealSaving \|\| !sealReadyToSave"/) + assert.match(page, /sealSaving \? '上传中…' : '保存并上传'/) + assert.match(page, /async saveSealNo\(\) \{[\s\S]*?if \(this\.sealSaving\) return/) + assert.match(page, /find\(\(item\) => item\.localPath && !item\.id\)/) + assert.match(page, /await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/) + assert.match(page, /stableOperationId\('evidence-upload',[\s\S]*?evidenceType: type,[\s\S]*?expectedVersion,[\s\S]*?localPath: path,[\s\S]*?sealNo: sealNo \|\| ''/) + assert.doesNotMatch(page, /seal-no-[^\n]*Date\.now\(\)/) + assert.match(page, /this\.sealSaving = true[\s\S]*?finally \{[\s\S]*?this\.sealSaving = false/) +}) + +test('server-returned evidence is deduplicated by type and file id', () => { + assert.match(page, /const seen = new Set\(\)/) + assert.match(page, /item\.evidenceType[\s\S]*?item\.fileId/) + assert.match(page, /if \(seen\.has\(key\)\) return false/) +}) diff --git a/tests/user-facing-identifiers.test.mjs b/tests/user-facing-identifiers.test.mjs new file mode 100644 index 0000000..f5281b3 --- /dev/null +++ b/tests/user-facing-identifiers.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { containerReference, displayBusinessValue, formatApiError, sanitizeUserMessage } from '../utils/user-facing.js' + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') + +test('PDA task labels prefer the container number and only fall back to the cabinet number', () => { + assert.equal(displayBusinessValue(null), '—') + assert.equal(displayBusinessValue('', '未关联'), '未关联') + assert.equal(containerReference({ currentContainerNo: 'TSTU0000760', cabinetNo: 'CAB-260830-0008', orderNo: 'FL-1' }), '集装箱号 TSTU0000760') + assert.equal(containerReference({ currentContainerNo: '', boxNo: 'TSTU0000708', cabinetNo: 'CAB-260829-0037' }), '集装箱号 TSTU0000708') + assert.equal(containerReference({ cabinetNo: 'CAB-260830-0008', taskId: '2092889048731414530' }), '柜次号 CAB-260830-0008') + assert.equal(containerReference({ boxNo: '同步中', cabinetNo: 'CAB-260830-0008' }), '柜次号 CAB-260830-0008') + assert.equal(containerReference({ taskId: '2092889048731414530', orderId: '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(/