Merge remote-tracking branch 'origin/release/test' into dev

This commit is contained in:
2026-09-02 10:34:13 +08:00
37 changed files with 1618 additions and 722 deletions
+5 -2
View File
@@ -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` 执行 |
+1 -1
View File
@@ -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`。当前草稿尚未持久化到设备,这是实现缺口,不代表允许离线作业。
+12 -3
View File
@@ -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
})
}
+149
View File
@@ -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 }
+63 -149
View File
@@ -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. */
+93 -93
View File
@@ -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
}
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
<template>
<view v-if="user" class="page-shell portal-page"><PortalHeader title="作业记录" :user="user" /><view class="portal-content"><view class="summary-card"><view><text class="summary-number">{{ overview.completedCount }}</text><text class="summary-label">今日完成</text></view><view><text class="summary-number pending-color">{{ overview.pendingBoxCount }}</text><text class="summary-label">待处理</text></view></view><text class="group-title">最近作业</text><view v-if="completedEntries.length === 0" class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">暂无作业记录</text><text class="history-empty-desc">完成的现场任务会显示在这里</text></view><view v-else class="operation-list"><view v-for="entry in completedEntries" :key="entry.box.boxNo" class="history-item"><view class="history-check">✓</view><view class="operation-info"><text class="operation-name">集装箱拣货</text><text class="operation-desc">{{ entry.box.boxNo }} · {{ entry.box.orderNo }}</text></view><text class="history-status">已完成</text></view></view></view></view>
<view v-if="user" class="page-shell portal-page"><PortalHeader title="作业记录" :user="user" /><view class="portal-content"><view class="summary-card"><view><text class="summary-number">{{ overview.completedCount }}</text><text class="summary-label">今日完成</text></view><view><text class="summary-number pending-color">{{ overview.pendingBoxCount }}</text><text class="summary-label">待处理</text></view></view><text class="group-title">最近作业</text><view v-if="completedEntries.length === 0" class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">暂无作业记录</text><text class="history-empty-desc">完成的现场任务会显示在这里</text></view><view v-else class="operation-list"><view v-for="entry in completedEntries" :key="entry.box.taskId || entry.index" class="history-item"><view class="history-check">✓</view><view class="operation-info"><text class="operation-name">集装箱拣货 · {{ containerReference(entry.box) }}</text></view><text class="history-status">已完成</text></view></view></view></view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'; import { session, requireSession } from '@/stores/session.js'; import { getPickingOverview } from '@/stores/picking.js'
export default { components: { PortalHeader }, data() { return { user: session.currentUser, overview: getPickingOverview() } }, computed: { completedEntries() { return this.overview.boxes.map((box, index) => ({ box, index })).filter((entry) => this.overview.completedBoxes[entry.index]) } }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() } }
import PortalHeader from '@/components/PortalHeader.vue'; import { session, requireSession } from '@/stores/session.js'; import { getPickingOverview } from '@/stores/picking.js'; import { containerReference } from '@/utils/user-facing.js'
export default { components: { PortalHeader }, data() { return { user: session.currentUser, overview: getPickingOverview() } }, computed: { completedEntries() { return this.overview.boxes.map((box, index) => ({ box, index })).filter((entry) => this.overview.completedBoxes[entry.index]) } }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() }, methods: { containerReference } }
</script>
+4 -5
View File
@@ -8,8 +8,8 @@
<button v-for="operation in group.operations" :key="operation.name" class="home-operation" :class="{ 'home-operation-primary': operation.available, 'home-operation-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)">
<view class="home-operation-icon" aria-hidden="true"><uni-icons :type="operation.iconType" :color="operation.available ? '#135ee8' : '#9aa6b2'" size="28" /></view>
<text class="home-operation-name">{{ operation.name }}</text>
<text v-if="operation.available" class="home-operation-pending">{{ overview.pendingBoxCount }} 箱待拣</text>
<text v-else class="home-operation-unavailable">未开放</text>
<text v-if="operation.available" class="home-operation-meta home-operation-pending">开始作业</text>
<text v-else class="home-operation-meta home-operation-unavailable">未开放</text>
</button>
</view>
</view>
@@ -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()
+27 -4
View File
@@ -7,6 +7,7 @@
<view class="login-card">
<view class="login-field"><text class="login-field-label">工号 / 用户名</text><input v-model="form.userId" class="login-field-input" aria-label="工号 / 用户名" placeholder="请输入工号" confirm-type="next" @focus="error = ''" /></view>
<view class="login-field"><text class="login-field-label">密码</text><input v-model="form.password" class="login-field-input" aria-label="密码" type="password" placeholder="请输入密码" confirm-type="done" @focus="error = ''" @confirm="login" /></view>
<view class="login-remember-row" @tap="toggleRememberAccount"><text class="login-remember-label">记住账号</text><switch :checked="rememberAccount" color="#1A73E8" aria-label="记住账号" @change="handleRememberChange" @tap.stop /></view>
<text v-if="error" class="login-error" role="alert">{{ error }}</text>
<button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? '登录中...' : '登录' }}</button>
</view>
@@ -15,12 +16,33 @@
<script>
import { login as loginRequest } from '@/api/auth.js'
import { session, restoreSession, signInWithTokens } from '@/stores/session.js'
import { ensureAccessToken } from '@/api/http.js'
import { loadLoginPreference, saveLoginPreference, session, restoreSession, signInWithTokens } from '@/stores/session.js'
import { requireOnline } from '@/utils/network.js'
import { formatApiError } from '@/utils/user-facing.js'
export default {
data() { return { form: { userId: 'platform_admin', password: 'ChangeMe123!' }, error: '', submitting: false } },
onLoad() { if (session.currentUser || restoreSession()) uni.reLaunch({ url: '/pages/home/home' }) },
data() { return { form: { userId: '', password: '' }, rememberAccount: true, error: '', submitting: false } },
async onLoad() {
const preference = loadLoginPreference()
this.rememberAccount = preference.rememberAccount
if (preference.account) this.form.userId = preference.account
if (!session.currentUser && !restoreSession()) return
try {
await ensureAccessToken()
uni.reLaunch({ url: '/pages/home/home' })
} catch (error) {
if (error.network) this.error = '当前网络不可用,暂时无法恢复登录状态'
}
},
methods: {
handleRememberChange(event) {
this.rememberAccount = Boolean(event.detail.value)
if (!this.rememberAccount) saveLoginPreference(false)
},
toggleRememberAccount() {
this.rememberAccount = !this.rememberAccount
if (!this.rememberAccount) saveLoginPreference(false)
},
async login() {
const userId = this.form.userId.trim()
if (!userId) return void (this.error = '请输入工号')
@@ -31,9 +53,10 @@
requireOnline('当前网络不可用,无法登录')
const tokens = await loginRequest(userId, this.form.password)
signInWithTokens(tokens)
saveLoginPreference(this.rememberAccount, userId)
uni.reLaunch({ url: '/pages/home/home' })
} catch (error) {
this.error = error.network ? '当前网络不可用,请联网后重试' : (error.message || '登录失败')
this.error = error.network ? '当前网络不可用,请联网后重试' : formatApiError(error, '登录失败')
} finally {
this.submitting = false
}
+224 -229
View File
@@ -11,10 +11,10 @@
</view>
</view>
<view class="work-content" :class="{ 'evidence-active': activeStage === 'evidence' && loadingStageReady }">
<view class="work-content" :class="{ 'evidence-active': activeStage === 'evidence' }">
<view class="box-select-wrap">
<button class="box-select-btn" @tap="toggleBoxDropdown">
<text>集装箱号 {{ currentBox.currentContainerNo || '同步中' }} · 柜次号 {{ currentBox.cabinetNo || '同步中' }} · 订单 {{ currentBox.orderNo }}</text>
<text class="box-select-summary">{{ containerReference(currentBox) }}</text>
<text class="box-chevron" :class="{ open: boxDropdownOpen }">▼</text>
</button>
<view v-if="boxDropdownOpen" class="box-dropdown">
@@ -25,7 +25,7 @@
:class="{ active: index === currentBoxIndex, completed: completedBoxes[index] }"
@tap="requestBoxSwitch(index)"
>
<view><text class="box-option-number">{{ box.currentContainerNo || '集装箱号同步中' }}</text><text class="box-option-order">柜次号 {{ box.cabinetNo || '同步中' }} · {{ box.orderNo }}</text></view>
<view><text class="box-option-number">{{ containerReference(box) }}</text></view>
<text v-if="completedBoxes[index]" class="box-option-state">已完成</text>
<text v-else-if="hasBoxDraft(index)" class="box-option-state draft">有草稿</text>
</view>
@@ -33,16 +33,13 @@
</view>
<view class="stage-switch" role="tablist" aria-label="作业阶段">
<button class="stage-tab" :class="{ active: activeStage === 'picking' }" role="tab" :aria-selected="activeStage === 'picking'" @tap="selectStage('picking')">拣货作业</button>
<button class="stage-tab" :class="{ active: activeStage === 'evidence' }" :disabled="!loadingStageReady" role="tab" :aria-selected="activeStage === 'evidence'" @tap="selectStage('evidence')">装柜取证</button>
<button class="stage-tab" :class="{ active: activeStage === 'evidence' }" role="tab" :aria-selected="activeStage === 'evidence'" @tap="selectStage('evidence')">装柜取证</button>
</view>
<view v-if="!loadingStageReady" class="evidence-stage-hint" role="status">请先处理并提交全部待拣明细;任务增量更新后,已拍证据会按当前柜次保留。</view>
<view v-if="activeStage === 'picking'" class="picking-stage">
<view class="scan-section">
<view class="scan-row">
<ScanTargetInput v-model="scanValue" name="boxCode" label="箱码扫码框" placeholder="扫描或输入箱唛标签码" :active="activeScanField === 'boxCode'" @activate="setScanTarget" @submit="handleManualSubmit" />
</view>
<text class="scan-hardware-tip">支持硬件广播扫码,也可点击输入后按“完成”确认</text>
<view v-if="lastScanResult" class="last-scan" :class="lastScanResult.type" role="status">
<view class="last-scan-icon">{{ lastScanResult.type === 'success' ? '✓' : '!' }}</view>
<view class="last-scan-content">
@@ -60,18 +57,8 @@
<view class="progress-header">
<text class="progress-title">拣货进度</text>
<view class="progress-actions">
<view class="progress-stats">
<template v-if="summary.partial > 0">
<text>已拣 </text><text class="progress-number">{{ summary.picked }}</text>
<text> · 部分拣 </text><text class="progress-number">{{ summary.partial }}</text>
<text> / {{ summary.total }} 箱</text>
</template>
<template v-else>
<text>已拣 </text><text class="progress-number">{{ summary.picked }}</text>
<text> / {{ summary.total }} 箱</text>
</template>
</view>
<button class="box-tasks-btn" @tap="openBoxList('pending')">柜次任务 {{ completedCount }}/{{ boxes.length }}</button>
<view class="progress-stats"><text>已处理 </text><text class="progress-number">{{ summary.processed }}</text><text>/{{ summary.total }} 箱</text></view>
<button class="box-tasks-btn" :aria-label="`查看柜次任务,已完成 ${completedCount}/${boxes.length}`" @tap="openBoxList('pending')"><text>柜次</text><text class="box-tasks-count">{{ completedCount }}/{{ boxes.length }}</text><text class="box-tasks-arrow">›</text></button>
</view>
</view>
<view class="progress-bar-bg">
@@ -91,7 +78,7 @@
:class="itemState(item)"
>
<view class="pick-item-row1">
<text class="pick-item-sku">{{ item.inboundCode || item.orderNo || '箱级拣货明细' }}</text>
<text class="pick-item-sku">拣货明细</text>
<view class="pick-item-meta">
<text v-if="itemState(item) === ITEM_STATUS.PICKED" class="pick-status-tag tag-picked">已拣 ✓</text>
<text v-else-if="itemState(item) === ITEM_STATUS.PARTIAL" class="pick-status-tag tag-partial">
@@ -108,7 +95,7 @@
</view>
<view class="pick-item-row2">
<text class="pick-item-desc">{{ itemDescription(item) }}</text>
<text class="pick-item-location">{{ item.location }}</text>
<text v-if="item.location" class="pick-item-location">{{ item.location }}</text>
</view>
<view v-if="itemState(item) === ITEM_STATUS.PENDING" class="pick-item-actions">
@@ -116,7 +103,7 @@
<button class="pick-btn pick-btn-partial" @tap="openPartial(item.id)">部分拣货</button>
<button class="pick-btn pick-btn-skip" @tap="openSkip(item.id)">跳拣</button>
</view>
<view v-else-if="!completedBoxes[currentBoxIndex]" class="pick-item-actions processed-actions">
<view v-else-if="pickingOperationReady" class="pick-item-actions processed-actions">
<button class="pick-btn pick-btn-undo" @tap="undoItem(item.id)">撤销</button>
</view>
</view>
@@ -124,17 +111,20 @@
</view>
</view>
<view v-if="activeStage === 'evidence' && loadingStageReady" class="loading-evidence-section">
<view v-if="activeStage === 'evidence'" class="loading-evidence-section">
<view class="section-heading-row">
<view><text class="section-title">装柜证据</text><text class="section-subtitle">空仓、半仓、满仓、锁仓及带锁码照</text></view>
<view><text class="section-title">装柜证据</text><text class="section-subtitle">每类至少 1 张,拍照后自动上传,可继续追加</text></view>
<text class="sync-state" :class="remoteSyncState.tone">{{ remoteSyncState.label }}</text>
</view>
<view class="evidence-grid">
<view v-for="slot in evidenceSlots" :key="slot.type" class="evidence-card" :class="{ filled: evidenceEntries(slot.type).length }">
<view v-for="(slot, slotIndex) in evidenceSlots" :key="slot.type" class="evidence-card" :class="{ filled: evidenceEntries(slot.type).length }">
<view class="evidence-card-header">
<view class="evidence-card-title">
<text class="evidence-label">{{ slot.label }}</text>
<text class="evidence-count" :class="{ complete: evidenceEntries(slot.type).length }">{{ evidenceEntries(slot.type).length ? evidenceEntries(slot.type).length + ' 张' : '待拍摄' }}</text>
<view class="evidence-card-main">
<text class="evidence-step" :class="{ complete: evidenceEntries(slot.type).length }">{{ evidenceEntries(slot.type).length ? '✓' : slotIndex + 1 }}</text>
<view class="evidence-card-title">
<text class="evidence-label">{{ slot.label }}</text>
<text class="evidence-count" :class="{ complete: evidenceEntries(slot.type).length }">{{ evidenceEntries(slot.type).length ? `已拍 ${evidenceEntries(slot.type).length} 张` : '待拍摄' }}</text>
</view>
</view>
<button class="evidence-btn evidence-add-btn" @tap="chooseEvidence(slot)">{{ evidenceUploadLabel(slot.type) }}</button>
</view>
@@ -146,24 +136,22 @@
<button v-if="photo.id && photo.status !== 'VOID' && photo.status !== 'VOIDED'" class="evidence-void-btn" @tap="voidEvidencePhoto(photo)">作废</button>
</view>
</view>
<text v-else class="evidence-empty-hint">现场拍摄后自动上传,可继续追加</text>
<text v-if="evidenceQueueEntry(slot.type)" class="evidence-upload-state" :class="evidenceQueueEntry(slot.type).status.toLowerCase()">
{{ evidenceUploadState(slot.type) }}
</text>
<text v-else-if="hasUnboundUploadedEvidence(slot.type)" class="evidence-upload-state pending">
{{ slot.type === 'SEAL' ? '照片已上传,请输入封签号并保存' : '照片已上传,等待保存' }}
<text v-else-if="hasPendingLocalEvidence(slot.type)" class="evidence-upload-state pending">
{{ slot.type === 'SEAL' ? '请填写封签号,保存后上传' : '照片待自动上传' }}
</text>
<text v-else-if="hasPendingLocalEvidence(slot.type)" class="evidence-upload-state pending">本地暂存,待自动上传</text>
<button v-if="evidenceQueueEntry(slot.type)?.status === 'FAILED'" class="evidence-retry-btn" @tap="retryEvidence(slot.type)">重试上传</button>
</view>
</view>
<view class="seal-row">
<input v-model="sealNo" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
<button class="evidence-btn seal-btn" @tap="saveSealNo">保存封签</button>
<button class="evidence-btn seal-btn" :disabled="sealSaving || !sealReadyToSave" @tap="saveSealNo">{{ sealSaving ? '上传中…' : '保存并上传' }}</button>
</view>
<view v-if="remoteError" class="remote-error-row"><text class="remote-error" role="alert">{{ remoteError }}</text><button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button></view>
<button class="loading-complete-btn" :disabled="submitting || !loadingStageReady || pickingVersion === null || loadingVersion === null || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button>
<view v-if="loadingVersion === null" class="remote-error-row">
<button class="loading-complete-btn" :disabled="submitting || !loadingStageReady || pickingVersion === null || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button>
<view v-if="pickingVersion === null" class="remote-error-row">
<text class="version-hint" role="status">状态尚未同步,请刷新重试</text>
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
</view>
@@ -201,7 +189,7 @@
@tap="selectBoxFromList(entry)"
>
<view class="box-list-info">
<text class="box-list-number">{{ entry.box.boxNo }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }}</text>
<text class="box-list-number">{{ containerReference(entry.box) }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }}</text>
<text class="box-list-detail">{{ boxListDetail(entry) }}</text>
</view>
<text class="box-list-tag" :class="boxListType === 'done' ? 'box-list-tag-done' : 'box-list-tag-pending'">
@@ -236,31 +224,32 @@
<view class="modal-sheet partial-modal" role="dialog" aria-modal="true" aria-label="部分拣货" @tap.stop>
<PopupHeader title="部分拣货" @close="closeOverlay" />
<view v-if="partialTargetItem" class="partial-info">
<text class="partial-info-sku">{{ partialTargetItem.sku }}</text>
<text class="partial-info-desc">{{ partialTargetItem.desc }}</text>
<view class="partial-info-qty">需拣总量:<text>{{ partialTargetItem.qty }}</text> {{ partialTargetItem.unit }}</view>
<view class="partial-info-copy"><text v-if="partialTargetItem.sku" class="partial-info-sku">{{ partialTargetItem.sku }}</text><text class="partial-info-desc">{{ partialTargetItem.desc }}</text></view>
<view class="partial-info-qty"><text class="partial-info-qty-label">需拣</text><text class="partial-info-qty-number">{{ partialTargetItem.qty }}</text><text>{{ partialTargetItem.unit }}</text></view>
</view>
<view class="partial-input-row">
<text class="partial-input-label">实际拣出</text>
<input
v-model="partialInput"
class="partial-input"
aria-label="实际拣出数量"
<view class="partial-field">
<text class="partial-input-label">实际拣出数量</text>
<view class="partial-input-control"><input
v-model="partialInput"
class="partial-input"
aria-label="实际拣出数量"
type="number"
placeholder="请输入数量"
placeholder-class="partial-input-placeholder"
:focus="partialFocused"
confirm-type="done"
@confirm="confirmPartial"
/>
<text class="partial-input-unit">{{ partialTargetItem ? partialTargetItem.unit : '' }}</text>
/><text class="partial-input-unit">{{ partialTargetItem ? partialTargetItem.unit : '' }}</text></view>
</view>
<view class="partial-quick-row">
<button
v-for="option in partialQuickOptions"
:key="option.label"
class="partial-quick-btn"
:class="{ selected: String(partialInput) === String(option.value) }"
@tap="setPartialInput(option.value)"
>
{{ option.label }} ({{ option.value }})
<text class="partial-quick-ratio">{{ option.label }}</text><text class="partial-quick-value">{{ option.value }}{{ partialTargetItem ? partialTargetItem.unit : '' }}</text>
</button>
</view>
<text v-if="partialValidationMessage" class="partial-validation" role="alert">{{ partialValidationMessage }}</text>
@@ -291,7 +280,7 @@
<PopupHeader :title="`仍有 ${summary.pending} 件未完成`" tone="danger" @close="closeOverlay" />
<text class="decision-desc submit-decision-desc">确认提交后,以下待拣商品会被明确标记为“未拣出”,本次演示提交不可撤销。</text>
<view class="pending-sku-list">
<text v-for="item in pendingItems.slice(0, 3)" :key="item.id">{{ item.sku }} · {{ item.location }}</text>
<text v-for="item in pendingItems.slice(0, 3)" :key="item.id">{{ item.location ? `${item.sku} · ${item.location}` : item.sku }}</text>
<text v-if="pendingItems.length > 3">另有 {{ pendingItems.length - 3 }} 件</text>
</view>
<text class="decision-label">选择未完成原因</text>
@@ -309,7 +298,11 @@
<view class="complete-modal" role="dialog" aria-modal="true" aria-label="拣货完成" @tap.stop>
<PopupHeader title="拣货完成" @close="closeOverlay" />
<view class="complete-icon">✓</view>
<text class="complete-desc">集装箱号 {{ currentBox.currentContainerNo }} · 柜次号 {{ currentBox.cabinetNo }} · 订单 {{ currentBox.orderNo }}</text>
<text class="complete-desc">{{ containerReference(currentBox) }}</text>
<view class="complete-evidence-status">
<text>装柜照片已拍 {{ evidenceCapturedTypeCount }}/{{ evidenceSlots.length }} 类</text>
<text>{{ sealNo ? '封签号已填写' : '封签号未填写' }}</text>
</view>
<view class="complete-stats">
<view class="complete-stat">
<text class="complete-stat-num green">{{ completionSummary.picked }}</text>
@@ -330,20 +323,11 @@
</view>
<view class="complete-btns">
<button class="complete-btn-back" @tap="closeOverlay">返回查看</button>
<button class="complete-btn-next" @tap="nextBox">{{ nextBoxButtonText }}</button>
<button class="complete-btn-next" @tap="continueToEvidence">{{ evidenceNextButtonText }}</button>
</view>
</view>
</uni-popup>
<uni-popup ref="allCompletePopup" type="center" :is-mask-click="true" @change="handleOverlayChange('allComplete', $event)">
<view class="complete-modal" role="dialog" aria-modal="true" aria-label="全部柜次已完成" @tap.stop>
<PopupHeader title="全部柜次已完成" @close="closeOverlay" />
<view class="complete-icon">✓</view>
<text class="complete-desc">本班次 {{ boxes.length }} 个柜次任务已全部处理</text>
<button class="all-complete-btn" @tap="leavePicking">返回工作台</button>
</view>
</uni-popup>
<view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
</view>
</template>
@@ -352,15 +336,17 @@
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates } from '@/utils/picking-state.js'
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
import { session, requireSession } from '@/stores/session.js'
import { clearPickingDraft, getPickingSession, restorePickingSession, savePickingSession } from '@/stores/picking.js'
import { clearLoadingCompletionAttempt, clearPickingDraft, getPickingSession, restoreLoadingCompletionAttempt, restorePickingSession, saveLoadingCompletionAttempt, savePickingSession } from '@/stores/picking.js'
import { navigateBackOr } from '@/utils/navigation.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { claimContainerPickingTask, completeContainerPickingTask, completePicking, downloadEvidencePreview, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getEvidencePreviewUrl, getPickingInventorySnapshot, getPickingTask, isRemoteReady, listLoadingOrders, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, upsertEvidence, voidEvidence } from '@/api/pda-loading.js'
import { claimContainerPickingTask, completeContainerPickingTask, downloadEvidencePreview, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getEvidencePreviewUrl, getPickingInventorySnapshot, isRemoteReady, listLoadingOrders, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, voidEvidence } from '@/api/pda-loading.js'
import { createEvidenceUploadQueue } from '@/utils/evidence-upload-queue.js'
import { isOnline } from '@/utils/network.js'
import { stableOperationId } from '@/utils/idempotency.js'
import { stableTaskOperationIdentity } from '@/utils/task-identity.js'
import { COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion } from '@/utils/loading-completion-state.js'
import { containerReference, formatApiError, sanitizeUserMessage } from '@/utils/user-facing.js'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import PopupHeader from '@/components/PopupHeader.vue'
@@ -370,7 +356,11 @@
const workflowStatus = task?.workflowStatus
const taskStatus = task?.taskStatus ?? task?.status
return workflowStatus === 'AWAITING_REVIEW'
|| workflowStatus === 'PROCESSING'
|| workflowStatus === 'COMPLETION_FAILED'
|| workflowStatus === 'COMPLETED'
|| taskStatus === 'COMPLETING'
|| taskStatus === 'COMPLETION_FAILED'
|| taskStatus === 'COMPLETED'
}
@@ -397,7 +387,7 @@
selectedIncompleteReason: '',
completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 },
evidenceSlots: EVIDENCE_SLOTS,
evidenceFiles: {}, evidenceDrafts: {}, evidencePreviewCache: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, evidenceIdentity: null, cameraPicking: false, evidencePreviewOpening: false, evidencePreviewReturning: false, sealNo: '', loadingVersion: null, pickingVersion: null, remotePick: null,
evidenceFiles: {}, evidenceDrafts: {}, evidencePreviewCache: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, evidenceIdentity: null, cameraPicking: false, evidencePreviewOpening: false, evidencePreviewReturning: false, sealNo: '', sealSaving: false, pickingVersion: null, remotePick: null,
remoteTaskId: null, assignmentVersion: null, submitId: '',
taskRequestId: 0,
remoteError: '', remoteLoading: false, submitting: false,
@@ -406,7 +396,7 @@
},
computed: {
currentBox() {
return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', items: [] }
return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', inboundCode: '', items: [] }
},
summary() {
return getPickSummary(this.currentBox.items, this.itemStates, this.partialQuantities)
@@ -442,16 +432,16 @@
},
pickingOperationReady() {
return this.remotePick?.workflowStatus === 'IN_PROGRESS'
&& Boolean(this.remoteTaskId && this.currentBox.inboundId && this.currentBox.loadingPlanId && this.currentBox.containerBookingId)
&& Boolean(this.remoteTaskId && this.currentBox.loadingPlanId && this.currentBox.containerBookingId)
&& this.pickingVersion !== null
},
loadingStageReady() {
return this.pickingCompleted
&& this.evidenceIdentity
&& this.evidenceIdentity.inboundId
&& this.loadingVersion !== null
&& this.loadingVersion !== undefined
&& Number.isFinite(Number(this.loadingVersion))
&& this.evidenceIdentity.taskId
&& this.evidenceIdentity.warehouseId
&& this.pickingVersion !== null
&& Number.isFinite(Number(this.pickingVersion))
},
partialTargetItem() {
return this.currentBox.items.find((item) => item.id === this.partialTargetId) || null
@@ -490,8 +480,13 @@
if (this.summary.pending > 0) return '提交时需选择原因'
return `已处理 ${this.summary.processed}/${this.summary.total}`
},
nextBoxButtonText() {
return this.completedCount === this.boxes.length ? '查看全部完成' : '下一箱'
evidenceCapturedTypeCount() {
return this.evidenceSlots.filter((slot) => this.evidenceEntries(slot.type).length > 0).length
},
evidenceNextButtonText() {
return this.evidenceCapturedTypeCount === this.evidenceSlots.length && Boolean(String(this.sealNo || '').trim())
? '查看装柜取证'
: '去拍照'
},
visibleBoxList() {
return this.boxes
@@ -501,13 +496,16 @@
: !this.completedBoxes[entry.index])
},
remoteSyncState() {
if (!isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) return { label: '未连接', tone: 'error' }
if (!isRemoteReady(this.remoteTaskId || this.currentBox.taskId)) return { label: '未连接', tone: 'error' }
if (this.remoteLoading) return { label: '同步中', tone: 'loading' }
if (this.remoteError) return { label: '同步失败', tone: 'error' }
return { label: this.remotePick ? '已同步' : '待同步', tone: this.remotePick ? 'ready' : 'local' }
},
evidenceComplete() {
return this.evidenceSlots.every((slot) => this.validEvidenceEntries(slot.type).length > 0) && Boolean(String(this.sealNo || '').trim())
},
sealReadyToSave() {
return Boolean(String(this.sealNo || '').trim()) && this.hasPendingLocalEvidence('SEAL')
}
},
created() {
@@ -515,17 +513,17 @@
},
onLoad(options = {}) {
if (!requireSession()) return
this.currentUser = session.currentUser
if (options.taskId) {
// A local completion marker belongs to the previously opened task. The
// canonical server detail decides whether this task is complete.
this.completedBoxes = {}
this.boxes = [{
boxNo: String(options.currentContainerNo || options.cabinetNo || '同步中'), cabinetNo: options.cabinetNo ? String(options.cabinetNo) : null, currentContainerNo: options.currentContainerNo ? String(options.currentContainerNo) : null, orderNo: String(options.orderNo || options.inboundCode || ''),
boxNo: '', cabinetNo: null, currentContainerNo: null, orderNo: '', inboundCode: '',
taskId: String(options.taskId), loadingPlanId: null, containerBookingId: null,
inboundId: options.inboundId ? String(options.inboundId) : null,
inboundVersion: options.inboundVersion == null ? null : Number(options.inboundVersion),
inboundId: null, inboundVersion: null,
warehouseId: options.warehouseId ? String(options.warehouseId) : null,
tenantId: options.tenantId ? String(options.tenantId) : null, items: []
tenantId: null, items: []
}]
this.currentBoxIndex = 0
this.remoteTaskId = options.taskId ? String(options.taskId) : null
@@ -533,6 +531,7 @@
},
async onShow() {
if (!requireSession()) return
this.currentUser = session.currentUser
this.startHardwareScanner()
if (this.cameraPicking) {
this.debugEvidence('choose.resume', { evidenceIdentity: this.evidenceIdentity })
@@ -562,6 +561,7 @@
return false
},
methods: {
containerReference,
isSelectableTask(task) {
return Boolean(task?.recordStatus === 'ACTIVE'
&& !task?.mergedIntoContainerBookingId
@@ -576,10 +576,11 @@
},
boxFromTask(task, existing = null) {
return {
boxNo: String(task.currentContainerNo || existing?.boxNo || task.cabinetNo || '同步中'),
boxNo: String(task.currentContainerNo || existing?.boxNo || ''),
cabinetNo: task.cabinetNo ? String(task.cabinetNo) : (existing?.cabinetNo || null),
currentContainerNo: task.currentContainerNo ? String(task.currentContainerNo) : (existing?.currentContainerNo || null),
orderNo: String(task.displayNo || task.orderNo || existing?.orderNo || ''),
orderNo: String(task.orderNo || existing?.orderNo || ''),
inboundCode: String(task.inboundCode || existing?.inboundCode || ''),
taskId: String(task.taskId),
loadingPlanId: task.loadingPlanId || existing?.loadingPlanId || null,
containerBookingId: task.containerBookingId || existing?.containerBookingId || null,
@@ -624,10 +625,6 @@
}
},
selectStage(stage) {
if (stage === 'evidence' && !this.loadingStageReady) {
this.showToast('请先完成新增待拣明细', 'warning')
return
}
this.activeStage = stage
},
debugEvidence(event, payload = {}) {
@@ -638,10 +635,9 @@
console.info('[PDA] picking.submit', {
event,
taskId: this.remoteTaskId,
inboundId: this.currentBox.inboundId || this.evidenceIdentity?.inboundId || null,
loadingPlanId: this.currentBox.loadingPlanId,
containerBookingId: this.currentBox.containerBookingId,
warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse,
warehouseId: this.currentBox.warehouseId || null,
pickingVersion: this.pickingVersion,
pending: this.summary.pending,
...payload
@@ -651,6 +647,9 @@
isTaskConflict(error) {
return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409
},
remoteFailureMessage(error, fallback = '操作失败') {
return formatApiError(error, fallback)
},
async refreshAfterTaskConflict(message) {
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
this.submitId = ''
@@ -664,7 +663,7 @@
if (String(this.remoteTaskId || '') === String(requestedTaskId || '') && !this.remoteError) this.remoteError = message
},
async refreshInventorySnapshot() {
const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse
const warehouseId = this.currentBox.warehouseId
const snapshot = await getPickingInventorySnapshot(this.remoteTaskId, warehouseId)
if (!snapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试')
if (this.pickingVersion !== null && String(snapshot.version) !== String(this.pickingVersion)) {
@@ -675,15 +674,11 @@
return snapshot
},
captureEvidenceIdentity() {
return {
taskId: this.remoteTaskId || this.currentBox.taskId || null,
inboundId: this.currentBox.inboundId || null,
loadingPlanId: this.currentBox.loadingPlanId || null,
containerBookingId: this.currentBox.containerBookingId || null
}
return { taskId: this.remoteTaskId || this.currentBox.taskId || null, warehouseId: this.currentBox.warehouseId || null }
},
isSameEvidenceIdentity(left, right) {
return ['taskId', 'inboundId', 'loadingPlanId', 'containerBookingId'].every((key) => String(left?.[key] ?? '') === String(right?.[key] ?? ''))
return String(left?.taskId ?? '') === String(right?.taskId ?? '')
&& String(left?.warehouseId ?? '') === String(right?.warehouseId ?? '')
},
isMergedContainerRecord(task) {
return task?.recordStatus === 'MERGED' || Boolean(task?.mergedIntoContainerBookingId)
@@ -698,12 +693,7 @@
warehouseId: previousIdentity?.warehouseId ?? this.currentBox.warehouseId
}
clearPickingDraft(staleIdentity)
const evidenceKey = this.evidenceDraftKey({
taskId: staleIdentity.taskId,
inboundId: previousIdentity?.inboundId ?? task?.inboundId,
loadingPlanId: staleIdentity.loadingPlanId,
containerBookingId: staleIdentity.containerBookingId
})
const evidenceKey = this.evidenceDraftKey({ taskId: staleIdentity.taskId, warehouseId: staleIdentity.warehouseId })
this.evidenceDrafts = Object.fromEntries(Object.entries(this.evidenceDrafts).filter(([key]) => key !== evidenceKey))
this.evidencePreviewCache = Object.fromEntries(Object.entries(this.evidencePreviewCache).filter(([key]) => !key.startsWith(`${evidenceKey}|`)))
this.taskRequestId += 1
@@ -713,7 +703,6 @@
this.remoteTaskId = null
this.remotePick = null
this.pickingVersion = null
this.loadingVersion = null
this.assignmentVersion = null
this.itemStates = {}
this.partialQuantities = {}
@@ -736,11 +725,11 @@
&& this.isSameEvidenceIdentity(this.evidenceIdentity, identity)
},
evidenceDraftKey(identity) {
return ['taskId', 'inboundId', 'loadingPlanId', 'containerBookingId'].map((key) => String(identity?.[key] ?? '')).join('|')
return `${String(identity?.taskId ?? '')}|${String(identity?.warehouseId ?? '')}`
},
rememberEvidenceDraft(identity = this.evidenceIdentity) {
const key = this.evidenceDraftKey(identity)
if (!key.replace(/\|/g, '')) return
if (!identity?.taskId || !identity?.warehouseId) return
const files = Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, (Array.isArray(entries) ? entries : [entries]).filter((entry) => entry && !entry.id && entry.localPath).map((entry) => ({ ...entry }))]).filter(([, entries]) => entries.length))
if (Object.keys(files).length || String(this.sealNo || '').trim()) this.evidenceDrafts = { ...this.evidenceDrafts, [key]: { files, sealNo: this.sealNo || '' } }
},
@@ -760,12 +749,20 @@
this.evidenceDrafts = Object.keys(files).length || draft.sealNo ? { ...this.evidenceDrafts, [key]: { ...draft, files } } : Object.fromEntries(Object.entries(this.evidenceDrafts).filter(([draftKey]) => draftKey !== key))
},
requireEvidenceCapture() {
if (!this.evidenceIdentity?.inboundId) {
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) {
this.showToast('任务状态尚未同步,请刷新重试', 'warning')
return false
}
return true
},
requireEvidenceMutation() {
if (!this.requireEvidenceCapture()) return false
if (!isRemoteReady(this.evidenceIdentity.taskId)) {
this.showToast('当前会话未连接服务端,证据保留本地', 'warning')
return false
}
return true
},
requirePickingOperation() {
if (this.pickingOperationReady) return true
this.showToast('任务尚未领取完成,请稍候或刷新重试', 'warning')
@@ -789,8 +786,9 @@
this.evidenceFiles = {}
this.evidenceQueueItems = []
this.sealNo = ''
this.sealSaving = false
this.evidenceQueue = createEvidenceUploadQueue({
upload: (item, onProgress) => uploadEvidenceFile(item.inboundId, item.evidenceType, item.localPath, item.tenantId, onProgress),
upload: (item, onProgress) => uploadEvidenceFile(item.taskId, item.warehouseId, item, item.localPath, onProgress),
onChange: (items) => { if (this.evidenceContextId === contextId) this.evidenceQueueItems = items }
})
},
@@ -804,7 +802,7 @@
},
draftIdentity() {
const operatorId = session.currentUser?.id || session.currentUser?.userId || session.currentUser?.accountId
return { operatorId, taskId: this.remoteTaskId || this.currentBox.taskId, inboundId: this.currentBox.inboundId || this.remotePick?.inboundId, loadingPlanId: this.currentBox.loadingPlanId || this.remotePick?.loadingPlanId, containerBookingId: this.currentBox.containerBookingId || this.remotePick?.containerBookingId, warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse }
return { operatorId, taskId: this.remoteTaskId || this.currentBox.taskId, inboundId: this.currentBox.inboundId || this.remotePick?.inboundId, loadingPlanId: this.currentBox.loadingPlanId || this.remotePick?.loadingPlanId, containerBookingId: this.currentBox.containerBookingId || this.remotePick?.containerBookingId, warehouseId: this.currentBox.warehouseId || null }
},
restoreRemoteDraft() {
const restored = restorePickingSession(this.draftIdentity())
@@ -935,7 +933,6 @@
this.remoteTaskId = this.currentBox.taskId || null
this.remotePick = null
this.pickingVersion = null
this.loadingVersion = null
this.assignmentVersion = null
this.resetEvidenceState()
this.boxDropdownOpen = false
@@ -1012,13 +1009,22 @@
uni.previewImage({ current, urls, fail: () => { this.evidencePreviewOpening = false } })
},
evidenceItems(result) {
if (Array.isArray(result?.evidences)) return result.evidences
return Array.isArray(result?.evidences?.evidences) ? result.evidences.evidences : []
const items = Array.isArray(result?.evidences) ? result.evidences : (Array.isArray(result?.evidences?.evidences) ? result.evidences.evidences : [])
const seen = new Set()
return items.map((item) => ({
...item,
id: String(item.evidenceId ?? item.id ?? ''),
fileId: String(item.fileId ?? '')
})).filter((item) => {
const key = `${item.evidenceType || ''}:${item.fileId || `id-${item.id || ''}`}`
if (seen.has(key)) return false
seen.add(key)
return true
})
},
validEvidenceEntries(type) { return this.evidenceEntries(type).filter((item) => item.id && item.fileId && item.uploadStatus !== 'PENDING') },
evidenceEntry(type) { const list = this.evidenceEntries(type); return list.length ? list[list.length - 1] : null },
hasPendingLocalEvidence(type) { return this.evidenceEntries(type).some((item) => item.localPath && !item.fileId) },
hasUnboundUploadedEvidence(type) { return this.evidenceEntries(type).some((item) => item.fileId && !item.id) },
evidenceQueueEntry(type) {
return [...this.evidenceQueueItems].reverse().find((item) => item.evidenceType === type) || null
},
@@ -1029,7 +1035,6 @@
if (item.status === 'UPLOADING') return `上传中 ${item.progress || 0}%`
if (item.status === 'FAILED') return item.error || '上传失败'
if (item.status === 'SUCCEEDED') {
if (type === 'SEAL' && this.hasUnboundUploadedEvidence(type)) return '照片已上传,请输入封签号并保存'
return this.validEvidenceEntries(type).length ? '已上传并保存' : (item.attempts > 1 ? '重试成功' : '已上传')
}
if (item.status === 'REPLACED') return '已替换'
@@ -1053,13 +1058,12 @@
this.currentBox.inboundId = null
this.currentBox.loadingPlanId = null
this.currentBox.containerBookingId = null
this.loadingVersion = null
this.resetEvidenceState()
this.debugSubmit('refresh.start', { requestId, selector })
this.remoteLoading = true
this.remoteError = ''
try {
const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse
const warehouseId = this.currentBox.warehouseId
const task = await getContainerPickingTask(selector, warehouseId)
if (requestId !== this.taskRequestId) return
if (!task) throw new Error('服务端未返回当前拣货任务')
@@ -1071,7 +1075,7 @@
if (task.recordStatus !== 'ACTIVE') throw new Error('当前柜记录尚未就绪,请返回任务列表刷新')
const detailTaskId = task.taskId ?? task.id
if (String(detailTaskId || '') !== String(requestedTaskId)) throw new Error('服务端返回的拣货任务与当前选择不一致,请返回刷新')
if (!task.inboundId || !task.loadingPlanId || !task.containerBookingId) throw new Error('任务尚未同步完整作业关联,请返回任务列表刷新')
if (!task.loadingPlanId || !task.containerBookingId) throw new Error('任务尚未同步完整作业关联,请返回任务列表刷新')
if (task.version === null || task.version === undefined) throw new Error('任务状态尚未同步,请刷新重试')
if (!task.currentContainerNo) throw new Error('任务尚未同步集装箱号,请返回任务列表刷新')
const remoteItems = task.items || task.pickItems || task.details || task.lines
@@ -1096,11 +1100,8 @@
skipReason: item.skipReason || '', finalState: item.finalState || '', finalPartialQuantity: item.finalPartialQuantity || 0
}))
const firstItem = this.currentBox.items[0]
const evidenceItem = this.currentBox.items.find((item) => String(item.inboundId || '') === String(task.inboundId))
if (!evidenceItem) throw new Error('任务明细与入库单关联不一致,请返回刷新')
this.currentBox.orderNo = firstItem.orderNo || firstItem.inboundCode || this.currentBox.orderNo
this.currentBox.inboundId = task.inboundId
this.currentBox.tenantId = evidenceItem.tenantId || null
this.currentBox.orderNo = firstItem.orderNo || task.orderNo || this.currentBox.orderNo
this.currentBox.inboundCode = firstItem.inboundCode || task.inboundCode || this.currentBox.inboundCode
const serverStates = createItemStates(this.currentBox.items)
const serverPartialQuantities = {}
this.currentBox.items.forEach((item) => {
@@ -1138,21 +1139,19 @@
this.remotePick = normalizePickingTask({ ...task, ...(claimed || {}) })
if (remoteTask.workflowStatus === 'UNASSIGNED' && this.remotePick.workflowStatus !== 'IN_PROGRESS') throw new Error('任务领取尚未完成,请刷新重试')
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? null
this.loadingVersion = this.remotePick.inboundVersion ?? this.remotePick.loadingVersion ?? null
this.evidenceIdentity = this.captureEvidenceIdentity()
this.restoreEvidenceDraft()
if (this.isSameEvidenceIdentity(previousEvidenceState.identity, this.evidenceIdentity)) {
this.evidenceFiles = { ...this.evidenceFiles, ...previousEvidenceState.files }
if (previousEvidenceState.sealNo) this.sealNo = previousEvidenceState.sealNo
}
this.debugSubmit('refresh.ready', { requestId, workflowStatus: this.remotePick.workflowStatus, taskStatus: this.remotePick.taskStatus, assignmentVersion: this.assignmentVersion, loadingVersion: this.loadingVersion, evidenceIdentity: this.evidenceIdentity })
this.debugSubmit('refresh.ready', { requestId, workflowStatus: this.remotePick.workflowStatus, taskStatus: this.remotePick.taskStatus, assignmentVersion: this.assignmentVersion, evidenceIdentity: this.evidenceIdentity })
this.assignmentVersion = this.remotePick.assignment?.version ?? this.remotePick.assignmentVersion ?? this.assignmentVersion
if (!this.pickingCompleted) this.restoreRemoteDraft()
if (!this.pickingCompleted) {
const completedBoxes = { ...this.completedBoxes }
delete completedBoxes[this.currentBoxIndex]
this.completedBoxes = completedBoxes
this.activeStage = 'picking'
this.completionSummary = { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 }
}
const addedItemIds = this.currentBox.items.map((item) => String(item.id)).filter((id) => !previousItemIds.has(id))
@@ -1161,26 +1160,24 @@
this.debugSubmit('refresh.incremental', { previousTaskVersion, taskVersion: this.pickingVersion, addedItemIds, pickingCompleted: this.pickingCompleted })
if (addedItemIds.length) this.showToast(`已同步 ${addedItemIds.length} 条新增待拣明细`, 'warning')
}
if (this.loadingStageReady) {
this.activeStage = 'evidence'
await this.loadLoadingEvidence()
}
await this.loadLoadingEvidence()
if (this.loadingStageReady) this.activeStage = 'evidence'
} catch (error) {
if (requestId !== this.taskRequestId) return
this.debugSubmit('refresh.error', { requestId, message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : (error.message || '任务同步失败')
this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : this.remoteFailureMessage(error, '任务同步失败')
} finally { if (requestId === this.taskRequestId) this.remoteLoading = false }
},
async loadLoadingEvidence() {
if (!this.loadingStageReady || !this.evidenceIdentity || !isRemoteReady(this.evidenceIdentity.inboundId)) return
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) return
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
try {
const evidence = await getEvidence(identity.inboundId, this.currentBox.tenantId)
const evidence = await getEvidence(identity.taskId, identity.warehouseId)
if (!this.isEvidenceContextActive(contextId, identity)) return
const remoteItems = this.evidenceItems(evidence).map((item) => ({
...item,
previewUrl: item.previewUrl || getEvidencePreviewUrl(identity.inboundId, item.id, this.currentBox.tenantId)
previewUrl: item.previewUrl || getEvidencePreviewUrl(identity.taskId, item.id, identity.warehouseId)
}))
this.debugEvidence('list.loaded', { evidenceIdentity: identity, count: remoteItems.length, previewCount: remoteItems.filter((item) => item.previewUrl).length })
const localFiles = Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, (Array.isArray(entries) ? entries : [entries]).filter((entry) => entry && !entry.id && entry.localPath)]))
@@ -1192,7 +1189,7 @@
await this.flushPendingEvidence()
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : (error.message || '装柜证据加载失败')
this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : this.remoteFailureMessage(error, '装柜证据加载失败')
}
},
async loadEvidencePreviews(contextId, identity) {
@@ -1247,8 +1244,11 @@
const next = { evidenceType: slot.type, fileName: path.split('/').pop() || path, localPath: path, uploadStatus: 'PENDING' }
this.evidenceFiles = { ...this.evidenceFiles, [slot.type]: [...(this.evidenceFiles[slot.type] || []), next] }
this.rememberEvidenceDraft(identity)
if (this.loadingStageReady) this.queueEvidenceUpload(slot.type, path)
else this.showToast('照片已暂存,装柜状态同步后自动上传', 'success')
if (slot.type === 'SEAL') {
this.showToast('照片已拍摄,请输入封签号并保存上传', 'success')
return
}
this.queueEvidenceUpload(slot.type, path)
}, fail: (error) => {
this.cameraPicking = false
if (!this.isEvidenceContextActive(contextId, identity)) return
@@ -1256,41 +1256,45 @@
this.showToast('未选择照片,可稍后重试', 'warning')
} })
},
async queueEvidenceUpload(type, path) {
if (!this.loadingStageReady) return
if (!this.evidenceIdentity?.inboundId || !isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return }
applyEvidenceResponse(result, type, localPath, identity) {
const remoteItems = this.evidenceItems(result)
const saved = [...remoteItems].reverse().find((item) => item.evidenceType === type && item.status !== 'VOID' && item.status !== 'VOIDED')
if (saved?.fileId && localPath) this.cacheEvidencePreview(saved.fileId, localPath, identity)
const localFiles = Object.fromEntries(Object.entries(this.evidenceFiles).map(([entryType, entries]) => [entryType, (Array.isArray(entries) ? entries : [entries]).filter((entry) => entry && !entry.id && entry.localPath && entry.localPath !== localPath)]).filter(([, entries]) => entries.length))
this.evidenceFiles = remoteItems.map((item) => this.withCachedEvidencePreview(item, identity)).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, localFiles)
const seal = this.evidenceEntry('SEAL')
if (seal?.sealNo) this.sealNo = seal.sealNo
this.removeDraftPhoto(type, localPath)
},
async queueEvidenceUpload(type, path, sealNo = null) {
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) { this.showToast('当前任务仓库尚未同步,证据保留本地', 'warning'); return }
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
const queue = this.evidenceQueue
const entry = queue.add({ ...identity, tenantId: this.currentBox.tenantId, evidenceType: type, localPath: path })
const active = [...this.validEvidenceEntries(type)].reverse()[0]
const expectedVersion = active?.version ?? 0
const operationId = stableOperationId('evidence-upload', `${identity.taskId}|${identity.warehouseId}`, { evidenceType: type, expectedVersion, localPath: path, sealNo: sealNo || '' })
const entry = queue.add({ taskId: identity.taskId, warehouseId: identity.warehouseId, evidenceType: type, expectedVersion, sealNo: sealNo || null, remark: null, operationId, localPath: path })
this.debugSubmit('evidence.queue.start', { evidenceType: type, queueId: entry.id, evidenceIdentity: identity })
await queue.flush()
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
const latest = queue.snapshot().find((item) => item.id === entry.id)
this.debugSubmit('evidence.queue.result', { evidenceType: type, queueId: entry.id, status: latest?.status || 'missing', fileId: latest?.result?.fileId || null, error: latest?.error || '' })
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
const entries = this.evidenceFiles[type] || []
const current = entries.find((item) => item.localPath === path) || {}
this.evidenceFiles = { ...this.evidenceFiles, [type]: entries.map((item) => item.localPath === path ? { ...item, fileId: latest.result?.fileId, fileName: latest.result?.fileName || item.fileName, uploadStatus: 'SUCCEEDED' } : item) }
if (type === 'SEAL') {
this.rememberEvidenceDraft(identity)
this.showToast('带锁码照已上传,请输入封签号并点击保存封签', 'success')
return
}
await this.saveEvidence(type, null, path)
this.applyEvidenceResponse(latest.result, type, path, identity)
this.showToast(type === 'SEAL' ? '带锁码照上传成功,封签号已保存' : '照片已上传并保存', 'success')
},
async flushPendingEvidence() {
if (!this.loadingStageReady) return
const pending = this.evidenceSlots.flatMap((slot) => this.evidenceEntries(slot.type).filter((entry) => !entry.id && entry.localPath).map((entry) => ({ type: slot.type, path: entry.localPath, fileId: entry.fileId })))
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
const pending = this.evidenceSlots.flatMap((slot) => this.evidenceEntries(slot.type).filter((entry) => !entry.id && entry.localPath).map((entry) => ({ type: slot.type, path: entry.localPath })))
for (const item of pending) {
if (!this.loadingStageReady) return
if (item.fileId && item.type !== 'SEAL') await this.saveEvidence(item.type, null, item.path)
else if (item.fileId) continue
else await this.queueEvidenceUpload(item.type, item.path)
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
if (item.type === 'SEAL' && !String(this.sealNo || '').trim()) continue
await this.queueEvidenceUpload(item.type, item.path, item.type === 'SEAL' ? this.sealNo : null)
}
},
async retryEvidence(type) {
if (!this.requireEvidenceStage()) return
if (!this.requireEvidenceCapture()) return
const item = this.evidenceQueueEntry(type)
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
@@ -1301,111 +1305,102 @@
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
const latest = queue.latest(type)
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
const current = this.evidenceEntry(type) || {}
this.evidenceFiles = { ...this.evidenceFiles, [type]: [...(this.evidenceFiles[type] || []).slice(0, -1), { ...current, fileId: latest.result?.fileId, fileName: latest.result?.fileName || current.fileName, uploadStatus: 'SUCCEEDED' }] }
if (type === 'SEAL') {
this.rememberEvidenceDraft(identity)
this.showToast('带锁码照已上传,请输入封签号并点击保存封签', 'success')
return
}
await this.saveEvidence(type)
},
async saveEvidence(type, operationId = null, localPath = null) {
if (!this.requireEvidenceStage()) return
if (type === 'SEAL' && !String(this.sealNo || '').trim()) { this.showToast('请输入封签号后点击保存封签', 'warning'); return }
if (!this.evidenceIdentity?.inboundId) { this.showToast('状态尚未同步,请刷新重试', 'warning'); return }
if (!isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return }
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
const inboundId = identity.inboundId
const tenantId = this.currentBox.tenantId
const current = (localPath ? this.evidenceEntries(type).find((item) => item.localPath === localPath) : null) || this.evidenceEntry(type) || {}
if (!current.fileId) { this.showToast('照片尚未上传完成', 'warning'); return }
const payload = { expectedVersion: current.version || 0, evidenceType: type, fileId: current.fileId || null, fileName: current.fileName || null, sealNo: type === 'SEAL' ? this.sealNo : null, remark: null, operationId: operationId || `evidence-${identity.taskId || identity.inboundId}-${type}-${current.fileId || 'seal'}` }
this.debugSubmit('evidence.bind.start', { evidenceType: type, fileId: current.fileId, expectedVersion: payload.expectedVersion, evidenceIdentity: identity })
try {
const result = await upsertEvidence(inboundId, payload, tenantId)
if (!this.isEvidenceContextActive(contextId, identity)) return
this.debugSubmit('evidence.bind.success', { evidenceType: type, fileId: current.fileId, evidenceCount: result?.evidences?.length || 0, evidenceIdentity: identity })
this.cacheEvidencePreview(current.fileId, current.localPath, identity)
this.evidenceFiles = this.evidenceItems(result).map((item) => this.withCachedEvidencePreview(item, identity)).reduce((all, item) => { const list = (all[item.evidenceType] || []).filter((entry) => entry.id || entry.fileId !== item.fileId); const index = list.findIndex((entry) => entry.id === item.id); all[item.evidenceType] = index >= 0 ? list.map((entry, i) => i === index ? item : entry) : [...list, item]; return all }, this.evidenceFiles)
if (current.localPath) this.evidenceFiles = { ...this.evidenceFiles, [type]: (this.evidenceFiles[type] || []).filter((entry) => entry.id || entry.localPath !== current.localPath) }
this.removeDraftPhoto(type, current.localPath)
this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success')
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
if (this.isTaskConflict(error)) {
await this.refreshAfterTaskConflict(error.message || '任务内容已更新,请完成新增待拣项后重试')
return
}
this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败')
this.showToast('证据保存失败', 'error')
}
this.applyEvidenceResponse(latest.result, type, item.localPath, identity)
this.showToast(type === 'SEAL' ? '带锁码照上传成功,封签号已保存' : '照片重试上传成功', 'success')
},
async saveSealNo() {
if (this.sealSaving) return
if (!String(this.sealNo || '').trim()) { this.showToast('请输入封签号', 'warning'); return }
this.rememberEvidenceDraft()
if (!this.loadingStageReady) { this.showToast('封签号已暂存,装柜状态同步后自动保存', 'success'); return }
await this.saveEvidence('SEAL', `seal-no-${this.evidenceIdentity?.taskId || this.evidenceIdentity?.inboundId}-${Date.now()}`)
const current = [...this.evidenceEntries('SEAL')].reverse().find((item) => item.localPath && !item.id)
if (!current) { this.showToast('当前封签已保存,无需重复提交', 'success'); return }
this.sealSaving = true
try {
await this.queueEvidenceUpload('SEAL', current.localPath, String(this.sealNo).trim())
} finally {
this.sealSaving = false
}
},
async voidEvidencePhoto(photo) {
if (!this.requireEvidenceStage()) return
if (!photo?.id || !this.evidenceIdentity?.inboundId) return
if (!this.requireEvidenceMutation()) return
if (!photo?.id || !this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
const inboundId = identity.inboundId
const tenantId = this.currentBox.tenantId
const reason = 'PDA现场标记错图'
try {
await voidEvidence(inboundId, photo.id, { expectedVersion: photo.version || 0, reason, operationId: `void-${inboundId}-${photo.id}-${photo.version || 0}` }, tenantId)
await voidEvidence(identity.taskId, photo.id, identity.warehouseId, { expectedVersion: photo.version || 0, reason })
if (!this.isEvidenceContextActive(contextId, identity)) return
this.evidenceFiles = { ...this.evidenceFiles, [photo.evidenceType]: (this.evidenceFiles[photo.evidenceType] || []).map((item) => item.id === photo.id ? { ...item, status: 'VOID', voidReason: reason } : item) }
this.showToast('照片已作废,可重新拍摄', 'success')
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
if (this.isTaskConflict(error)) {
await this.refreshAfterTaskConflict(error.message || '任务内容已更新,请刷新后重试')
await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '任务内容已更新,请刷新后重试'))
return
}
this.remoteError = error.network ? '网络失败,照片未作废,请重试' : (error.message || '照片作废失败')
this.remoteError = error.network ? '网络失败,照片未作废,请重试' : this.remoteFailureMessage(error, '照片作废失败')
this.showToast('照片作废失败', 'error')
}
},
async completeLoadingTask() {
if (!this.requireEvidenceStage()) return
if (!this.evidenceIdentity?.inboundId || !isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('装柜任务尚未同步,请刷新后重试', 'warning'); return }
if (this.pickingVersion === null || this.loadingVersion === null) { this.showToast(VERSION_SYNC_MESSAGE, 'warning'); return }
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) { this.showToast('装柜任务或仓库尚未同步,请刷新后重试', 'warning'); return }
if (this.pickingVersion === null) { this.showToast(VERSION_SYNC_MESSAGE, 'warning'); return }
if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return }
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
const expectedTaskVersion = Number(this.pickingVersion)
const operationIdentity = stableTaskOperationIdentity(this.draftIdentity())
const operationId = stableOperationId('complete', operationIdentity, {
const serverAttempt = this.remotePick?.completionCommand
const savedAttempt = restoreLoadingCompletionAttempt(this.draftIdentity()) || (serverAttempt?.operationId ? {
operationId: serverAttempt.operationId,
expectedTaskVersion: serverAttempt.expectedTaskVersion,
remark: serverAttempt.remark || 'PDA确认装柜完成'
} : null)
const expectedTaskVersion = savedAttempt?.expectedTaskVersion ?? Number(this.pickingVersion)
const completionIdentity = this.draftIdentity()
const operationIdentity = stableTaskOperationIdentity(completionIdentity)
const operationId = savedAttempt?.operationId || stableOperationId('complete', operationIdentity, {
taskId: this.remoteTaskId,
expectedTaskVersion,
loadingPlanId: identity.loadingPlanId,
containerBookingId: identity.containerBookingId
loadingPlanId: completionIdentity.loadingPlanId,
containerBookingId: completionIdentity.containerBookingId
})
const completionAttempt = { operationId, expectedTaskVersion, remark: savedAttempt?.remark || 'PDA确认装柜完成' }
saveLoadingCompletionAttempt(this.draftIdentity(), completionAttempt)
this.submitting = true
try {
const completedTask = await completeContainerPickingTask(this.remoteTaskId, {
expectedTaskVersion,
operationId,
remark: 'PDA确认装柜完成'
remark: completionAttempt.remark
})
if (!this.isEvidenceContextActive(contextId, identity)) return
if (completedTask) {
this.remotePick = normalizePickingTask({ ...this.remotePick, ...completedTask })
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion
}
this.showToast('已确认装柜完成', 'success')
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
if (this.isTaskConflict(error)) {
await this.refreshAfterTaskConflict(error.message || '订舱、装柜关联或证据状态已变化,请刷新后重试')
const phase = resolveCompletionPhase({ taskStatus: completedTask?.taskStatus, commandStatus: completedTask?.commandStatus })
if (phase === COMPLETION_PHASE.PROCESSING) {
this.showToast('完成命令已受理,后台处理中', 'success')
this.persistSession()
navigateBackOr('/pages/tasks/tasks')
return
}
this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败')
if (phase === COMPLETION_PHASE.COMPLETED) clearLoadingCompletionAttempt(this.draftIdentity())
this.showToast('装柜已完成', 'success')
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
if (shouldReconcileCompletion(error)) {
this.remoteError = '提交结果待确认,请返回任务列表刷新'
this.showToast('提交结果待确认', 'warning')
navigateBackOr('/pages/tasks/tasks')
return
}
if (this.isTaskConflict(error)) {
await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '订舱、装柜关联或证据状态已变化,请刷新后重试'))
return
}
this.debugSubmit('complete.error', { message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
this.remoteError = error.network ? '网络失败,装柜完成未提交' : this.remoteFailureMessage(error, '装柜完成失败')
this.showToast('装柜完成失败', 'error')
}
finally {
@@ -1420,7 +1415,7 @@
this.itemStates = { ...this.itemStates,
[item.id]: pickedCount >= plannedCount ? ITEM_STATUS.PICKED : ITEM_STATUS.PARTIAL }
this.recordScanResult('success', '箱码扫描成功', boxCode,
`${item.inboundCode || item.orderNo || '当前明细'} · 已拣 ${pickedCount}/${plannedCount} 箱`)
`当前明细 · 已拣 ${pickedCount}/${plannedCount} 箱`)
this.showToast(`已拣箱 ${pickedCount}/${plannedCount}`, 'success')
this.persistSession()
},
@@ -1518,11 +1513,11 @@
const processed = draft
? Object.values(draft.itemStates).filter((status) => status !== ITEM_STATUS.PENDING).length
: 0
return `${entry.box.orderNo} · 已处理${processed}件 · 待拣${entry.box.items.length - processed}件`
return `已处理${processed}件 · 待拣${entry.box.items.length - processed}件`
}
const picked = entry.box.items.filter((item) => item.finalState === ITEM_STATUS.PICKED).length
const partial = entry.box.items.filter((item) => item.finalState === ITEM_STATUS.PARTIAL).length
return `${entry.box.orderNo} · 拣出${picked}件${partial ? ` · 部分拣${partial}件` : ''}`
return `拣出${picked}件${partial ? ` · 部分拣${partial}件` : ''}`
},
selectBoxFromList(entry) {
if (this.boxListType === 'done' || entry.index === this.currentBoxIndex) return
@@ -1584,7 +1579,7 @@
const operationIdentity = stableTaskOperationIdentity(this.draftIdentity())
const submitId = this.submitId || stableOperationId('submit', operationIdentity, { itemStates: this.itemStates, partialQuantities: this.partialQuantities, incompleteReason })
this.submitId = submitId
const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse
const warehouseId = this.currentBox.warehouseId
const inventorySnapshot = await this.refreshInventorySnapshot()
this.debugSubmit('submit.inventory-snapshot', { snapshotTokenLength: inventorySnapshot?.snapshotToken ? String(inventorySnapshot.snapshotToken).length : 0, taskVersion: inventorySnapshot?.version ?? null })
if (!inventorySnapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试')
@@ -1594,10 +1589,15 @@
if (submittedTask) {
this.remotePick = normalizePickingTask({ ...this.remotePick, ...submittedTask })
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion
this.loadingVersion = submittedTask.inboundVersion ?? submittedTask.loadingVersion ?? this.remotePick.inboundVersion ?? this.remotePick.loadingVersion ?? this.loadingVersion
this.evidenceIdentity = this.captureEvidenceIdentity()
}
// Legacy inbound completePicking(expectedVersion: Number(this.pickingVersion)) remains adapter-only; cabinet tasks use item actions.
this.showToast('拣货已提交,正在刷新最新状态', 'success')
await this.syncRemoteTask()
if (!this.pickingCompleted) {
this.remoteError = this.remoteError || '拣货已提交,但最新任务状态尚未同步,请点击刷新任务'
this.showToast('拣货已提交,状态同步未完成,请刷新任务', 'warning')
return
}
} catch (error) {
this.debugSubmit('submit.error', { message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '', network: Boolean(error.network) })
const versionConflict = this.isTaskConflict(error)
@@ -1607,7 +1607,7 @@
await this.refreshAfterTaskConflict(conflictMessage)
return
}
this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败')
this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : this.remoteFailureMessage(error, '拣货完成失败')
this.showToast('拣货完成未提交,请查看错误提示', 'error')
return
} finally { this.submitting = false }
@@ -1635,15 +1635,10 @@
}
this.openOverlay('complete')
},
nextBox() {
continueToEvidence() {
this.closeOverlay(false)
const nextIndex = this.boxes.findIndex((box, index) => !this.completedBoxes[index])
if (nextIndex < 0) {
this.openOverlay('allComplete')
return
}
this.selectBox(nextIndex)
this.showToast('切换至下一箱', 'success')
this.activeStage = 'evidence'
this.scrollToTop()
},
resetOverlayState() {
this.activeOverlay = ''
+9 -2
View File
@@ -1,7 +1,8 @@
<template>
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">{{ user.role }} · {{ user.id }}</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">08:00–17:00</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">{{ appVersion }}</text></view></view><button class="profile-action" @tap="checkUpdate">检查更新 <text>›</text></button><button class="profile-action" @tap="showSettings">系统设置 <text>›</text></button><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">{{ user.role }} · {{ user.id }}</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">08:00–17:00</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">{{ appVersion }}</text></view><button class="profile-row profile-row-action" @tap="checkUpdate"><text>检查更新</text><text class="profile-row-arrow">›</text></button><button class="profile-row profile-row-action" @tap="showSettings"><text>系统设置</text><text class="profile-row-arrow">›</text></button></view><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
</template>
<script>
import { logout as logoutRequest } from '@/api/auth.js'
import PortalHeader from '@/components/PortalHeader.vue'
import PopupHeader from '@/components/PopupHeader.vue'
import { session, requireSession, signOut } from '@/stores/session.js'
@@ -32,7 +33,13 @@
closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() },
handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) }
logout() {
const refreshToken = session.refreshToken
const accessToken = session.token
signOut()
uni.reLaunch({ url: '/pages/login/login' })
if (refreshToken) logoutRequest(refreshToken, accessToken).catch(() => {})
}
}
}
</script>
+24 -31
View File
@@ -5,24 +5,22 @@
<view class="task-toolbar">
<view class="task-heading">
<text class="task-title">待处理任务</text>
<text class="task-summary">{{ loading ? '正在同步' : `接口返回 ${rawTaskCount} 个,可处理 ${tasks.length} 个` }}</text>
<text class="task-environment">{{ apiEnvironment }}</text>
<text class="task-summary">{{ loading ? '正在同步任务' : `共 ${tasks.length} 个待处理任务` }}</text>
</view>
<button class="task-refresh" :disabled="loading" @tap="loadTasks">{{ loading ? '刷新中' : '刷新' }}</button>
</view>
<view v-if="error" class="task-error" role="alert">{{ error }}</view>
<view v-if="loading" class="task-state">正在加载真实任务...</view>
<view v-else-if="!tasks.length && !error" class="task-state">暂无可领取的拣货任务。请确认预装已补充集装箱号、已关联放舱订舱且预装已确认。</view>
<view v-else-if="!tasks.length && !error" class="task-state">暂无待处理的拣货任务</view>
<view v-if="notice" class="task-notice" role="status">{{ notice }}</view>
<view class="task-list">
<button v-for="task in tasks" :key="task.taskId" class="task-card" :disabled="!isTaskReady(task)" :aria-label="`${taskActionLabel(task)},集装箱号 ${task.currentContainerNo}`" @tap="enterTask(task)">
<button v-for="task in tasks" :key="task.taskId" class="task-card" :disabled="!isTaskReady(task)" :aria-label="`${taskActionLabel(task)},${containerReference(task)}`" @tap="enterTask(task)">
<view class="task-card-head">
<view class="task-container-info"><text class="task-label">集装箱号</text><text class="task-code">{{ task.currentContainerNo }}</text></view>
<text class="task-status" :class="{ progress: task.workflowStatus === 'IN_PROGRESS', evidence: task.workflowStatus === 'AWAITING_REVIEW' }">{{ statusLabel(task.workflowStatus) }}</text>
<view class="task-container-info"><text class="task-code">{{ containerReference(task) }}</text></view>
<text class="task-status" :class="{ progress: task.workflowStatus === 'IN_PROGRESS', evidence: task.workflowStatus === 'AWAITING_REVIEW', processing: task.workflowStatus === 'PROCESSING', failed: task.workflowStatus === 'COMPLETION_FAILED' }">{{ statusLabel(task.workflowStatus) }}</text>
</view>
<view class="task-identifiers"><text class="task-cabinet">柜次号 {{ task.cabinetNo }}</text><text class="task-order">{{ task.displayNo || '未命名订单' }}</text></view>
<view class="task-card-foot">
<view class="task-meta-group"><text class="task-warehouse">{{ task.warehouseName || (task.warehouseId ? `仓库 ${task.warehouseId}` : '缺少仓库信息') }}</text><text class="task-pending">待处理 {{ task.pendingItemCount ?? task.itemCount ?? 0 }} 项</text></view>
<view class="task-meta-group"><text class="task-warehouse">仓库 {{ displayBusinessValue(task.warehouseName, '未关联') }}</text><text class="task-pending">待处理 {{ task.pendingItemCount ?? task.itemCount ?? 0 }} 项</text></view>
<view class="task-enter"><text>{{ taskActionLabel(task) }}</text><text class="task-enter-arrow">›</text></view>
</view>
</button>
@@ -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)}&currentContainerNo=${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; }
+29
View File
@@ -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()
}
+51 -2
View File
@@ -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
}
}
+159 -98
View File
@@ -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; }
+24
View File
@@ -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)
})
+2 -1
View File
@@ -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 中检查更新/)
+60
View File
@@ -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\)/)
})
+21
View File
@@ -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/)
})
+10
View File
@@ -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/)
})
+61
View File
@@ -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, '装柜已完成')
})
+4 -3
View File
@@ -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'/)
+43 -15
View File
@@ -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><text class="task-code">\{\{ 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, /<uni-popup ref="logoutPopup" type="center"[\s\S]*?:is-mask-click="true"[\s\S]*?@change="handleLogoutChange"/)
assert.match(profile, /<PopupHeader title="确认退出登录?"[\s\S]*?@close="closeLogout"/)
assert.match(profile, /class="logout-dialog-confirm"/)
for (const name of ['boxList', 'skip', 'partial', 'switchBoxConfirm', 'submitConfirm', 'complete', 'allComplete']) {
for (const name of ['boxList', 'skip', 'partial', 'switchBoxConfirm', 'submitConfirm', 'complete']) {
assert.match(picking, new RegExp(`<uni-popup ref="${name}Popup"[^>]*:is-mask-click="true"[^>]*@change="handleOverlayChange\\('${name}', \\$event\\)"`))
}
assert.equal((picking.match(/<PopupHeader\b/g) || []).length, 7)
assert.equal((picking.match(/<PopupHeader\b/g) || []).length, 6)
assert.match(picking, /openOverlay\(name\)[\s\S]*?this\.\$refs\[`\$\{name\}Popup`\]/)
assert.match(picking, /closeOverlay\(\)[\s\S]*?popup\.close\(\)/)
assert.match(picking, /handleOverlayChange\(name, event\)[\s\S]*?!event\.show[\s\S]*?this\.resetOverlayState\(\)/)
@@ -264,19 +277,34 @@ test('shared visual contracts preserve accessible PDA targets', async () => {
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"/)
+25
View File
@@ -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;/)
})
+42 -28
View File
@@ -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/)
+21
View File
@@ -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/)
})
+75
View File
@@ -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'/)
})
+86 -53
View File
@@ -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, '装柜完成失败'\)/)
})
+11
View File
@@ -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, /<text v-if="item\.location" class="pick-item-location">\{\{ item\.location \}\}<\/text>/)
assert.match(page, /item\.location \? `\$\{item\.sku\} · \$\{item\.location\}` : item\.sku/)
assert.doesNotMatch(page, /<text class="pick-item-location">\{\{ item\.location \}\}/)
})
+59
View File
@@ -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, /<view v-if="currentUser" class="page-shell">/)
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, '任务同步失败'\)/)
})
+8
View File
@@ -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\)/)
})
+25
View File
@@ -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!/)
})
+22
View File
@@ -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/)
})
+49
View File
@@ -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(/<template>[\s\S]*?<\/template>/)?.[0] || ''
assert.doesNotMatch(template, /\{\{[^}\n]*(?:taskId|inboundId|orderId|loadingPlanId|containerBookingId)[^}\n]*\}\}/)
assert.doesNotMatch(template, /订单号|入仓码|业务编号未关联/)
}
assert.match(pages[0], /formatApiError\(error, '登录失败'\)/)
assert.match(pages[1], /containerReference\(task\)/)
assert.match(pages[2], /containerReference\(currentBox\)/)
assert.match(pages[3], /containerReference\(entry\.box\)/)
})
+15
View File
@@ -0,0 +1,15 @@
export function resolveEvidencePreviewUrl(previewUrl, baseUrl) {
const rawUrl = String(previewUrl || '')
const normalizedBase = String(baseUrl || '').replace(/\/$/, '')
if (!rawUrl || /^https?:\/\//i.test(rawUrl)) return rawUrl
if (/^https?:\/\//i.test(normalizedBase)) {
const origin = normalizedBase.match(/^(https?:\/\/[^/]+)/i)?.[1] || ''
return `${origin}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}`
}
const platformPrefix = '/api/platform/v1'
if (rawUrl === platformPrefix || rawUrl.startsWith(`${platformPrefix}/`)) {
return `${normalizedBase}${rawUrl.slice(platformPrefix.length)}`
}
if (rawUrl.startsWith('/')) return rawUrl
return `${normalizedBase}/${rawUrl}`
}
+63
View File
@@ -0,0 +1,63 @@
export const COMPLETION_PHASE = Object.freeze({
READY: 'READY',
SUBMITTING: 'SUBMITTING',
RECONCILING: 'RECONCILING',
PROCESSING: 'PROCESSING',
FAILED: 'FAILED',
COMPLETED: 'COMPLETED'
})
const PROCESSING_STATUSES = new Set(['COMPLETING', 'PROCESSING'])
const COMPLETED_STATUSES = new Set(['COMPLETED'])
const FAILED_STATUSES = new Set(['COMPLETION_FAILED'])
function normalizedStatus(value) {
return String(value || '').trim().toUpperCase()
}
/**
* Resolve already-normalized task/command facts into a UI phase. DTO field
* extraction deliberately stays outside this module until the backend schema
* is frozen.
*/
export function resolveCompletionPhase({ taskStatus, commandStatus, submitting = false, reconciling = false } = {}) {
if (reconciling) return COMPLETION_PHASE.RECONCILING
if (submitting) return COMPLETION_PHASE.SUBMITTING
const statuses = [taskStatus, commandStatus].map(normalizedStatus)
if (statuses.some((status) => COMPLETED_STATUSES.has(status))) return COMPLETION_PHASE.COMPLETED
if (statuses.some((status) => FAILED_STATUSES.has(status))) return COMPLETION_PHASE.FAILED
if (statuses.some((status) => PROCESSING_STATUSES.has(status))) return COMPLETION_PHASE.PROCESSING
return COMPLETION_PHASE.READY
}
export function completionPresentation(phase, failureStage = '') {
if (phase === COMPLETION_PHASE.SUBMITTING) return { label: '正在提交', action: '', blocked: true }
if (phase === COMPLETION_PHASE.RECONCILING) return { label: '正在确认提交结果', action: '', blocked: true }
if (phase === COMPLETION_PHASE.PROCESSING) return { label: '完成处理中', action: '', blocked: true }
if (phase === COMPLETION_PHASE.COMPLETED) return { label: '装柜已完成', action: '', blocked: true }
if (phase === COMPLETION_PHASE.FAILED) {
return { label: failureStage ? `完成失败:${failureStage}` : '完成失败', action: '重试完成', blocked: true, retryable: true }
}
return { label: '待确认装柜', action: '确认装柜完成', blocked: false }
}
export function shouldReconcileCompletion(error = {}) {
const code = normalizedStatus(error.code || error.errCode)
return Boolean(error.network || error.timeout || code === 'TIMEOUT' || code === 'REQUEST_TIMEOUT')
}
/**
* A retry keeps the original operationId and the exact accepted request facts.
* expectedTaskVersion participates in requestHash and must not be refreshed.
*/
export function refreshCompletionAttempt(previousAttempt = {}, latestFacts = {}) {
if (!previousAttempt.operationId) throw new Error('缺少原完成操作标识,不能安全重试')
const expectedTaskVersion = previousAttempt.expectedTaskVersion
if (expectedTaskVersion === null || expectedTaskVersion === undefined) throw new Error('缺少服务端最新任务版本,不能安全重试')
return {
...previousAttempt,
...latestFacts,
operationId: previousAttempt.operationId,
expectedTaskVersion: Number(expectedTaskVersion)
}
}
+39
View File
@@ -0,0 +1,39 @@
const INTERNAL_ID_FIELD_PATTERN = /(?:taskId|inboundId|orderId|loadingPlanId|containerBookingId|任务ID|入库单ID|订单ID|装柜计划ID|柜次ID)\s*[:=:]?\s*[A-Za-z0-9_-]*/gi
const LONG_INTERNAL_NUMBER_PATTERN = /(^|[^A-Za-z0-9])\d{18,20}(?=$|[^A-Za-z0-9])/g
export function displayBusinessValue(value, missing = '—') {
const text = String(value ?? '').trim()
return text || missing
}
export function containerReference(source = {}) {
const containerNo = [source?.currentContainerNo, source?.containerNo, source?.boxNo]
.map((value) => String(value ?? '').trim())
.find((value) => value && value !== '同步中') || ''
if (containerNo) return `集装箱号 ${containerNo}`
const cabinetNo = String(source?.cabinetNo ?? '').trim()
return cabinetNo ? `柜次号 ${cabinetNo}` : '集装箱号 —'
}
export function sanitizeUserMessage(message, fallback = '操作失败') {
const raw = String(message ?? '').trim()
if (!raw) return fallback
if (/^internal server error$/i.test(raw)) return '服务端处理异常,请联系管理员'
const sanitized = raw
.replace(INTERNAL_ID_FIELD_PATTERN, '内部标识已隐藏')
.replace(LONG_INTERNAL_NUMBER_PATTERN, '$1内部标识已隐藏')
return sanitized || fallback
}
export function formatApiError(error, fallback = '操作失败') {
const message = sanitizeUserMessage(error?.message, fallback)
const code = String(error?.code ?? '').trim()
const requestId = String(error?.requestId ?? '').trim()
const traceId = String(error?.traceId ?? '').trim()
const evidence = [
code ? `错误码:${code}` : '',
requestId ? `requestId:${requestId}` : '',
traceId ? `traceId:${traceId}` : ''
].filter(Boolean)
return evidence.length ? `${message}(${evidence.join(';')})` : message
}