Merge remote-tracking branch 'origin/release/test' into dev
This commit is contained in:
@@ -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` 执行 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 = ''
|
||||
|
||||
@@ -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
@@ -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)}¤tContainerNo=${encodeURIComponent(task.currentContainerNo)}&warehouseId=${encodeURIComponent(task.warehouseId)}&taskId=${encodeURIComponent(task.taskId)}&orderNo=${encodeURIComponent(task.displayNo || '')}` })
|
||||
uni.navigateTo({ url: `/pages/picking/picking?taskId=${encodeURIComponent(task.taskId)}&warehouseId=${encodeURIComponent(task.warehouseId)}` })
|
||||
},
|
||||
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2200) }
|
||||
}
|
||||
@@ -97,7 +93,6 @@
|
||||
.task-heading { display: flex; min-width: 0; flex-direction: column; }
|
||||
.task-title { color: #12233f; font-size: 18px; font-weight: 700; line-height: 25px; }
|
||||
.task-summary { margin-top: 2px; color: #667085; font-size: 11px; line-height: 16px; }
|
||||
.task-environment { margin-top: 1px; color: #98a2b3; font-size: 10px; line-height: 15px; }
|
||||
.task-refresh { flex-shrink: 0; margin: 0; min-width: 76px; height: 44px; border: 0; border-radius: 9px; background: #1a73e8; color: #fff; font-size: 14px; line-height: 44px; }
|
||||
.task-refresh[disabled] { opacity: .6; }
|
||||
.task-error, .task-state { padding: 20px 14px; border-radius: 10px; background: #fff; color: #667085; font-size: 13px; line-height: 20px; text-align: center; }
|
||||
@@ -110,17 +105,15 @@
|
||||
.task-card[disabled] { opacity: .62; }
|
||||
.task-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.task-container-info { display: flex; min-width: 0; flex-direction: column; }
|
||||
.task-label { color: #667085; font-size: 10px; line-height: 15px; }
|
||||
.task-code { overflow: hidden; margin-top: 1px; color: #12233f; font-size: 17px; font-weight: 700; line-height: 23px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-code { margin-top: 1px; color: #12233f; font-size: 17px; font-weight: 700; line-height: 23px; overflow-wrap: anywhere; }
|
||||
.task-status { flex-shrink: 0; padding: 4px 7px; border-radius: 99px; color: #52616f; font-size: 10px; line-height: 16px; background: #eef2f6; }
|
||||
.task-status.progress { color: #155eef; background: #eff4ff; }
|
||||
.task-status.evidence { color: #9a5b13; background: #fff4e5; }
|
||||
.task-identifiers { display: flex; flex-wrap: wrap; gap: 5px 8px; margin-top: 8px; }
|
||||
.task-cabinet, .task-order { padding: 3px 7px; border-radius: 5px; color: #475467; font-size: 11px; line-height: 17px; background: #f2f4f7; }
|
||||
.task-order { overflow: hidden; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-status.processing { color: #155eef; background: #eff4ff; }
|
||||
.task-status.failed { color: #b42318; background: #fff1f0; }
|
||||
.task-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; padding-top: 9px; border-top: 1px solid #edf1f5; }
|
||||
.task-meta-group { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.task-warehouse { overflow: hidden; color: #667085; font-size: 10px; line-height: 15px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-warehouse { color: #667085; font-size: 10px; line-height: 15px; overflow-wrap: anywhere; }
|
||||
.task-pending { color: #344054; font-size: 12px; font-weight: 600; line-height: 17px; }
|
||||
.task-enter { display: flex; min-height: 40px; flex-shrink: 0; align-items: center; gap: 5px; padding-left: 10px; color: #155eef; font-size: 12px; font-weight: 600; }
|
||||
.task-enter-arrow { font-size: 22px; font-weight: 400; line-height: 22px; }
|
||||
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 中检查更新/)
|
||||
|
||||
@@ -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\)/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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, '装柜已完成')
|
||||
})
|
||||
@@ -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'/)
|
||||
|
||||
@@ -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"/)
|
||||
|
||||
@@ -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;/)
|
||||
})
|
||||
@@ -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/)
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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'/)
|
||||
})
|
||||
@@ -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, '装柜完成失败'\)/)
|
||||
})
|
||||
|
||||
@@ -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 \}\}/)
|
||||
})
|
||||
@@ -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, '任务同步失败'\)/)
|
||||
})
|
||||
@@ -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\)/)
|
||||
})
|
||||
|
||||
@@ -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!/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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\)/)
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user