Merge remote-tracking branch 'origin/release/test' into dev
This commit is contained in:
@@ -13,7 +13,7 @@
|
|||||||
- 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。
|
- 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。
|
||||||
- 明确使用的工具链和发布流程后,补充准确的依赖版本、安装、调试、测试和打包命令,并同步更新 `README.md` 与本文件。
|
- 明确使用的工具链和发布流程后,补充准确的依赖版本、安装、调试、测试和打包命令,并同步更新 `README.md` 与本文件。
|
||||||
- 在 `package.json` 与锁文件正式补齐前,`vite.config.js` 只证明目标配置,不证明依赖可安装;维护者只能运行仓库当前可执行的 `node --test tests/*.test.mjs`,不得从海外 PDA 或全局缓存猜测国内 PDA 的 uni-app/Vite 版本。
|
- 在 `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。
|
- 国内 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` | 全局样式和应用生命周期 |
|
| 页面注册与启动 | `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 不执行更新逻辑 |
|
| 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/login/`、`stores/session.js` | 远程鉴权适配和会话清理 |
|
||||||
| 工作台与模块入口 | `pages/home/`、`pages/module/`、`pages/operation/` | `data/demo-data.js` 与页面禁用规则 |
|
| 工作台与模块入口 | `pages/home/`、`pages/module/`、`pages/operation/` | `data/demo-data.js` 与页面禁用规则 |
|
||||||
| 待处理任务、服务端就绪与领取 | `pages/tasks/tasks.vue` | `api/pda-loading.js` 的 `listLoadingOrders`、`getContainerPickingTask`、`claimContainerPickingTask`;合并任务和缺少柜号/仓库/版本的任务不可进入 |
|
| 待处理任务、服务端就绪与领取 | `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/`、`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` |
|
||||||
|
| 装柜取证与完成 | `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/scanner-test/` | `utils/scan-broadcast.js` 与广播 Action/Extra |
|
||||||
| 作业记录与人员 | `pages/history/`、`pages/profile/` | session 与本地敏感数据清理 |
|
| 作业记录与人员 | `pages/history/`、`pages/profile/` | session 与本地敏感数据清理 |
|
||||||
| 页面与真实 API 合同 | `tests/page-contract.test.mjs`、`tests/pda-loading-api.test.mjs` | 覆盖部分拣货页面、提交门禁、库位不足反馈和国内 PDA 真实接口路径;统一由 `node --test tests/*.test.mjs` 执行 |
|
| 页面与真实 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/scanner-test/scanner-test`:Android 硬件扫描广播诊断页;
|
||||||
- `pages/picking/picking`:集装箱拣货完整交互。
|
- `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`。当前草稿尚未持久化到设备,这是实现缺口,不代表允许离线作业。
|
拣货原型会在本次登录会话内按箱保存本地草稿和最近扫码结果。已确认的正式流程是:进入任务即由后端标记“拣货中”;扫码只更新本地草稿,员工点击提交时才将整批明细写入后端,不逐码提交。允许部分拣货或跳拣,但每个未完成项必须填写异常原因,并挂起任务、阻断完成和 `LOADED`。当前草稿尚未持久化到设备,这是实现缺口,不代表允许离线作业。
|
||||||
|
|
||||||
|
|||||||
+12
-3
@@ -1,11 +1,13 @@
|
|||||||
import { API_ROOT } from '@/config/runtime.js'
|
import { API_ROOT } from '@/config/runtime.js'
|
||||||
|
|
||||||
function authRequest(path, data) {
|
function authRequest(path, data, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
uni.request({
|
uni.request({
|
||||||
url: `${API_ROOT}${path}`,
|
url: `${API_ROOT}${path}`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data,
|
data,
|
||||||
|
header: options.header,
|
||||||
|
timeout: options.timeout || 15000,
|
||||||
success(response) {
|
success(response) {
|
||||||
const body = response.data || {}
|
const body = response.data || {}
|
||||||
if (String(body.code ?? response.statusCode) !== '200') {
|
if (String(body.code ?? response.statusCode) !== '200') {
|
||||||
@@ -27,10 +29,17 @@ function authRequest(path, data) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function login(username, password) {
|
export function login(loginId, password) {
|
||||||
return authRequest('/auth/login', { username, password })
|
return authRequest('/auth/login', { loginId, password })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function refresh(refreshToken) {
|
export function refresh(refreshToken) {
|
||||||
return authRequest('/auth/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 }
|
||||||
+62
-148
@@ -1,5 +1,9 @@
|
|||||||
import { session } from '@/stores/session.js'
|
import { session } from '@/stores/session.js'
|
||||||
import { requireOnline } from '@/utils/network.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([
|
const EVIDENCE_TYPES = Object.freeze([
|
||||||
'PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'
|
'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: '带锁码照' }
|
{ type: 'SEAL', label: '带锁码照' }
|
||||||
])
|
])
|
||||||
|
|
||||||
// C02 supplies inboundId/inboundVersion and loading.pickingVersion. C04 supplies
|
// Picking and cabinet evidence are both scoped by the canonical PDA task. The
|
||||||
// inbound-scoped task and evidence operations. No local task is promoted to a
|
// task warehouse is always sent as an authorization boundary and never guessed.
|
||||||
// server success when any required identifier or version is absent.
|
|
||||||
|
|
||||||
function debugLog(event, payload = {}) {
|
function debugLog(event, payload = {}) {
|
||||||
try { console.info(`[PDA] ${event}`, payload) } catch (_) { /* console may be unavailable on a device */ }
|
try { console.info(`[PDA] ${event}`, payload) } catch (_) { /* console may be unavailable on a device */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function request(options) {
|
const request = apiRequest
|
||||||
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)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function stableIdempotencyKey(operationId, suffix = '') {
|
function stableIdempotencyKey(operationId, suffix = '') {
|
||||||
const raw = `pda-${operationId}${suffix ? `-${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)
|
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) {
|
export function isRemoteReady(inboundId) {
|
||||||
return Boolean(session.token && (session.tenantId || 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 ?? '',
|
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,
|
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,
|
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,
|
mergedIntoContainerBookingId: task.mergedIntoContainerBookingId ?? task.container?.mergedIntoContainerBookingId ?? null,
|
||||||
cabinetNo: task.cabinetNo ?? task.loading?.cabinetNo ?? task.containerNo ?? '',
|
cabinetNo: task.cabinetNo ?? task.loading?.cabinetNo ?? task.containerNo ?? '',
|
||||||
currentContainerNo: task.currentContainerNo ?? task.containerNo ?? null,
|
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,
|
pickingVersion: task.pickingVersion ?? task.version ?? null,
|
||||||
inboundVersion: task.inboundVersion ?? null
|
inboundVersion: task.inboundVersion ?? null
|
||||||
}
|
}
|
||||||
@@ -214,61 +179,26 @@ export function normalizeInventorySnapshot(snapshot = {}) {
|
|||||||
return normalizePickingTask({ ...snapshot, version: snapshot.taskVersion ?? snapshot.version })
|
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({
|
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'
|
method: 'GET'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startPicking(inboundId, payload, tenantId) {
|
export function getEvidencePreviewUrl(taskId, evidenceId, warehouseId) {
|
||||||
return request({
|
if (!taskId || !evidenceId || !warehouseId) return ''
|
||||||
url: `/inbounds/${encodeURIComponent(inboundId)}/loading/picking/start?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
|
return `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/content?warehouseId=${encodeURIComponent(warehouseId)}`
|
||||||
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 downloadEvidencePreview(previewUrl) {
|
export function downloadEvidencePreview(previewUrl) {
|
||||||
requireOnline('当前网络不可用,无法查看装柜照片')
|
requireOnline('当前网络不可用,无法查看装柜照片')
|
||||||
if (!previewUrl) throw new Error('装柜照片地址尚未同步')
|
if (!previewUrl) throw new Error('装柜照片地址尚未同步')
|
||||||
const baseUrl = session.apiBase || '/api/platform/v1'
|
const url = resolveEvidencePreviewUrl(previewUrl, 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
|
|
||||||
debugLog('evidence.preview.start', { previewPath: String(previewUrl), resolvedUrl: url })
|
debugLog('evidence.preview.start', { previewPath: String(previewUrl), resolvedUrl: url })
|
||||||
return new Promise((resolve, reject) => {
|
return apiDownload({ url }).then((response) => {
|
||||||
uni.downloadFile({
|
|
||||||
url,
|
|
||||||
header: headers,
|
|
||||||
success(response) {
|
|
||||||
const responseHeaders = response.header || {}
|
const responseHeaders = response.header || {}
|
||||||
const contentType = responseHeaders['Content-Type'] || responseHeaders['content-type'] || ''
|
const contentType = responseHeaders['Content-Type'] || responseHeaders['content-type'] || ''
|
||||||
const requestId = responseHeaders['X-Request-Id'] || responseHeaders['x-request-id'] || ''
|
const requestId = responseHeaders['X-Request-Id'] || responseHeaders['x-request-id'] || ''
|
||||||
@@ -279,8 +209,7 @@ export function downloadEvidencePreview(previewUrl) {
|
|||||||
error.statusCode = response.statusCode
|
error.statusCode = response.statusCode
|
||||||
error.requestId = requestId
|
error.requestId = requestId
|
||||||
error.traceId = traceId
|
error.traceId = traceId
|
||||||
reject(error)
|
throw error
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (contentType && !/^image\//i.test(contentType) && !/^application\/octet-stream/i.test(contentType)) {
|
if (contentType && !/^image\//i.test(contentType) && !/^application\/octet-stream/i.test(contentType)) {
|
||||||
const error = new Error('服务端未返回有效照片')
|
const error = new Error('服务端未返回有效照片')
|
||||||
@@ -288,80 +217,65 @@ export function downloadEvidencePreview(previewUrl) {
|
|||||||
error.contentType = contentType
|
error.contentType = contentType
|
||||||
error.requestId = requestId
|
error.requestId = requestId
|
||||||
error.traceId = traceId
|
error.traceId = traceId
|
||||||
reject(error)
|
throw error
|
||||||
return
|
|
||||||
}
|
}
|
||||||
resolve(response.tempFilePath)
|
return response.tempFilePath
|
||||||
},
|
}).catch((error) => {
|
||||||
fail(error) {
|
debugLog('evidence.preview.error', { previewPath: String(previewUrl), message: error.message || error.errMsg || '' })
|
||||||
debugLog('evidence.preview.error', { previewPath: String(previewUrl), message: error.errMsg || '' })
|
throw error
|
||||||
const next = new Error(error.errMsg || '装柜照片下载失败')
|
|
||||||
next.network = true
|
|
||||||
reject(next)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertEvidence(inboundId, payload, tenantId) {
|
export function voidEvidence(taskId, evidenceId, warehouseId, payload = {}) {
|
||||||
if (!EVIDENCE_TYPES.includes(payload.evidenceType)) throw new Error('不支持的装柜证据类型')
|
if (!taskId || !evidenceId) throw new Error('缺少装柜任务或证据标识,无法作废')
|
||||||
return request({
|
if (!warehouseId) throw new Error('缺少任务仓库标识,无法作废装柜证据')
|
||||||
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('缺少装柜证据标识,无法作废')
|
|
||||||
if (!String(payload.reason || '').trim()) throw new Error('作废原因不能为空')
|
if (!String(payload.reason || '').trim()) throw new Error('作废原因不能为空')
|
||||||
return request({
|
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',
|
method: 'POST',
|
||||||
header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId || `void-evidence-${inboundId}-${evidenceId}-${payload.expectedVersion ?? 0}`) },
|
data: { warehouseId, expectedVersion: payload.expectedVersion ?? 0, reason: String(payload.reason).trim() }
|
||||||
data: payload
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Upload a local photo to the file service and return its durable fileId. */
|
/** 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('当前网络不可用,无法上传装柜照片')
|
requireOnline('当前网络不可用,无法上传装柜照片')
|
||||||
|
if (!taskId) throw new Error('缺少装柜任务标识,无法上传装柜照片')
|
||||||
|
if (!warehouseId) throw new Error('缺少任务仓库标识,无法上传装柜照片')
|
||||||
|
const evidenceType = payload?.evidenceType
|
||||||
if (!EVIDENCE_TYPES.includes(evidenceType)) throw new Error('不支持的装柜证据类型')
|
if (!EVIDENCE_TYPES.includes(evidenceType)) throw new Error('不支持的装柜证据类型')
|
||||||
if (!localPath) 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 baseUrl = session.apiBase || '/api/platform/v1'
|
||||||
const headers = {}
|
debugLog('evidence.upload.start', { taskId, evidenceType, hasLocalPath: Boolean(localPath) })
|
||||||
if (session.token) headers.Authorization = `Bearer ${session.token}`
|
const query = [
|
||||||
if (session.requestId) headers['X-Request-Id'] = session.requestId
|
`warehouseId=${encodeURIComponent(warehouseId)}`,
|
||||||
debugLog('evidence.upload.start', { inboundId, evidenceType, tenantId: tenant, hasLocalPath: Boolean(localPath) })
|
`expectedVersion=${encodeURIComponent(payload.expectedVersion)}`,
|
||||||
return new Promise((resolve, reject) => {
|
`evidenceType=${encodeURIComponent(evidenceType)}`
|
||||||
const task = uni.uploadFile({
|
]
|
||||||
url: `${baseUrl}/files/loading-evidence/${encodeURIComponent(tenant)}/${encodeURIComponent(inboundId)}?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,
|
filePath: localPath,
|
||||||
name: 'file',
|
name: 'file',
|
||||||
header: headers,
|
header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId || `evidence-${taskId}-${evidenceType}-${payload.expectedVersion}`) }
|
||||||
success(response) {
|
}, onProgress).then((response) => {
|
||||||
let body = response.data
|
let body = response.data
|
||||||
try { body = typeof body === 'string' ? JSON.parse(body) : body } catch (_) { /* handled below */ }
|
try { body = typeof body === 'string' ? JSON.parse(body) : body } catch (_) { /* handled below */ }
|
||||||
const code = String(body?.code ?? response.statusCode)
|
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 })
|
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; reject(error); return }
|
if (code !== '200') {
|
||||||
resolve(body?.data || body)
|
const error = new Error(body?.message || '文件上传失败')
|
||||||
},
|
error.code = body?.code || response.statusCode
|
||||||
fail(error) { debugLog('evidence.upload.error', { inboundId, evidenceType, message: error.errMsg || '' }); const next = new Error(error.errMsg || '网络连接失败'); next.network = true; reject(next) }
|
error.statusCode = response.statusCode
|
||||||
})
|
error.requestId = body?.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'] || ''
|
||||||
if (task && typeof task.onProgressUpdate === 'function' && typeof onProgress === 'function') task.onProgressUpdate(({ progress }) => onProgress(progress))
|
error.traceId = body?.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] || ''
|
||||||
})
|
throw error
|
||||||
}
|
}
|
||||||
|
return body?.data || body
|
||||||
export function completeLoading(inboundId, payload, tenantId) {
|
}).catch((error) => { debugLog('evidence.upload.error', { taskId, evidenceType, message: error.message || error.errMsg || '' }); throw error })
|
||||||
return request({
|
|
||||||
url: `/inbounds/${encodeURIComponent(inboundId)}/loading/complete?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
|
|
||||||
method: 'POST',
|
|
||||||
header: { 'Idempotency-Key': idempotencyKey('complete-loading', inboundId) },
|
|
||||||
data: payload
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Complete the cabinet PDA task after the operator has captured all evidence. */
|
/** Complete the cabinet PDA task after the operator has captured all evidence. */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<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>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import PortalHeader from '@/components/PortalHeader.vue'; import { session, requireSession } from '@/stores/session.js'; import { getPickingOverview } from '@/stores/picking.js'
|
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() } }
|
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>
|
</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)">
|
<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>
|
<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 class="home-operation-name">{{ operation.name }}</text>
|
||||||
<text v-if="operation.available" class="home-operation-pending">{{ overview.pendingBoxCount }} 箱待拣</text>
|
<text v-if="operation.available" class="home-operation-meta home-operation-pending">开始作业</text>
|
||||||
<text v-else class="home-operation-unavailable">未开放</text>
|
<text v-else class="home-operation-meta home-operation-unavailable">未开放</text>
|
||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -21,7 +21,6 @@
|
|||||||
import PortalHeader from '@/components/PortalHeader.vue'
|
import PortalHeader from '@/components/PortalHeader.vue'
|
||||||
import { MODULES } from '@/data/demo-data.js'
|
import { MODULES } from '@/data/demo-data.js'
|
||||||
import { session, requireSession } from '@/stores/session.js'
|
import { session, requireSession } from '@/stores/session.js'
|
||||||
import { getPickingOverview } from '@/stores/picking.js'
|
|
||||||
|
|
||||||
const ICON_TYPES = {
|
const ICON_TYPES = {
|
||||||
'到货收货': 'download', '商品上架': 'upload', '入库质检': 'checkbox',
|
'到货收货': 'download', '商品上架': 'upload', '入库质检': 'checkbox',
|
||||||
@@ -39,8 +38,8 @@
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { PortalHeader },
|
components: { PortalHeader },
|
||||||
data() { return { user: session.currentUser, groups: HOME_GROUPS, overview: getPickingOverview(), menuOpen: false } },
|
data() { return { user: session.currentUser, groups: HOME_GROUPS, menuOpen: false } },
|
||||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() },
|
onShow() { if (!requireSession()) return; this.user = session.currentUser },
|
||||||
onBackPress() {
|
onBackPress() {
|
||||||
if (!this.menuOpen) return false
|
if (!this.menuOpen) return false
|
||||||
this.$refs.portalHeader.closeMenu()
|
this.$refs.portalHeader.closeMenu()
|
||||||
|
|||||||
+27
-4
@@ -7,6 +7,7 @@
|
|||||||
<view class="login-card">
|
<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.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-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>
|
<text v-if="error" class="login-error" role="alert">{{ error }}</text>
|
||||||
<button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? '登录中...' : '登录' }}</button>
|
<button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? '登录中...' : '登录' }}</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -15,12 +16,33 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { login as loginRequest } from '@/api/auth.js'
|
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 { requireOnline } from '@/utils/network.js'
|
||||||
|
import { formatApiError } from '@/utils/user-facing.js'
|
||||||
export default {
|
export default {
|
||||||
data() { return { form: { userId: 'platform_admin', password: 'ChangeMe123!' }, error: '', submitting: false } },
|
data() { return { form: { userId: '', password: '' }, rememberAccount: true, error: '', submitting: false } },
|
||||||
onLoad() { if (session.currentUser || restoreSession()) uni.reLaunch({ url: '/pages/home/home' }) },
|
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: {
|
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() {
|
async login() {
|
||||||
const userId = this.form.userId.trim()
|
const userId = this.form.userId.trim()
|
||||||
if (!userId) return void (this.error = '请输入工号')
|
if (!userId) return void (this.error = '请输入工号')
|
||||||
@@ -31,9 +53,10 @@
|
|||||||
requireOnline('当前网络不可用,无法登录')
|
requireOnline('当前网络不可用,无法登录')
|
||||||
const tokens = await loginRequest(userId, this.form.password)
|
const tokens = await loginRequest(userId, this.form.password)
|
||||||
signInWithTokens(tokens)
|
signInWithTokens(tokens)
|
||||||
|
saveLoginPreference(this.rememberAccount, userId)
|
||||||
uni.reLaunch({ url: '/pages/home/home' })
|
uni.reLaunch({ url: '/pages/home/home' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error.network ? '当前网络不可用,请联网后重试' : (error.message || '登录失败')
|
this.error = error.network ? '当前网络不可用,请联网后重试' : formatApiError(error, '登录失败')
|
||||||
} finally {
|
} finally {
|
||||||
this.submitting = false
|
this.submitting = false
|
||||||
}
|
}
|
||||||
|
|||||||
+218
-223
@@ -11,10 +11,10 @@
|
|||||||
</view>
|
</view>
|
||||||
</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">
|
<view class="box-select-wrap">
|
||||||
<button class="box-select-btn" @tap="toggleBoxDropdown">
|
<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>
|
<text class="box-chevron" :class="{ open: boxDropdownOpen }">▼</text>
|
||||||
</button>
|
</button>
|
||||||
<view v-if="boxDropdownOpen" class="box-dropdown">
|
<view v-if="boxDropdownOpen" class="box-dropdown">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
:class="{ active: index === currentBoxIndex, completed: completedBoxes[index] }"
|
:class="{ active: index === currentBoxIndex, completed: completedBoxes[index] }"
|
||||||
@tap="requestBoxSwitch(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-if="completedBoxes[index]" class="box-option-state">已完成</text>
|
||||||
<text v-else-if="hasBoxDraft(index)" class="box-option-state draft">有草稿</text>
|
<text v-else-if="hasBoxDraft(index)" class="box-option-state draft">有草稿</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -33,16 +33,13 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="stage-switch" role="tablist" aria-label="作业阶段">
|
<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 === '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>
|
||||||
<view v-if="!loadingStageReady" class="evidence-stage-hint" role="status">请先处理并提交全部待拣明细;任务增量更新后,已拍证据会按当前柜次保留。</view>
|
|
||||||
|
|
||||||
<view v-if="activeStage === 'picking'" class="picking-stage">
|
<view v-if="activeStage === 'picking'" class="picking-stage">
|
||||||
<view class="scan-section">
|
<view class="scan-section">
|
||||||
<view class="scan-row">
|
<view class="scan-row">
|
||||||
<ScanTargetInput v-model="scanValue" name="boxCode" label="箱码扫码框" placeholder="扫描或输入箱唛标签码" :active="activeScanField === 'boxCode'" @activate="setScanTarget" @submit="handleManualSubmit" />
|
<ScanTargetInput v-model="scanValue" name="boxCode" label="箱码扫码框" placeholder="扫描或输入箱唛标签码" :active="activeScanField === 'boxCode'" @activate="setScanTarget" @submit="handleManualSubmit" />
|
||||||
</view>
|
</view>
|
||||||
<text class="scan-hardware-tip">支持硬件广播扫码,也可点击输入后按“完成”确认</text>
|
|
||||||
<view v-if="lastScanResult" class="last-scan" :class="lastScanResult.type" role="status">
|
<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-icon">{{ lastScanResult.type === 'success' ? '✓' : '!' }}</view>
|
||||||
<view class="last-scan-content">
|
<view class="last-scan-content">
|
||||||
@@ -60,18 +57,8 @@
|
|||||||
<view class="progress-header">
|
<view class="progress-header">
|
||||||
<text class="progress-title">拣货进度</text>
|
<text class="progress-title">拣货进度</text>
|
||||||
<view class="progress-actions">
|
<view class="progress-actions">
|
||||||
<view class="progress-stats">
|
<view class="progress-stats"><text>已处理 </text><text class="progress-number">{{ summary.processed }}</text><text>/{{ summary.total }} 箱</text></view>
|
||||||
<template v-if="summary.partial > 0">
|
<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>
|
||||||
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="progress-bar-bg">
|
<view class="progress-bar-bg">
|
||||||
@@ -91,7 +78,7 @@
|
|||||||
:class="itemState(item)"
|
:class="itemState(item)"
|
||||||
>
|
>
|
||||||
<view class="pick-item-row1">
|
<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">
|
<view class="pick-item-meta">
|
||||||
<text v-if="itemState(item) === ITEM_STATUS.PICKED" class="pick-status-tag tag-picked">已拣 ✓</text>
|
<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">
|
<text v-else-if="itemState(item) === ITEM_STATUS.PARTIAL" class="pick-status-tag tag-partial">
|
||||||
@@ -108,7 +95,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="pick-item-row2">
|
<view class="pick-item-row2">
|
||||||
<text class="pick-item-desc">{{ itemDescription(item) }}</text>
|
<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>
|
||||||
|
|
||||||
<view v-if="itemState(item) === ITEM_STATUS.PENDING" class="pick-item-actions">
|
<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-partial" @tap="openPartial(item.id)">部分拣货</button>
|
||||||
<button class="pick-btn pick-btn-skip" @tap="openSkip(item.id)">跳拣</button>
|
<button class="pick-btn pick-btn-skip" @tap="openSkip(item.id)">跳拣</button>
|
||||||
</view>
|
</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>
|
<button class="pick-btn pick-btn-undo" @tap="undoItem(item.id)">撤销</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -124,17 +111,20 @@
|
|||||||
</view>
|
</view>
|
||||||
</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 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>
|
<text class="sync-state" :class="remoteSyncState.tone">{{ remoteSyncState.label }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="evidence-grid">
|
<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-header">
|
||||||
|
<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">
|
<view class="evidence-card-title">
|
||||||
<text class="evidence-label">{{ slot.label }}</text>
|
<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>
|
<text class="evidence-count" :class="{ complete: evidenceEntries(slot.type).length }">{{ evidenceEntries(slot.type).length ? `已拍 ${evidenceEntries(slot.type).length} 张` : '待拍摄' }}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<button class="evidence-btn evidence-add-btn" @tap="chooseEvidence(slot)">{{ evidenceUploadLabel(slot.type) }}</button>
|
<button class="evidence-btn evidence-add-btn" @tap="chooseEvidence(slot)">{{ evidenceUploadLabel(slot.type) }}</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -146,24 +136,22 @@
|
|||||||
<button v-if="photo.id && photo.status !== 'VOID' && photo.status !== 'VOIDED'" class="evidence-void-btn" @tap="voidEvidencePhoto(photo)">作废</button>
|
<button v-if="photo.id && photo.status !== 'VOID' && photo.status !== 'VOIDED'" class="evidence-void-btn" @tap="voidEvidencePhoto(photo)">作废</button>
|
||||||
</view>
|
</view>
|
||||||
</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()">
|
<text v-if="evidenceQueueEntry(slot.type)" class="evidence-upload-state" :class="evidenceQueueEntry(slot.type).status.toLowerCase()">
|
||||||
{{ evidenceUploadState(slot.type) }}
|
{{ evidenceUploadState(slot.type) }}
|
||||||
</text>
|
</text>
|
||||||
<text v-else-if="hasUnboundUploadedEvidence(slot.type)" class="evidence-upload-state pending">
|
<text v-else-if="hasPendingLocalEvidence(slot.type)" class="evidence-upload-state pending">
|
||||||
{{ slot.type === 'SEAL' ? '照片已上传,请输入封签号并保存' : '照片已上传,等待保存' }}
|
{{ slot.type === 'SEAL' ? '请填写封签号,保存后上传' : '照片待自动上传' }}
|
||||||
</text>
|
</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>
|
<button v-if="evidenceQueueEntry(slot.type)?.status === 'FAILED'" class="evidence-retry-btn" @tap="retryEvidence(slot.type)">重试上传</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="seal-row">
|
<view class="seal-row">
|
||||||
<input v-model="sealNo" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
|
<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>
|
||||||
<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>
|
<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>
|
<button class="loading-complete-btn" :disabled="submitting || !loadingStageReady || pickingVersion === null || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button>
|
||||||
<view v-if="loadingVersion === null" class="remote-error-row">
|
<view v-if="pickingVersion === null" class="remote-error-row">
|
||||||
<text class="version-hint" role="status">状态尚未同步,请刷新重试</text>
|
<text class="version-hint" role="status">状态尚未同步,请刷新重试</text>
|
||||||
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
|
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -201,7 +189,7 @@
|
|||||||
@tap="selectBoxFromList(entry)"
|
@tap="selectBoxFromList(entry)"
|
||||||
>
|
>
|
||||||
<view class="box-list-info">
|
<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>
|
<text class="box-list-detail">{{ boxListDetail(entry) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="box-list-tag" :class="boxListType === 'done' ? 'box-list-tag-done' : 'box-list-tag-pending'">
|
<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>
|
<view class="modal-sheet partial-modal" role="dialog" aria-modal="true" aria-label="部分拣货" @tap.stop>
|
||||||
<PopupHeader title="部分拣货" @close="closeOverlay" />
|
<PopupHeader title="部分拣货" @close="closeOverlay" />
|
||||||
<view v-if="partialTargetItem" class="partial-info">
|
<view v-if="partialTargetItem" class="partial-info">
|
||||||
<text class="partial-info-sku">{{ partialTargetItem.sku }}</text>
|
<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>
|
||||||
<text class="partial-info-desc">{{ partialTargetItem.desc }}</text>
|
<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 class="partial-info-qty">需拣总量:<text>{{ partialTargetItem.qty }}</text> {{ partialTargetItem.unit }}</view>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="partial-input-row">
|
<view class="partial-field">
|
||||||
<text class="partial-input-label">实际拣出</text>
|
<text class="partial-input-label">实际拣出数量</text>
|
||||||
<input
|
<view class="partial-input-control"><input
|
||||||
v-model="partialInput"
|
v-model="partialInput"
|
||||||
class="partial-input"
|
class="partial-input"
|
||||||
aria-label="实际拣出数量"
|
aria-label="实际拣出数量"
|
||||||
type="number"
|
type="number"
|
||||||
|
placeholder="请输入数量"
|
||||||
|
placeholder-class="partial-input-placeholder"
|
||||||
:focus="partialFocused"
|
:focus="partialFocused"
|
||||||
confirm-type="done"
|
confirm-type="done"
|
||||||
@confirm="confirmPartial"
|
@confirm="confirmPartial"
|
||||||
/>
|
/><text class="partial-input-unit">{{ partialTargetItem ? partialTargetItem.unit : '' }}</text></view>
|
||||||
<text class="partial-input-unit">{{ partialTargetItem ? partialTargetItem.unit : '' }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="partial-quick-row">
|
<view class="partial-quick-row">
|
||||||
<button
|
<button
|
||||||
v-for="option in partialQuickOptions"
|
v-for="option in partialQuickOptions"
|
||||||
:key="option.label"
|
:key="option.label"
|
||||||
class="partial-quick-btn"
|
class="partial-quick-btn"
|
||||||
|
:class="{ selected: String(partialInput) === String(option.value) }"
|
||||||
@tap="setPartialInput(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>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
<text v-if="partialValidationMessage" class="partial-validation" role="alert">{{ partialValidationMessage }}</text>
|
<text v-if="partialValidationMessage" class="partial-validation" role="alert">{{ partialValidationMessage }}</text>
|
||||||
@@ -291,7 +280,7 @@
|
|||||||
<PopupHeader :title="`仍有 ${summary.pending} 件未完成`" tone="danger" @close="closeOverlay" />
|
<PopupHeader :title="`仍有 ${summary.pending} 件未完成`" tone="danger" @close="closeOverlay" />
|
||||||
<text class="decision-desc submit-decision-desc">确认提交后,以下待拣商品会被明确标记为“未拣出”,本次演示提交不可撤销。</text>
|
<text class="decision-desc submit-decision-desc">确认提交后,以下待拣商品会被明确标记为“未拣出”,本次演示提交不可撤销。</text>
|
||||||
<view class="pending-sku-list">
|
<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>
|
<text v-if="pendingItems.length > 3">另有 {{ pendingItems.length - 3 }} 件</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="decision-label">选择未完成原因</text>
|
<text class="decision-label">选择未完成原因</text>
|
||||||
@@ -309,7 +298,11 @@
|
|||||||
<view class="complete-modal" role="dialog" aria-modal="true" aria-label="拣货完成" @tap.stop>
|
<view class="complete-modal" role="dialog" aria-modal="true" aria-label="拣货完成" @tap.stop>
|
||||||
<PopupHeader title="拣货完成" @close="closeOverlay" />
|
<PopupHeader title="拣货完成" @close="closeOverlay" />
|
||||||
<view class="complete-icon">✓</view>
|
<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-stats">
|
||||||
<view class="complete-stat">
|
<view class="complete-stat">
|
||||||
<text class="complete-stat-num green">{{ completionSummary.picked }}</text>
|
<text class="complete-stat-num green">{{ completionSummary.picked }}</text>
|
||||||
@@ -330,20 +323,11 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="complete-btns">
|
<view class="complete-btns">
|
||||||
<button class="complete-btn-back" @tap="closeOverlay">返回查看</button>
|
<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>
|
||||||
</view>
|
</view>
|
||||||
</uni-popup>
|
</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 v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -352,15 +336,17 @@
|
|||||||
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates } from '@/utils/picking-state.js'
|
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates } from '@/utils/picking-state.js'
|
||||||
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
|
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
|
||||||
import { session, requireSession } from '@/stores/session.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 { navigateBackOr } from '@/utils/navigation.js'
|
||||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.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 { createEvidenceUploadQueue } from '@/utils/evidence-upload-queue.js'
|
||||||
import { isOnline } from '@/utils/network.js'
|
import { isOnline } from '@/utils/network.js'
|
||||||
import { stableOperationId } from '@/utils/idempotency.js'
|
import { stableOperationId } from '@/utils/idempotency.js'
|
||||||
import { stableTaskOperationIdentity } from '@/utils/task-identity.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 ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||||
import PopupHeader from '@/components/PopupHeader.vue'
|
import PopupHeader from '@/components/PopupHeader.vue'
|
||||||
|
|
||||||
@@ -370,7 +356,11 @@
|
|||||||
const workflowStatus = task?.workflowStatus
|
const workflowStatus = task?.workflowStatus
|
||||||
const taskStatus = task?.taskStatus ?? task?.status
|
const taskStatus = task?.taskStatus ?? task?.status
|
||||||
return workflowStatus === 'AWAITING_REVIEW'
|
return workflowStatus === 'AWAITING_REVIEW'
|
||||||
|
|| workflowStatus === 'PROCESSING'
|
||||||
|
|| workflowStatus === 'COMPLETION_FAILED'
|
||||||
|| workflowStatus === 'COMPLETED'
|
|| workflowStatus === 'COMPLETED'
|
||||||
|
|| taskStatus === 'COMPLETING'
|
||||||
|
|| taskStatus === 'COMPLETION_FAILED'
|
||||||
|| taskStatus === 'COMPLETED'
|
|| taskStatus === 'COMPLETED'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +387,7 @@
|
|||||||
selectedIncompleteReason: '',
|
selectedIncompleteReason: '',
|
||||||
completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 },
|
completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 },
|
||||||
evidenceSlots: EVIDENCE_SLOTS,
|
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: '',
|
remoteTaskId: null, assignmentVersion: null, submitId: '',
|
||||||
taskRequestId: 0,
|
taskRequestId: 0,
|
||||||
remoteError: '', remoteLoading: false, submitting: false,
|
remoteError: '', remoteLoading: false, submitting: false,
|
||||||
@@ -406,7 +396,7 @@
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
currentBox() {
|
currentBox() {
|
||||||
return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', items: [] }
|
return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', inboundCode: '', items: [] }
|
||||||
},
|
},
|
||||||
summary() {
|
summary() {
|
||||||
return getPickSummary(this.currentBox.items, this.itemStates, this.partialQuantities)
|
return getPickSummary(this.currentBox.items, this.itemStates, this.partialQuantities)
|
||||||
@@ -442,16 +432,16 @@
|
|||||||
},
|
},
|
||||||
pickingOperationReady() {
|
pickingOperationReady() {
|
||||||
return this.remotePick?.workflowStatus === 'IN_PROGRESS'
|
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
|
&& this.pickingVersion !== null
|
||||||
},
|
},
|
||||||
loadingStageReady() {
|
loadingStageReady() {
|
||||||
return this.pickingCompleted
|
return this.pickingCompleted
|
||||||
&& this.evidenceIdentity
|
&& this.evidenceIdentity
|
||||||
&& this.evidenceIdentity.inboundId
|
&& this.evidenceIdentity.taskId
|
||||||
&& this.loadingVersion !== null
|
&& this.evidenceIdentity.warehouseId
|
||||||
&& this.loadingVersion !== undefined
|
&& this.pickingVersion !== null
|
||||||
&& Number.isFinite(Number(this.loadingVersion))
|
&& Number.isFinite(Number(this.pickingVersion))
|
||||||
},
|
},
|
||||||
partialTargetItem() {
|
partialTargetItem() {
|
||||||
return this.currentBox.items.find((item) => item.id === this.partialTargetId) || null
|
return this.currentBox.items.find((item) => item.id === this.partialTargetId) || null
|
||||||
@@ -490,8 +480,13 @@
|
|||||||
if (this.summary.pending > 0) return '提交时需选择原因'
|
if (this.summary.pending > 0) return '提交时需选择原因'
|
||||||
return `已处理 ${this.summary.processed}/${this.summary.total}`
|
return `已处理 ${this.summary.processed}/${this.summary.total}`
|
||||||
},
|
},
|
||||||
nextBoxButtonText() {
|
evidenceCapturedTypeCount() {
|
||||||
return this.completedCount === this.boxes.length ? '查看全部完成' : '下一箱'
|
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() {
|
visibleBoxList() {
|
||||||
return this.boxes
|
return this.boxes
|
||||||
@@ -501,13 +496,16 @@
|
|||||||
: !this.completedBoxes[entry.index])
|
: !this.completedBoxes[entry.index])
|
||||||
},
|
},
|
||||||
remoteSyncState() {
|
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.remoteLoading) return { label: '同步中', tone: 'loading' }
|
||||||
if (this.remoteError) return { label: '同步失败', tone: 'error' }
|
if (this.remoteError) return { label: '同步失败', tone: 'error' }
|
||||||
return { label: this.remotePick ? '已同步' : '待同步', tone: this.remotePick ? 'ready' : 'local' }
|
return { label: this.remotePick ? '已同步' : '待同步', tone: this.remotePick ? 'ready' : 'local' }
|
||||||
},
|
},
|
||||||
evidenceComplete() {
|
evidenceComplete() {
|
||||||
return this.evidenceSlots.every((slot) => this.validEvidenceEntries(slot.type).length > 0) && Boolean(String(this.sealNo || '').trim())
|
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() {
|
created() {
|
||||||
@@ -515,17 +513,17 @@
|
|||||||
},
|
},
|
||||||
onLoad(options = {}) {
|
onLoad(options = {}) {
|
||||||
if (!requireSession()) return
|
if (!requireSession()) return
|
||||||
|
this.currentUser = session.currentUser
|
||||||
if (options.taskId) {
|
if (options.taskId) {
|
||||||
// A local completion marker belongs to the previously opened task. The
|
// A local completion marker belongs to the previously opened task. The
|
||||||
// canonical server detail decides whether this task is complete.
|
// canonical server detail decides whether this task is complete.
|
||||||
this.completedBoxes = {}
|
this.completedBoxes = {}
|
||||||
this.boxes = [{
|
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,
|
taskId: String(options.taskId), loadingPlanId: null, containerBookingId: null,
|
||||||
inboundId: options.inboundId ? String(options.inboundId) : null,
|
inboundId: null, inboundVersion: null,
|
||||||
inboundVersion: options.inboundVersion == null ? null : Number(options.inboundVersion),
|
|
||||||
warehouseId: options.warehouseId ? String(options.warehouseId) : null,
|
warehouseId: options.warehouseId ? String(options.warehouseId) : null,
|
||||||
tenantId: options.tenantId ? String(options.tenantId) : null, items: []
|
tenantId: null, items: []
|
||||||
}]
|
}]
|
||||||
this.currentBoxIndex = 0
|
this.currentBoxIndex = 0
|
||||||
this.remoteTaskId = options.taskId ? String(options.taskId) : null
|
this.remoteTaskId = options.taskId ? String(options.taskId) : null
|
||||||
@@ -533,6 +531,7 @@
|
|||||||
},
|
},
|
||||||
async onShow() {
|
async onShow() {
|
||||||
if (!requireSession()) return
|
if (!requireSession()) return
|
||||||
|
this.currentUser = session.currentUser
|
||||||
this.startHardwareScanner()
|
this.startHardwareScanner()
|
||||||
if (this.cameraPicking) {
|
if (this.cameraPicking) {
|
||||||
this.debugEvidence('choose.resume', { evidenceIdentity: this.evidenceIdentity })
|
this.debugEvidence('choose.resume', { evidenceIdentity: this.evidenceIdentity })
|
||||||
@@ -562,6 +561,7 @@
|
|||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
containerReference,
|
||||||
isSelectableTask(task) {
|
isSelectableTask(task) {
|
||||||
return Boolean(task?.recordStatus === 'ACTIVE'
|
return Boolean(task?.recordStatus === 'ACTIVE'
|
||||||
&& !task?.mergedIntoContainerBookingId
|
&& !task?.mergedIntoContainerBookingId
|
||||||
@@ -576,10 +576,11 @@
|
|||||||
},
|
},
|
||||||
boxFromTask(task, existing = null) {
|
boxFromTask(task, existing = null) {
|
||||||
return {
|
return {
|
||||||
boxNo: String(task.currentContainerNo || existing?.boxNo || task.cabinetNo || '同步中'),
|
boxNo: String(task.currentContainerNo || existing?.boxNo || ''),
|
||||||
cabinetNo: task.cabinetNo ? String(task.cabinetNo) : (existing?.cabinetNo || null),
|
cabinetNo: task.cabinetNo ? String(task.cabinetNo) : (existing?.cabinetNo || null),
|
||||||
currentContainerNo: task.currentContainerNo ? String(task.currentContainerNo) : (existing?.currentContainerNo || 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),
|
taskId: String(task.taskId),
|
||||||
loadingPlanId: task.loadingPlanId || existing?.loadingPlanId || null,
|
loadingPlanId: task.loadingPlanId || existing?.loadingPlanId || null,
|
||||||
containerBookingId: task.containerBookingId || existing?.containerBookingId || null,
|
containerBookingId: task.containerBookingId || existing?.containerBookingId || null,
|
||||||
@@ -624,10 +625,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectStage(stage) {
|
selectStage(stage) {
|
||||||
if (stage === 'evidence' && !this.loadingStageReady) {
|
|
||||||
this.showToast('请先完成新增待拣明细', 'warning')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.activeStage = stage
|
this.activeStage = stage
|
||||||
},
|
},
|
||||||
debugEvidence(event, payload = {}) {
|
debugEvidence(event, payload = {}) {
|
||||||
@@ -638,10 +635,9 @@
|
|||||||
console.info('[PDA] picking.submit', {
|
console.info('[PDA] picking.submit', {
|
||||||
event,
|
event,
|
||||||
taskId: this.remoteTaskId,
|
taskId: this.remoteTaskId,
|
||||||
inboundId: this.currentBox.inboundId || this.evidenceIdentity?.inboundId || null,
|
|
||||||
loadingPlanId: this.currentBox.loadingPlanId,
|
loadingPlanId: this.currentBox.loadingPlanId,
|
||||||
containerBookingId: this.currentBox.containerBookingId,
|
containerBookingId: this.currentBox.containerBookingId,
|
||||||
warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse,
|
warehouseId: this.currentBox.warehouseId || null,
|
||||||
pickingVersion: this.pickingVersion,
|
pickingVersion: this.pickingVersion,
|
||||||
pending: this.summary.pending,
|
pending: this.summary.pending,
|
||||||
...payload
|
...payload
|
||||||
@@ -651,6 +647,9 @@
|
|||||||
isTaskConflict(error) {
|
isTaskConflict(error) {
|
||||||
return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409
|
return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409
|
||||||
},
|
},
|
||||||
|
remoteFailureMessage(error, fallback = '操作失败') {
|
||||||
|
return formatApiError(error, fallback)
|
||||||
|
},
|
||||||
async refreshAfterTaskConflict(message) {
|
async refreshAfterTaskConflict(message) {
|
||||||
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
|
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
|
||||||
this.submitId = ''
|
this.submitId = ''
|
||||||
@@ -664,7 +663,7 @@
|
|||||||
if (String(this.remoteTaskId || '') === String(requestedTaskId || '') && !this.remoteError) this.remoteError = message
|
if (String(this.remoteTaskId || '') === String(requestedTaskId || '') && !this.remoteError) this.remoteError = message
|
||||||
},
|
},
|
||||||
async refreshInventorySnapshot() {
|
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)
|
const snapshot = await getPickingInventorySnapshot(this.remoteTaskId, warehouseId)
|
||||||
if (!snapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试')
|
if (!snapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试')
|
||||||
if (this.pickingVersion !== null && String(snapshot.version) !== String(this.pickingVersion)) {
|
if (this.pickingVersion !== null && String(snapshot.version) !== String(this.pickingVersion)) {
|
||||||
@@ -675,15 +674,11 @@
|
|||||||
return snapshot
|
return snapshot
|
||||||
},
|
},
|
||||||
captureEvidenceIdentity() {
|
captureEvidenceIdentity() {
|
||||||
return {
|
return { taskId: this.remoteTaskId || this.currentBox.taskId || null, warehouseId: this.currentBox.warehouseId || null }
|
||||||
taskId: this.remoteTaskId || this.currentBox.taskId || null,
|
|
||||||
inboundId: this.currentBox.inboundId || null,
|
|
||||||
loadingPlanId: this.currentBox.loadingPlanId || null,
|
|
||||||
containerBookingId: this.currentBox.containerBookingId || null
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
isSameEvidenceIdentity(left, right) {
|
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) {
|
isMergedContainerRecord(task) {
|
||||||
return task?.recordStatus === 'MERGED' || Boolean(task?.mergedIntoContainerBookingId)
|
return task?.recordStatus === 'MERGED' || Boolean(task?.mergedIntoContainerBookingId)
|
||||||
@@ -698,12 +693,7 @@
|
|||||||
warehouseId: previousIdentity?.warehouseId ?? this.currentBox.warehouseId
|
warehouseId: previousIdentity?.warehouseId ?? this.currentBox.warehouseId
|
||||||
}
|
}
|
||||||
clearPickingDraft(staleIdentity)
|
clearPickingDraft(staleIdentity)
|
||||||
const evidenceKey = this.evidenceDraftKey({
|
const evidenceKey = this.evidenceDraftKey({ taskId: staleIdentity.taskId, warehouseId: staleIdentity.warehouseId })
|
||||||
taskId: staleIdentity.taskId,
|
|
||||||
inboundId: previousIdentity?.inboundId ?? task?.inboundId,
|
|
||||||
loadingPlanId: staleIdentity.loadingPlanId,
|
|
||||||
containerBookingId: staleIdentity.containerBookingId
|
|
||||||
})
|
|
||||||
this.evidenceDrafts = Object.fromEntries(Object.entries(this.evidenceDrafts).filter(([key]) => key !== evidenceKey))
|
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.evidencePreviewCache = Object.fromEntries(Object.entries(this.evidencePreviewCache).filter(([key]) => !key.startsWith(`${evidenceKey}|`)))
|
||||||
this.taskRequestId += 1
|
this.taskRequestId += 1
|
||||||
@@ -713,7 +703,6 @@
|
|||||||
this.remoteTaskId = null
|
this.remoteTaskId = null
|
||||||
this.remotePick = null
|
this.remotePick = null
|
||||||
this.pickingVersion = null
|
this.pickingVersion = null
|
||||||
this.loadingVersion = null
|
|
||||||
this.assignmentVersion = null
|
this.assignmentVersion = null
|
||||||
this.itemStates = {}
|
this.itemStates = {}
|
||||||
this.partialQuantities = {}
|
this.partialQuantities = {}
|
||||||
@@ -736,11 +725,11 @@
|
|||||||
&& this.isSameEvidenceIdentity(this.evidenceIdentity, identity)
|
&& this.isSameEvidenceIdentity(this.evidenceIdentity, identity)
|
||||||
},
|
},
|
||||||
evidenceDraftKey(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) {
|
rememberEvidenceDraft(identity = this.evidenceIdentity) {
|
||||||
const key = this.evidenceDraftKey(identity)
|
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))
|
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 || '' } }
|
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))
|
this.evidenceDrafts = Object.keys(files).length || draft.sealNo ? { ...this.evidenceDrafts, [key]: { ...draft, files } } : Object.fromEntries(Object.entries(this.evidenceDrafts).filter(([draftKey]) => draftKey !== key))
|
||||||
},
|
},
|
||||||
requireEvidenceCapture() {
|
requireEvidenceCapture() {
|
||||||
if (!this.evidenceIdentity?.inboundId) {
|
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) {
|
||||||
this.showToast('任务状态尚未同步,请刷新重试', 'warning')
|
this.showToast('任务状态尚未同步,请刷新重试', 'warning')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
requireEvidenceMutation() {
|
||||||
|
if (!this.requireEvidenceCapture()) return false
|
||||||
|
if (!isRemoteReady(this.evidenceIdentity.taskId)) {
|
||||||
|
this.showToast('当前会话未连接服务端,证据保留本地', 'warning')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
requirePickingOperation() {
|
requirePickingOperation() {
|
||||||
if (this.pickingOperationReady) return true
|
if (this.pickingOperationReady) return true
|
||||||
this.showToast('任务尚未领取完成,请稍候或刷新重试', 'warning')
|
this.showToast('任务尚未领取完成,请稍候或刷新重试', 'warning')
|
||||||
@@ -789,8 +786,9 @@
|
|||||||
this.evidenceFiles = {}
|
this.evidenceFiles = {}
|
||||||
this.evidenceQueueItems = []
|
this.evidenceQueueItems = []
|
||||||
this.sealNo = ''
|
this.sealNo = ''
|
||||||
|
this.sealSaving = false
|
||||||
this.evidenceQueue = createEvidenceUploadQueue({
|
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 }
|
onChange: (items) => { if (this.evidenceContextId === contextId) this.evidenceQueueItems = items }
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -804,7 +802,7 @@
|
|||||||
},
|
},
|
||||||
draftIdentity() {
|
draftIdentity() {
|
||||||
const operatorId = session.currentUser?.id || session.currentUser?.userId || session.currentUser?.accountId
|
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() {
|
restoreRemoteDraft() {
|
||||||
const restored = restorePickingSession(this.draftIdentity())
|
const restored = restorePickingSession(this.draftIdentity())
|
||||||
@@ -935,7 +933,6 @@
|
|||||||
this.remoteTaskId = this.currentBox.taskId || null
|
this.remoteTaskId = this.currentBox.taskId || null
|
||||||
this.remotePick = null
|
this.remotePick = null
|
||||||
this.pickingVersion = null
|
this.pickingVersion = null
|
||||||
this.loadingVersion = null
|
|
||||||
this.assignmentVersion = null
|
this.assignmentVersion = null
|
||||||
this.resetEvidenceState()
|
this.resetEvidenceState()
|
||||||
this.boxDropdownOpen = false
|
this.boxDropdownOpen = false
|
||||||
@@ -1012,13 +1009,22 @@
|
|||||||
uni.previewImage({ current, urls, fail: () => { this.evidencePreviewOpening = false } })
|
uni.previewImage({ current, urls, fail: () => { this.evidencePreviewOpening = false } })
|
||||||
},
|
},
|
||||||
evidenceItems(result) {
|
evidenceItems(result) {
|
||||||
if (Array.isArray(result?.evidences)) return result.evidences
|
const items = Array.isArray(result?.evidences) ? result.evidences : (Array.isArray(result?.evidences?.evidences) ? result.evidences.evidences : [])
|
||||||
return 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') },
|
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 },
|
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) },
|
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) {
|
evidenceQueueEntry(type) {
|
||||||
return [...this.evidenceQueueItems].reverse().find((item) => item.evidenceType === type) || null
|
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 === 'UPLOADING') return `上传中 ${item.progress || 0}%`
|
||||||
if (item.status === 'FAILED') return item.error || '上传失败'
|
if (item.status === 'FAILED') return item.error || '上传失败'
|
||||||
if (item.status === 'SUCCEEDED') {
|
if (item.status === 'SUCCEEDED') {
|
||||||
if (type === 'SEAL' && this.hasUnboundUploadedEvidence(type)) return '照片已上传,请输入封签号并保存'
|
|
||||||
return this.validEvidenceEntries(type).length ? '已上传并保存' : (item.attempts > 1 ? '重试成功' : '已上传')
|
return this.validEvidenceEntries(type).length ? '已上传并保存' : (item.attempts > 1 ? '重试成功' : '已上传')
|
||||||
}
|
}
|
||||||
if (item.status === 'REPLACED') return '已替换'
|
if (item.status === 'REPLACED') return '已替换'
|
||||||
@@ -1053,13 +1058,12 @@
|
|||||||
this.currentBox.inboundId = null
|
this.currentBox.inboundId = null
|
||||||
this.currentBox.loadingPlanId = null
|
this.currentBox.loadingPlanId = null
|
||||||
this.currentBox.containerBookingId = null
|
this.currentBox.containerBookingId = null
|
||||||
this.loadingVersion = null
|
|
||||||
this.resetEvidenceState()
|
this.resetEvidenceState()
|
||||||
this.debugSubmit('refresh.start', { requestId, selector })
|
this.debugSubmit('refresh.start', { requestId, selector })
|
||||||
this.remoteLoading = true
|
this.remoteLoading = true
|
||||||
this.remoteError = ''
|
this.remoteError = ''
|
||||||
try {
|
try {
|
||||||
const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse
|
const warehouseId = this.currentBox.warehouseId
|
||||||
const task = await getContainerPickingTask(selector, warehouseId)
|
const task = await getContainerPickingTask(selector, warehouseId)
|
||||||
if (requestId !== this.taskRequestId) return
|
if (requestId !== this.taskRequestId) return
|
||||||
if (!task) throw new Error('服务端未返回当前拣货任务')
|
if (!task) throw new Error('服务端未返回当前拣货任务')
|
||||||
@@ -1071,7 +1075,7 @@
|
|||||||
if (task.recordStatus !== 'ACTIVE') throw new Error('当前柜记录尚未就绪,请返回任务列表刷新')
|
if (task.recordStatus !== 'ACTIVE') throw new Error('当前柜记录尚未就绪,请返回任务列表刷新')
|
||||||
const detailTaskId = task.taskId ?? task.id
|
const detailTaskId = task.taskId ?? task.id
|
||||||
if (String(detailTaskId || '') !== String(requestedTaskId)) throw new Error('服务端返回的拣货任务与当前选择不一致,请返回刷新')
|
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.version === null || task.version === undefined) throw new Error('任务状态尚未同步,请刷新重试')
|
||||||
if (!task.currentContainerNo) throw new Error('任务尚未同步集装箱号,请返回任务列表刷新')
|
if (!task.currentContainerNo) throw new Error('任务尚未同步集装箱号,请返回任务列表刷新')
|
||||||
const remoteItems = task.items || task.pickItems || task.details || task.lines
|
const remoteItems = task.items || task.pickItems || task.details || task.lines
|
||||||
@@ -1096,11 +1100,8 @@
|
|||||||
skipReason: item.skipReason || '', finalState: item.finalState || '', finalPartialQuantity: item.finalPartialQuantity || 0
|
skipReason: item.skipReason || '', finalState: item.finalState || '', finalPartialQuantity: item.finalPartialQuantity || 0
|
||||||
}))
|
}))
|
||||||
const firstItem = this.currentBox.items[0]
|
const firstItem = this.currentBox.items[0]
|
||||||
const evidenceItem = this.currentBox.items.find((item) => String(item.inboundId || '') === String(task.inboundId))
|
this.currentBox.orderNo = firstItem.orderNo || task.orderNo || this.currentBox.orderNo
|
||||||
if (!evidenceItem) throw new Error('任务明细与入库单关联不一致,请返回刷新')
|
this.currentBox.inboundCode = firstItem.inboundCode || task.inboundCode || this.currentBox.inboundCode
|
||||||
this.currentBox.orderNo = firstItem.orderNo || firstItem.inboundCode || this.currentBox.orderNo
|
|
||||||
this.currentBox.inboundId = task.inboundId
|
|
||||||
this.currentBox.tenantId = evidenceItem.tenantId || null
|
|
||||||
const serverStates = createItemStates(this.currentBox.items)
|
const serverStates = createItemStates(this.currentBox.items)
|
||||||
const serverPartialQuantities = {}
|
const serverPartialQuantities = {}
|
||||||
this.currentBox.items.forEach((item) => {
|
this.currentBox.items.forEach((item) => {
|
||||||
@@ -1138,21 +1139,19 @@
|
|||||||
this.remotePick = normalizePickingTask({ ...task, ...(claimed || {}) })
|
this.remotePick = normalizePickingTask({ ...task, ...(claimed || {}) })
|
||||||
if (remoteTask.workflowStatus === 'UNASSIGNED' && this.remotePick.workflowStatus !== 'IN_PROGRESS') throw new Error('任务领取尚未完成,请刷新重试')
|
if (remoteTask.workflowStatus === 'UNASSIGNED' && this.remotePick.workflowStatus !== 'IN_PROGRESS') throw new Error('任务领取尚未完成,请刷新重试')
|
||||||
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? null
|
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? null
|
||||||
this.loadingVersion = this.remotePick.inboundVersion ?? this.remotePick.loadingVersion ?? null
|
|
||||||
this.evidenceIdentity = this.captureEvidenceIdentity()
|
this.evidenceIdentity = this.captureEvidenceIdentity()
|
||||||
this.restoreEvidenceDraft()
|
this.restoreEvidenceDraft()
|
||||||
if (this.isSameEvidenceIdentity(previousEvidenceState.identity, this.evidenceIdentity)) {
|
if (this.isSameEvidenceIdentity(previousEvidenceState.identity, this.evidenceIdentity)) {
|
||||||
this.evidenceFiles = { ...this.evidenceFiles, ...previousEvidenceState.files }
|
this.evidenceFiles = { ...this.evidenceFiles, ...previousEvidenceState.files }
|
||||||
if (previousEvidenceState.sealNo) this.sealNo = previousEvidenceState.sealNo
|
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
|
this.assignmentVersion = this.remotePick.assignment?.version ?? this.remotePick.assignmentVersion ?? this.assignmentVersion
|
||||||
if (!this.pickingCompleted) this.restoreRemoteDraft()
|
if (!this.pickingCompleted) this.restoreRemoteDraft()
|
||||||
if (!this.pickingCompleted) {
|
if (!this.pickingCompleted) {
|
||||||
const completedBoxes = { ...this.completedBoxes }
|
const completedBoxes = { ...this.completedBoxes }
|
||||||
delete completedBoxes[this.currentBoxIndex]
|
delete completedBoxes[this.currentBoxIndex]
|
||||||
this.completedBoxes = completedBoxes
|
this.completedBoxes = completedBoxes
|
||||||
this.activeStage = 'picking'
|
|
||||||
this.completionSummary = { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 }
|
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))
|
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 })
|
this.debugSubmit('refresh.incremental', { previousTaskVersion, taskVersion: this.pickingVersion, addedItemIds, pickingCompleted: this.pickingCompleted })
|
||||||
if (addedItemIds.length) this.showToast(`已同步 ${addedItemIds.length} 条新增待拣明细`, 'warning')
|
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) {
|
} catch (error) {
|
||||||
if (requestId !== this.taskRequestId) return
|
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.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 }
|
} finally { if (requestId === this.taskRequestId) this.remoteLoading = false }
|
||||||
},
|
},
|
||||||
async loadLoadingEvidence() {
|
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 contextId = this.evidenceContextId
|
||||||
const identity = { ...this.evidenceIdentity }
|
const identity = { ...this.evidenceIdentity }
|
||||||
try {
|
try {
|
||||||
const evidence = await getEvidence(identity.inboundId, this.currentBox.tenantId)
|
const evidence = await getEvidence(identity.taskId, identity.warehouseId)
|
||||||
if (!this.isEvidenceContextActive(contextId, identity)) return
|
if (!this.isEvidenceContextActive(contextId, identity)) return
|
||||||
const remoteItems = this.evidenceItems(evidence).map((item) => ({
|
const remoteItems = this.evidenceItems(evidence).map((item) => ({
|
||||||
...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 })
|
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)]))
|
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()
|
await this.flushPendingEvidence()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this.isEvidenceContextActive(contextId, identity)) return
|
if (!this.isEvidenceContextActive(contextId, identity)) return
|
||||||
this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : (error.message || '装柜证据加载失败')
|
this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : this.remoteFailureMessage(error, '装柜证据加载失败')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async loadEvidencePreviews(contextId, identity) {
|
async loadEvidencePreviews(contextId, identity) {
|
||||||
@@ -1247,8 +1244,11 @@
|
|||||||
const next = { evidenceType: slot.type, fileName: path.split('/').pop() || path, localPath: path, uploadStatus: 'PENDING' }
|
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.evidenceFiles = { ...this.evidenceFiles, [slot.type]: [...(this.evidenceFiles[slot.type] || []), next] }
|
||||||
this.rememberEvidenceDraft(identity)
|
this.rememberEvidenceDraft(identity)
|
||||||
if (this.loadingStageReady) this.queueEvidenceUpload(slot.type, path)
|
if (slot.type === 'SEAL') {
|
||||||
else this.showToast('照片已暂存,装柜状态同步后自动上传', 'success')
|
this.showToast('照片已拍摄,请输入封签号并保存上传', 'success')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.queueEvidenceUpload(slot.type, path)
|
||||||
}, fail: (error) => {
|
}, fail: (error) => {
|
||||||
this.cameraPicking = false
|
this.cameraPicking = false
|
||||||
if (!this.isEvidenceContextActive(contextId, identity)) return
|
if (!this.isEvidenceContextActive(contextId, identity)) return
|
||||||
@@ -1256,41 +1256,45 @@
|
|||||||
this.showToast('未选择照片,可稍后重试', 'warning')
|
this.showToast('未选择照片,可稍后重试', 'warning')
|
||||||
} })
|
} })
|
||||||
},
|
},
|
||||||
async queueEvidenceUpload(type, path) {
|
applyEvidenceResponse(result, type, localPath, identity) {
|
||||||
if (!this.loadingStageReady) return
|
const remoteItems = this.evidenceItems(result)
|
||||||
if (!this.evidenceIdentity?.inboundId || !isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return }
|
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 contextId = this.evidenceContextId
|
||||||
const identity = { ...this.evidenceIdentity }
|
const identity = { ...this.evidenceIdentity }
|
||||||
const queue = this.evidenceQueue
|
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 })
|
this.debugSubmit('evidence.queue.start', { evidenceType: type, queueId: entry.id, evidenceIdentity: identity })
|
||||||
await queue.flush()
|
await queue.flush()
|
||||||
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
|
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
|
||||||
const latest = queue.snapshot().find((item) => item.id === entry.id)
|
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 || '' })
|
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 }
|
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
|
||||||
const entries = this.evidenceFiles[type] || []
|
this.applyEvidenceResponse(latest.result, type, path, identity)
|
||||||
const current = entries.find((item) => item.localPath === path) || {}
|
this.showToast(type === 'SEAL' ? '带锁码照上传成功,封签号已保存' : '照片已上传并保存', 'success')
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
async flushPendingEvidence() {
|
async flushPendingEvidence() {
|
||||||
if (!this.loadingStageReady) return
|
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, fileId: entry.fileId })))
|
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) {
|
for (const item of pending) {
|
||||||
if (!this.loadingStageReady) return
|
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
|
||||||
if (item.fileId && item.type !== 'SEAL') await this.saveEvidence(item.type, null, item.path)
|
if (item.type === 'SEAL' && !String(this.sealNo || '').trim()) continue
|
||||||
else if (item.fileId) continue
|
await this.queueEvidenceUpload(item.type, item.path, item.type === 'SEAL' ? this.sealNo : null)
|
||||||
else await this.queueEvidenceUpload(item.type, item.path)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async retryEvidence(type) {
|
async retryEvidence(type) {
|
||||||
if (!this.requireEvidenceStage()) return
|
if (!this.requireEvidenceCapture()) return
|
||||||
const item = this.evidenceQueueEntry(type)
|
const item = this.evidenceQueueEntry(type)
|
||||||
const contextId = this.evidenceContextId
|
const contextId = this.evidenceContextId
|
||||||
const identity = { ...this.evidenceIdentity }
|
const identity = { ...this.evidenceIdentity }
|
||||||
@@ -1301,111 +1305,102 @@
|
|||||||
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
|
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
|
||||||
const latest = queue.latest(type)
|
const latest = queue.latest(type)
|
||||||
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
|
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
|
||||||
const current = this.evidenceEntry(type) || {}
|
this.applyEvidenceResponse(latest.result, type, item.localPath, identity)
|
||||||
this.evidenceFiles = { ...this.evidenceFiles, [type]: [...(this.evidenceFiles[type] || []).slice(0, -1), { ...current, fileId: latest.result?.fileId, fileName: latest.result?.fileName || current.fileName, uploadStatus: 'SUCCEEDED' }] }
|
this.showToast(type === 'SEAL' ? '带锁码照上传成功,封签号已保存' : '照片重试上传成功', 'success')
|
||||||
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')
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async saveSealNo() {
|
async saveSealNo() {
|
||||||
|
if (this.sealSaving) return
|
||||||
if (!String(this.sealNo || '').trim()) { this.showToast('请输入封签号', 'warning'); return }
|
if (!String(this.sealNo || '').trim()) { this.showToast('请输入封签号', 'warning'); return }
|
||||||
this.rememberEvidenceDraft()
|
this.rememberEvidenceDraft()
|
||||||
if (!this.loadingStageReady) { this.showToast('封签号已暂存,装柜状态同步后自动保存', 'success'); return }
|
const current = [...this.evidenceEntries('SEAL')].reverse().find((item) => item.localPath && !item.id)
|
||||||
await this.saveEvidence('SEAL', `seal-no-${this.evidenceIdentity?.taskId || this.evidenceIdentity?.inboundId}-${Date.now()}`)
|
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) {
|
async voidEvidencePhoto(photo) {
|
||||||
if (!this.requireEvidenceStage()) return
|
if (!this.requireEvidenceMutation()) return
|
||||||
if (!photo?.id || !this.evidenceIdentity?.inboundId) return
|
if (!photo?.id || !this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
|
||||||
const contextId = this.evidenceContextId
|
const contextId = this.evidenceContextId
|
||||||
const identity = { ...this.evidenceIdentity }
|
const identity = { ...this.evidenceIdentity }
|
||||||
const inboundId = identity.inboundId
|
|
||||||
const tenantId = this.currentBox.tenantId
|
|
||||||
const reason = 'PDA现场标记错图'
|
const reason = 'PDA现场标记错图'
|
||||||
try {
|
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
|
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.evidenceFiles = { ...this.evidenceFiles, [photo.evidenceType]: (this.evidenceFiles[photo.evidenceType] || []).map((item) => item.id === photo.id ? { ...item, status: 'VOID', voidReason: reason } : item) }
|
||||||
this.showToast('照片已作废,可重新拍摄', 'success')
|
this.showToast('照片已作废,可重新拍摄', 'success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this.isEvidenceContextActive(contextId, identity)) return
|
if (!this.isEvidenceContextActive(contextId, identity)) return
|
||||||
if (this.isTaskConflict(error)) {
|
if (this.isTaskConflict(error)) {
|
||||||
await this.refreshAfterTaskConflict(error.message || '任务内容已更新,请刷新后重试')
|
await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '任务内容已更新,请刷新后重试'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.remoteError = error.network ? '网络失败,照片未作废,请重试' : (error.message || '照片作废失败')
|
this.remoteError = error.network ? '网络失败,照片未作废,请重试' : this.remoteFailureMessage(error, '照片作废失败')
|
||||||
this.showToast('照片作废失败', 'error')
|
this.showToast('照片作废失败', 'error')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async completeLoadingTask() {
|
async completeLoadingTask() {
|
||||||
if (!this.requireEvidenceStage()) return
|
if (!this.requireEvidenceStage()) return
|
||||||
if (!this.evidenceIdentity?.inboundId || !isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('装柜任务尚未同步,请刷新后重试', 'warning'); return }
|
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) { this.showToast('装柜任务或仓库尚未同步,请刷新后重试', 'warning'); return }
|
||||||
if (this.pickingVersion === null || this.loadingVersion === null) { this.showToast(VERSION_SYNC_MESSAGE, 'warning'); return }
|
if (this.pickingVersion === null) { this.showToast(VERSION_SYNC_MESSAGE, 'warning'); return }
|
||||||
if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return }
|
if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return }
|
||||||
const contextId = this.evidenceContextId
|
const contextId = this.evidenceContextId
|
||||||
const identity = { ...this.evidenceIdentity }
|
const identity = { ...this.evidenceIdentity }
|
||||||
const expectedTaskVersion = Number(this.pickingVersion)
|
const serverAttempt = this.remotePick?.completionCommand
|
||||||
const operationIdentity = stableTaskOperationIdentity(this.draftIdentity())
|
const savedAttempt = restoreLoadingCompletionAttempt(this.draftIdentity()) || (serverAttempt?.operationId ? {
|
||||||
const operationId = stableOperationId('complete', operationIdentity, {
|
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,
|
taskId: this.remoteTaskId,
|
||||||
expectedTaskVersion,
|
expectedTaskVersion,
|
||||||
loadingPlanId: identity.loadingPlanId,
|
loadingPlanId: completionIdentity.loadingPlanId,
|
||||||
containerBookingId: identity.containerBookingId
|
containerBookingId: completionIdentity.containerBookingId
|
||||||
})
|
})
|
||||||
|
const completionAttempt = { operationId, expectedTaskVersion, remark: savedAttempt?.remark || 'PDA确认装柜完成' }
|
||||||
|
saveLoadingCompletionAttempt(this.draftIdentity(), completionAttempt)
|
||||||
this.submitting = true
|
this.submitting = true
|
||||||
try {
|
try {
|
||||||
const completedTask = await completeContainerPickingTask(this.remoteTaskId, {
|
const completedTask = await completeContainerPickingTask(this.remoteTaskId, {
|
||||||
expectedTaskVersion,
|
expectedTaskVersion,
|
||||||
operationId,
|
operationId,
|
||||||
remark: 'PDA确认装柜完成'
|
remark: completionAttempt.remark
|
||||||
})
|
})
|
||||||
if (!this.isEvidenceContextActive(contextId, identity)) return
|
if (!this.isEvidenceContextActive(contextId, identity)) return
|
||||||
if (completedTask) {
|
if (completedTask) {
|
||||||
this.remotePick = normalizePickingTask({ ...this.remotePick, ...completedTask })
|
this.remotePick = normalizePickingTask({ ...this.remotePick, ...completedTask })
|
||||||
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion
|
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion
|
||||||
}
|
}
|
||||||
this.showToast('已确认装柜完成', 'success')
|
const phase = resolveCompletionPhase({ taskStatus: completedTask?.taskStatus, commandStatus: completedTask?.commandStatus })
|
||||||
} catch (error) {
|
if (phase === COMPLETION_PHASE.PROCESSING) {
|
||||||
if (!this.isEvidenceContextActive(contextId, identity)) return
|
this.showToast('完成命令已受理,后台处理中', 'success')
|
||||||
if (this.isTaskConflict(error)) {
|
this.persistSession()
|
||||||
await this.refreshAfterTaskConflict(error.message || '订舱、装柜关联或证据状态已变化,请刷新后重试')
|
navigateBackOr('/pages/tasks/tasks')
|
||||||
return
|
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')
|
this.showToast('装柜完成失败', 'error')
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -1420,7 +1415,7 @@
|
|||||||
this.itemStates = { ...this.itemStates,
|
this.itemStates = { ...this.itemStates,
|
||||||
[item.id]: pickedCount >= plannedCount ? ITEM_STATUS.PICKED : ITEM_STATUS.PARTIAL }
|
[item.id]: pickedCount >= plannedCount ? ITEM_STATUS.PICKED : ITEM_STATUS.PARTIAL }
|
||||||
this.recordScanResult('success', '箱码扫描成功', boxCode,
|
this.recordScanResult('success', '箱码扫描成功', boxCode,
|
||||||
`${item.inboundCode || item.orderNo || '当前明细'} · 已拣 ${pickedCount}/${plannedCount} 箱`)
|
`当前明细 · 已拣 ${pickedCount}/${plannedCount} 箱`)
|
||||||
this.showToast(`已拣箱 ${pickedCount}/${plannedCount}`, 'success')
|
this.showToast(`已拣箱 ${pickedCount}/${plannedCount}`, 'success')
|
||||||
this.persistSession()
|
this.persistSession()
|
||||||
},
|
},
|
||||||
@@ -1518,11 +1513,11 @@
|
|||||||
const processed = draft
|
const processed = draft
|
||||||
? Object.values(draft.itemStates).filter((status) => status !== ITEM_STATUS.PENDING).length
|
? Object.values(draft.itemStates).filter((status) => status !== ITEM_STATUS.PENDING).length
|
||||||
: 0
|
: 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 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
|
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) {
|
selectBoxFromList(entry) {
|
||||||
if (this.boxListType === 'done' || entry.index === this.currentBoxIndex) return
|
if (this.boxListType === 'done' || entry.index === this.currentBoxIndex) return
|
||||||
@@ -1584,7 +1579,7 @@
|
|||||||
const operationIdentity = stableTaskOperationIdentity(this.draftIdentity())
|
const operationIdentity = stableTaskOperationIdentity(this.draftIdentity())
|
||||||
const submitId = this.submitId || stableOperationId('submit', operationIdentity, { itemStates: this.itemStates, partialQuantities: this.partialQuantities, incompleteReason })
|
const submitId = this.submitId || stableOperationId('submit', operationIdentity, { itemStates: this.itemStates, partialQuantities: this.partialQuantities, incompleteReason })
|
||||||
this.submitId = submitId
|
this.submitId = submitId
|
||||||
const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse
|
const warehouseId = this.currentBox.warehouseId
|
||||||
const inventorySnapshot = await this.refreshInventorySnapshot()
|
const inventorySnapshot = await this.refreshInventorySnapshot()
|
||||||
this.debugSubmit('submit.inventory-snapshot', { snapshotTokenLength: inventorySnapshot?.snapshotToken ? String(inventorySnapshot.snapshotToken).length : 0, taskVersion: inventorySnapshot?.version ?? null })
|
this.debugSubmit('submit.inventory-snapshot', { snapshotTokenLength: inventorySnapshot?.snapshotToken ? String(inventorySnapshot.snapshotToken).length : 0, taskVersion: inventorySnapshot?.version ?? null })
|
||||||
if (!inventorySnapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试')
|
if (!inventorySnapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试')
|
||||||
@@ -1594,10 +1589,15 @@
|
|||||||
if (submittedTask) {
|
if (submittedTask) {
|
||||||
this.remotePick = normalizePickingTask({ ...this.remotePick, ...submittedTask })
|
this.remotePick = normalizePickingTask({ ...this.remotePick, ...submittedTask })
|
||||||
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion
|
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()
|
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) {
|
} 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) })
|
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)
|
const versionConflict = this.isTaskConflict(error)
|
||||||
@@ -1607,7 +1607,7 @@
|
|||||||
await this.refreshAfterTaskConflict(conflictMessage)
|
await this.refreshAfterTaskConflict(conflictMessage)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败')
|
this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : this.remoteFailureMessage(error, '拣货完成失败')
|
||||||
this.showToast('拣货完成未提交,请查看错误提示', 'error')
|
this.showToast('拣货完成未提交,请查看错误提示', 'error')
|
||||||
return
|
return
|
||||||
} finally { this.submitting = false }
|
} finally { this.submitting = false }
|
||||||
@@ -1635,15 +1635,10 @@
|
|||||||
}
|
}
|
||||||
this.openOverlay('complete')
|
this.openOverlay('complete')
|
||||||
},
|
},
|
||||||
nextBox() {
|
continueToEvidence() {
|
||||||
this.closeOverlay(false)
|
this.closeOverlay(false)
|
||||||
const nextIndex = this.boxes.findIndex((box, index) => !this.completedBoxes[index])
|
this.activeStage = 'evidence'
|
||||||
if (nextIndex < 0) {
|
this.scrollToTop()
|
||||||
this.openOverlay('allComplete')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.selectBox(nextIndex)
|
|
||||||
this.showToast('切换至下一箱', 'success')
|
|
||||||
},
|
},
|
||||||
resetOverlayState() {
|
resetOverlayState() {
|
||||||
this.activeOverlay = ''
|
this.activeOverlay = ''
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<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>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
|
import { logout as logoutRequest } from '@/api/auth.js'
|
||||||
import PortalHeader from '@/components/PortalHeader.vue'
|
import PortalHeader from '@/components/PortalHeader.vue'
|
||||||
import PopupHeader from '@/components/PopupHeader.vue'
|
import PopupHeader from '@/components/PopupHeader.vue'
|
||||||
import { session, requireSession, signOut } from '@/stores/session.js'
|
import { session, requireSession, signOut } from '@/stores/session.js'
|
||||||
@@ -32,7 +33,13 @@
|
|||||||
closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() },
|
closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() },
|
||||||
handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
|
handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
|
||||||
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
|
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>
|
</script>
|
||||||
|
|||||||
+24
-31
@@ -5,24 +5,22 @@
|
|||||||
<view class="task-toolbar">
|
<view class="task-toolbar">
|
||||||
<view class="task-heading">
|
<view class="task-heading">
|
||||||
<text class="task-title">待处理任务</text>
|
<text class="task-title">待处理任务</text>
|
||||||
<text class="task-summary">{{ loading ? '正在同步' : `接口返回 ${rawTaskCount} 个,可处理 ${tasks.length} 个` }}</text>
|
<text class="task-summary">{{ loading ? '正在同步任务' : `共 ${tasks.length} 个待处理任务` }}</text>
|
||||||
<text class="task-environment">{{ apiEnvironment }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
<button class="task-refresh" :disabled="loading" @tap="loadTasks">{{ loading ? '刷新中' : '刷新' }}</button>
|
<button class="task-refresh" :disabled="loading" @tap="loadTasks">{{ loading ? '刷新中' : '刷新' }}</button>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="error" class="task-error" role="alert">{{ error }}</view>
|
<view v-if="error" class="task-error" role="alert">{{ error }}</view>
|
||||||
<view v-if="loading" class="task-state">正在加载真实任务...</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 v-if="notice" class="task-notice" role="status">{{ notice }}</view>
|
||||||
<view class="task-list">
|
<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-card-head">
|
||||||
<view class="task-container-info"><text class="task-label">集装箱号</text><text class="task-code">{{ task.currentContainerNo }}</text></view>
|
<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' }">{{ statusLabel(task.workflowStatus) }}</text>
|
<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>
|
||||||
<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-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 class="task-enter"><text>{{ taskActionLabel(task) }}</text><text class="task-enter-arrow">›</text></view>
|
||||||
</view>
|
</view>
|
||||||
</button>
|
</button>
|
||||||
@@ -38,27 +36,26 @@
|
|||||||
import { session, requireSession } from '@/stores/session.js'
|
import { session, requireSession } from '@/stores/session.js'
|
||||||
import { clearPickingDraft } from '@/stores/picking.js'
|
import { clearPickingDraft } from '@/stores/picking.js'
|
||||||
import { isOnline, watchNetwork } from '@/utils/network.js'
|
import { isOnline, watchNetwork } from '@/utils/network.js'
|
||||||
|
import { containerReference, displayBusinessValue, formatApiError } from '@/utils/user-facing.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { PortalHeader },
|
components: { PortalHeader },
|
||||||
data() { return { user: session.currentUser, tasks: [], rawTaskCount: 0, loading: false, error: '', notice: '', online: true, toast: { visible: false, message: '', type: '' } } },
|
data() { return { user: session.currentUser, tasks: [], 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 || '未知'}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLoad() { if (!requireSession()) return; this._stopNetwork = watchNetwork((value) => { this.online = value }) },
|
onLoad() { if (!requireSession()) return; this._stopNetwork = watchNetwork((value) => { this.online = value }) },
|
||||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() },
|
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() },
|
||||||
onUnload() { if (this._stopNetwork) this._stopNetwork(); clearTimeout(this._toastTimer) },
|
onUnload() { if (this._stopNetwork) this._stopNetwork(); clearTimeout(this._toastTimer) },
|
||||||
methods: {
|
methods: {
|
||||||
|
displayBusinessValue,
|
||||||
|
containerReference,
|
||||||
statusLabel(status) {
|
statusLabel(status) {
|
||||||
|
if (status === 'PROCESSING') return '完成处理中'
|
||||||
|
if (status === 'COMPLETION_FAILED') return '完成失败'
|
||||||
if (status === 'AWAITING_REVIEW') return '待上传证据/确认装柜'
|
if (status === 'AWAITING_REVIEW') return '待上传证据/确认装柜'
|
||||||
return status === 'IN_PROGRESS' ? '拣货中' : '待领取'
|
return status === 'IN_PROGRESS' ? '拣货中' : '待领取'
|
||||||
},
|
},
|
||||||
taskActionLabel(task) {
|
taskActionLabel(task) {
|
||||||
if (!this.isTaskReady(task)) return '任务暂不可进入'
|
if (!this.isTaskReady(task)) return '任务暂不可进入'
|
||||||
|
if (task.workflowStatus === 'COMPLETION_FAILED') return '重试完成'
|
||||||
return task.workflowStatus === 'AWAITING_REVIEW' ? '继续装柜取证' : '进入并开始拣货'
|
return task.workflowStatus === 'AWAITING_REVIEW' ? '继续装柜取证' : '进入并开始拣货'
|
||||||
},
|
},
|
||||||
isMergedTask(task) { return task?.recordStatus === 'MERGED' || Boolean(task?.mergedIntoContainerBookingId) },
|
isMergedTask(task) { return task?.recordStatus === 'MERGED' || Boolean(task?.mergedIntoContainerBookingId) },
|
||||||
@@ -66,25 +63,24 @@
|
|||||||
const operatorId = session.currentUser?.id || session.currentUser?.userId || session.currentUser?.accountId
|
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 })
|
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() {
|
async loadTasks() {
|
||||||
if (!isOnline()) { this.error = '当前网络不可用,请联网后刷新'; return }
|
if (!isOnline()) { this.error = '当前网络不可用,请联网后刷新'; return }
|
||||||
this.loading = true; this.error = ''; this.notice = ''; this.rawTaskCount = 0
|
this.loading = true; this.error = ''; this.notice = ''
|
||||||
try {
|
try {
|
||||||
const result = await listLoadingOrders({ page: 1, size: 50 })
|
const result = await listLoadingOrders({ page: 1, size: 50 })
|
||||||
const records = result.records || []
|
const records = result.records || []
|
||||||
this.rawTaskCount = records.length
|
|
||||||
records.filter((task) => this.isMergedTask(task)).forEach((task) => this.clearMergedTaskDraft(task))
|
records.filter((task) => this.isMergedTask(task)).forEach((task) => this.clearMergedTaskDraft(task))
|
||||||
this.tasks = records.filter((task) => this.isTaskReady(task))
|
this.tasks = records.filter((task) => this.isTaskVisible(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 = '部分任务暂不可作业,请稍后刷新'
|
||||||
if (records.length > this.tasks.length) this.notice = '部分任务尚未完成服务端就绪校验,本机已隐藏,请稍后刷新'
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error.network ? '当前网络不可用,请联网后刷新' : (error.message || '任务加载失败')
|
this.error = error.network ? '当前网络不可用,请联网后刷新' : formatApiError(error, '任务加载失败')
|
||||||
} finally { this.loading = false }
|
} finally { this.loading = false }
|
||||||
},
|
},
|
||||||
enterTask(task) {
|
enterTask(task) {
|
||||||
if (!this.isTaskReady(task)) { this.showToast('任务尚未满足后端进入条件', 'error'); return }
|
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) }
|
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-heading { display: flex; min-width: 0; flex-direction: column; }
|
||||||
.task-title { color: #12233f; font-size: 18px; font-weight: 700; line-height: 25px; }
|
.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-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 { 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-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; }
|
.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[disabled] { opacity: .62; }
|
||||||
.task-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
.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-container-info { display: flex; min-width: 0; flex-direction: column; }
|
||||||
.task-label { color: #667085; font-size: 10px; line-height: 15px; }
|
.task-code { margin-top: 1px; color: #12233f; font-size: 17px; font-weight: 700; line-height: 23px; overflow-wrap: anywhere; }
|
||||||
.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-status { flex-shrink: 0; padding: 4px 7px; border-radius: 99px; color: #52616f; font-size: 10px; line-height: 16px; background: #eef2f6; }
|
.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.progress { color: #155eef; background: #eff4ff; }
|
||||||
.task-status.evidence { color: #9a5b13; background: #fff4e5; }
|
.task-status.evidence { color: #9a5b13; background: #fff4e5; }
|
||||||
.task-identifiers { display: flex; flex-wrap: wrap; gap: 5px 8px; margin-top: 8px; }
|
.task-status.processing { color: #155eef; background: #eff4ff; }
|
||||||
.task-cabinet, .task-order { padding: 3px 7px; border-radius: 5px; color: #475467; font-size: 11px; line-height: 17px; background: #f2f4f7; }
|
.task-status.failed { color: #b42318; background: #fff1f0; }
|
||||||
.task-order { overflow: hidden; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.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-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-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-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 { 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; }
|
.task-enter-arrow { font-size: 22px; font-weight: 400; line-height: 22px; }
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function createInitialState() {
|
|||||||
let state = createInitialState()
|
let state = createInitialState()
|
||||||
|
|
||||||
const STORAGE_PREFIX = 'pda-picking-draft'
|
const STORAGE_PREFIX = 'pda-picking-draft'
|
||||||
|
const COMPLETION_PREFIX = 'pda-loading-completion'
|
||||||
|
|
||||||
function storageAvailable() {
|
function storageAvailable() {
|
||||||
return typeof uni !== 'undefined' && typeof uni.getStorageSync === 'function' && typeof uni.setStorageSync === 'function'
|
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)
|
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() {
|
export function resetPickingSession() {
|
||||||
state = createInitialState()
|
state = createInitialState()
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-2
@@ -3,6 +3,7 @@ import { resetPickingSession } from '@/stores/picking.js'
|
|||||||
import { PLATFORM_API_ROOT } from '@/config/runtime.js'
|
import { PLATFORM_API_ROOT } from '@/config/runtime.js'
|
||||||
|
|
||||||
const SESSION_KEY = 'pda-auth-session'
|
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 })
|
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() {
|
function storageAvailable() {
|
||||||
return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function'
|
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() {
|
function persistSession() {
|
||||||
if (!storageAvailable()) return
|
if (!storageAvailable()) return
|
||||||
uni.setStorageSync(SESSION_KEY, {
|
uni.setStorageSync(SESSION_KEY, {
|
||||||
@@ -52,6 +77,7 @@ export function signIn(account, warehouse, options = {}) {
|
|||||||
|
|
||||||
export function signInWithTokens(tokens) {
|
export function signInWithTokens(tokens) {
|
||||||
if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken')
|
if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken')
|
||||||
|
if (!tokens?.refreshToken) throw new Error('登录响应缺少 refreshToken')
|
||||||
const claims = decodeAccessToken(tokens.accessToken)
|
const claims = decodeAccessToken(tokens.accessToken)
|
||||||
if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端')
|
if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端')
|
||||||
if (claims.exp && claims.exp * 1000 <= Date.now()) 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() {
|
export function restoreSession() {
|
||||||
if (!storageAvailable()) return false
|
if (!storageAvailable()) return false
|
||||||
const saved = uni.getStorageSync(SESSION_KEY)
|
const saved = uni.getStorageSync(SESSION_KEY)
|
||||||
if (!saved?.token || !saved?.currentUser) return false
|
if (!saved?.token || !saved?.currentUser) return false
|
||||||
try {
|
try {
|
||||||
const claims = decodeAccessToken(saved.token)
|
const claims = decodeAccessToken(saved.token)
|
||||||
if (claims.exp && claims.exp * 1000 <= Date.now()) return false
|
if (claims.app_code !== 'PLATFORM') throw new Error('登录域无效')
|
||||||
Object.assign(session, saved, { apiBase: PLATFORM_API_ROOT })
|
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
|
return true
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
signOut()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+159
-98
@@ -24,8 +24,9 @@
|
|||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
button::after {
|
button::after,
|
||||||
border: 0;
|
uni-button::after {
|
||||||
|
border: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
@@ -133,6 +134,20 @@
|
|||||||
font-size: 13px;
|
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 {
|
.login-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
@@ -212,8 +227,7 @@
|
|||||||
.pick-item-row1,
|
.pick-item-row1,
|
||||||
.pick-item-row2,
|
.pick-item-row2,
|
||||||
.submit-info,
|
.submit-info,
|
||||||
.box-list-item,
|
.box-list-item {
|
||||||
.partial-input-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -275,20 +289,30 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 48px;
|
min-height: 48px;
|
||||||
padding: 0 14px;
|
padding: 8px 14px;
|
||||||
border: 2px solid $primary;
|
border: 2px solid $primary;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: $primary;
|
color: $primary;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 44px;
|
line-height: 20px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: $card;
|
background: $card;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
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 {
|
.box-chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
transition: transform 160ms ease-out;
|
transition: transform 160ms ease-out;
|
||||||
}
|
}
|
||||||
@@ -334,23 +358,13 @@
|
|||||||
line-height: 12px;
|
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 {
|
.box-chevron.open {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.box-dropdown {
|
.box-dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 53px;
|
top: calc(100% + 5px);
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -375,6 +389,8 @@
|
|||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.box-option > view { min-width: 0; flex: 1; }
|
||||||
|
|
||||||
.box-option.active {
|
.box-option.active {
|
||||||
color: $primary;
|
color: $primary;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -383,8 +399,8 @@
|
|||||||
|
|
||||||
.box-option.completed { color: #596675; background: #f3f7f6; }
|
.box-option.completed { color: #596675; background: #f3f7f6; }
|
||||||
.box-option-number, .box-option-order { display: block; }
|
.box-option-number, .box-option-order { display: block; }
|
||||||
.box-option-number { font-size: 13px; font-weight: 700; }
|
.box-option-number { font-size: 13px; font-weight: 700; overflow-wrap: anywhere; }
|
||||||
.box-option-order { margin-top: 3px; color: $text-secondary; font-size: 11px; }
|
.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 { padding: 5px 7px; border-radius: 5px; color: #08786c; font-size: 11px; background: $success-light; }
|
||||||
.box-option-state.draft { color: #865b0b; background: $warning-light; }
|
.box-option-state.draft { color: #865b0b; background: $warning-light; }
|
||||||
|
|
||||||
@@ -400,8 +416,6 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scan-hardware-tip { display: block; margin-top: 9px; color: #41668f; font-size: 11px; font-weight: 600; line-height: 17px; }
|
|
||||||
|
|
||||||
.last-scan {
|
.last-scan {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -426,31 +440,37 @@
|
|||||||
|
|
||||||
.progress-section {
|
.progress-section {
|
||||||
margin: 0 12px 10px;
|
margin: 0 12px 10px;
|
||||||
padding: 13px 14px;
|
padding: 10px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: $card;
|
background: $card;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-header {
|
.progress-header {
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-title {
|
.progress-title {
|
||||||
|
flex-shrink: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-stats {
|
.progress-stats {
|
||||||
color: $text-secondary;
|
color: $text-secondary;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 7px; }
|
.progress-actions { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||||
.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; }
|
.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 {
|
.progress-number {
|
||||||
color: $success;
|
color: $success;
|
||||||
@@ -459,8 +479,8 @@
|
|||||||
|
|
||||||
.progress-bar-bg {
|
.progress-bar-bg {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 7px;
|
height: 5px;
|
||||||
margin-top: 10px;
|
margin-top: 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #e5e7eb;
|
background: #e5e7eb;
|
||||||
}
|
}
|
||||||
@@ -516,9 +536,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pick-item-sku {
|
.pick-item-sku {
|
||||||
flex-shrink: 0;
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pick-item-meta {
|
.pick-item-meta {
|
||||||
@@ -577,9 +599,10 @@
|
|||||||
|
|
||||||
.pick-item-actions {
|
.pick-item-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.15fr 1fr 0.78fr;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
width: 100%;
|
||||||
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.processed-actions {
|
.processed-actions {
|
||||||
@@ -587,18 +610,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pick-btn {
|
.pick-btn {
|
||||||
|
display: flex;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
padding: 0 6px;
|
min-width: 0;
|
||||||
border-radius: 7px;
|
margin: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
line-height: 48px;
|
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::after { border: 0; }
|
||||||
.pick-btn-partial { border: 1px solid #75a5ef; color: #4a7dff; background: #eef5ff; }
|
.pick-btn:active { transform: translateY(1px); box-shadow: none; }
|
||||||
.pick-btn-skip { color: $skip; background: #eef0f3; }
|
.pick-btn-confirm { border-color: #0d8f84; color: #ffffff; background: $success; box-shadow: 0 3px 8px rgba(13, 148, 136, 0.22); }
|
||||||
.pick-btn-undo { border: 1px solid #d8dde4; color: $text-secondary; background: #ffffff; }
|
.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 {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -738,22 +773,23 @@
|
|||||||
|
|
||||||
.portal-content { padding: 12px 14px 24px; }
|
.portal-content { padding: 12px 14px 24px; }
|
||||||
|
|
||||||
.home-menu { padding: 12px 12px 24px; }
|
.home-menu { padding: 10px 10px 18px; }
|
||||||
.home-group + .home-group { margin-top: 18px; }
|
.home-group + .home-group { margin-top: 11px; }
|
||||||
.home-group-title { display: flex; align-items: center; margin-bottom: 10px; color: #445166; font-size: 16px; line-height: 24px; }
|
.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: 4px; height: 20px; flex-shrink: 0; margin-right: 10px; background: #1769e8; }
|
.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-title { color: #7b8795; }
|
||||||
.home-group-disabled .home-group-mark { background: #aeb8c4; }
|
.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-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
|
||||||
.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 { 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: #9bbcf2; background: #edf4ff; }
|
.home-operation:active { border-color: #77a7f2; background: #edf4ff; transform: translateY(1px); }
|
||||||
.home-operation-primary { border-color: #8cb5f5; color: #135ee8; background: #edf4ff; }
|
.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: #e1e6ec; color: #8b97a5; opacity: 1; background: #f1f3f6; }
|
.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: #e1e6ec; background: #f1f3f6; }
|
.home-operation-disabled:active { border-color: #e3e8ef; background: #fafbfc; transform: none; }
|
||||||
.home-operation-icon { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; font-size: 20px; font-weight: 700; }
|
.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%; margin-top: 3px; overflow: hidden; font-size: 14px; line-height: 20px; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
|
.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-pending { color: #52657c; font-size: 11px; line-height: 16px; }
|
.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-unavailable { color: #929dab; font-size: 11px; line-height: 16px; }
|
.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-sheet { box-sizing: border-box; width: 100vw; max-width: 480px; }
|
||||||
.header-menu-content { padding-top: 1px; }
|
.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 { 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-row:last-child { border-bottom: 0; }
|
||||||
.profile-value { color: $text-secondary; font-size: 12px; }
|
.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-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-action { display: flex; justify-content: space-between; color: $text; }
|
.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-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-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; }
|
.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;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box-list-number { font-size: 13px; font-weight: 600; }
|
.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; }
|
.box-list-detail { margin-top: 4px; color: $text-secondary; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
.box-list-tag {
|
.box-list-tag {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -1005,61 +1044,81 @@
|
|||||||
|
|
||||||
.partial-info {
|
.partial-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
padding: 12px;
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background: $primary-light;
|
background: $primary-light;
|
||||||
}
|
}
|
||||||
|
|
||||||
.partial-info-sku { color: $primary; font-size: 15px; font-weight: 700; }
|
.partial-info-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; }
|
||||||
.partial-info-desc { margin-top: 4px; color: $text-secondary; font-size: 12px; }
|
.partial-info-sku { overflow: hidden; color: $primary; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.partial-info-qty { margin-top: 7px; font-size: 13px; }
|
.partial-info-desc { overflow: hidden; margin-top: 2px; color: $text-secondary; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.partial-info-qty text { font-size: 16px; font-weight: 700; }
|
.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 {
|
.partial-field { margin-top: 12px; }
|
||||||
gap: 9px;
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.partial-input-label {
|
.partial-input-label {
|
||||||
flex-shrink: 0;
|
display: block;
|
||||||
font-size: 13px;
|
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 {
|
.partial-input {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 48px;
|
height: 46px;
|
||||||
padding: 0 12px;
|
padding: 0 8px 0 14px;
|
||||||
border: 2px solid $primary;
|
border: 0;
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
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 {
|
.partial-quick-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 8px;
|
align-items: stretch;
|
||||||
margin-top: 12px;
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.partial-quick-btn {
|
.partial-quick-btn {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
padding: 0 6px;
|
margin: 0;
|
||||||
|
padding: 4px 2px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
border: 1px solid #b9c9df;
|
border: 1px solid #b9c9df;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
color: #436080;
|
color: #436080;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
line-height: 48px;
|
line-height: 16px;
|
||||||
background: #f4f8fc;
|
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-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; }
|
.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; }
|
.decision-desc { display: block; color: $text-secondary; font-size: 13px; line-height: 20px; text-align: center; }
|
||||||
.submit-decision-modal { padding: 18px 16px 16px; }
|
.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 { 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; }
|
.incomplete-reason.selected { border-color: $danger; color: #a51d1d; background: $danger-light; }
|
||||||
.danger-confirm { background: $danger; }
|
.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 {
|
.complete-modal {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: calc(100vw - 32px);
|
width: calc(100vw - 32px);
|
||||||
@@ -1110,7 +1167,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.complete-title { font-size: 20px; font-weight: 700; }
|
.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 {
|
.complete-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1138,8 +1196,8 @@
|
|||||||
.red { color: $danger; }
|
.red { color: $danger; }
|
||||||
|
|
||||||
.loading-evidence-section {
|
.loading-evidence-section {
|
||||||
margin: 8px 12px 92px;
|
margin: 8px 12px 32px;
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
border: 1px solid #d8e4f4;
|
border: 1px solid #d8e4f4;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: $card;
|
background: $card;
|
||||||
@@ -1152,28 +1210,30 @@
|
|||||||
.sync-state.ready { color: $success; background: #e8f8ef; }
|
.sync-state.ready { color: $success; background: #e8f8ef; }
|
||||||
.sync-state.error { color: $danger; background: #fff0f0; }
|
.sync-state.error { color: $danger; background: #fff0f0; }
|
||||||
.sync-state.loading { color: #8a5a00; background: #fff7df; }
|
.sync-state.loading { color: #8a5a00; background: #fff7df; }
|
||||||
.evidence-grid { display: flex; flex-direction: column; margin-top: 12px; }
|
.evidence-grid { display: flex; flex-direction: column; margin-top: 10px; }
|
||||||
.evidence-card { margin-bottom: 12px; padding: 10px; border: 1px solid #d7e1ed; border-radius: 9px; background: #f8fafc; }
|
.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:last-child { margin-bottom: 0; }
|
||||||
.evidence-card.filled { border-color: #a7d6ba; background: #f4fbf6; }
|
.evidence-card.filled { border-color: #a7d6ba; background: #f4fbf6; }
|
||||||
.evidence-card-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
.evidence-card-header { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
.evidence-card-title { display: flex; min-width: 0; align-items: center; gap: 7px; }
|
.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-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 { color: $text-secondary; font-size: 10px; line-height: 15px; }
|
||||||
.evidence-count.complete { color: #087443; background: #dff4e7; }
|
.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-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-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 { 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-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-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-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 { 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.uploading, .evidence-upload-state.pending { color: #174d91; }
|
||||||
.evidence-upload-state.failed { color: $danger; }
|
.evidence-upload-state.failed { color: $danger; }
|
||||||
.evidence-upload-state.succeeded { color: $success; }
|
.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-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; }
|
.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-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; }
|
.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) {
|
@media (max-width: 340px) {
|
||||||
.login-page { padding-right: 16px; padding-left: 16px; }
|
.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; }
|
.header-title { font-size: 18px; }
|
||||||
.box-select-btn { padding: 0 10px; font-size: 12px; }
|
.box-select-btn { padding: 0 10px; font-size: 12px; }
|
||||||
.pick-item { padding-right: 10px; padding-left: 10px; }
|
.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; }
|
.processed-actions { grid-template-columns: 1fr; }
|
||||||
.evidence-photo-grid { grid-template-columns: repeat(2, minmax(0, 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; }
|
.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')
|
const profile = await read('../pages/profile/profile.vue')
|
||||||
|
|
||||||
assert.match(profile, /应用版本[\s\S]*?\{\{ appVersion \}\}/)
|
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, /plus\.runtime\.version/)
|
||||||
assert.match(profile, /checkForAppUpdate\(\{ manual: true \}\)/)
|
assert.match(profile, /checkForAppUpdate\(\{ manual: true \}\)/)
|
||||||
assert.match(profile, /#ifndef APP-PLUS[\s\S]*?请在 PDA App 中检查更新/)
|
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')
|
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
|
||||||
|
|
||||||
test('remote PDA mutations use the shared online gate', async () => {
|
test('remote PDA mutations use the shared online gate', async () => {
|
||||||
const [api, network, page] = await Promise.all([
|
const [api, http, network, page] = await Promise.all([
|
||||||
read('../api/pda-loading.js'), read('../utils/network.js'), read('../pages/picking/picking.vue')
|
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(api, /uploadEvidenceFile[\s\S]*?requireOnline\('当前网络不可用,无法上传装柜照片'\)/)
|
||||||
assert.match(network, /uni\.onNetworkStatusChange/)
|
assert.match(network, /uni\.onNetworkStatusChange/)
|
||||||
assert.match(network, /result\.networkType !== 'none'/)
|
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))
|
for (const text of ['柏悦物流 · PDA作业端', '工号 / 用户名', '登录中']) assert.match(login, new RegExp(text))
|
||||||
assert.doesNotMatch(login, /DEMO_ACCOUNTS|快捷登录|作业仓库/)
|
assert.doesNotMatch(login, /DEMO_ACCOUNTS|快捷登录|作业仓库/)
|
||||||
assert.match(auth, /\/auth\/login/)
|
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, /loginRequest\(userId, this\.form\.password\)/)
|
||||||
assert.match(login, /signInWithTokens\(tokens\)/)
|
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-auth-session/)
|
||||||
|
assert.match(session, /pda-login-account-preference/)
|
||||||
assert.match(session, /claims\.app_code !== 'PLATFORM'/)
|
assert.match(session, /claims\.app_code !== 'PLATFORM'/)
|
||||||
assert.match(runtime, /import\.meta\.env\.VITE_API_BASE_URL/)
|
assert.match(runtime, /import\.meta\.env\.VITE_API_BASE_URL/)
|
||||||
assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL \|\| '\/gateway-api'/)
|
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, /进入并开始拣货/)
|
||||||
assert.match(tasks, /AWAITING_REVIEW[\s\S]*?待上传证据\/确认装柜/)
|
assert.match(tasks, /AWAITING_REVIEW[\s\S]*?待上传证据\/确认装柜/)
|
||||||
assert.match(tasks, /task\.workflowStatus === 'AWAITING_REVIEW' \? '继续装柜取证'/)
|
assert.match(tasks, /task\.workflowStatus === 'AWAITING_REVIEW' \? '继续装柜取证'/)
|
||||||
assert.match(tasks, /\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW'\]\.includes\(task\.workflowStatus\)/)
|
assert.match(tasks, /\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETION_FAILED'\]\.includes\(task\.workflowStatus\)/)
|
||||||
assert.match(tasks, /task\.workflowStatus === 'AWAITING_REVIEW' \|\| \(task\.assignmentVersion !== null/)
|
assert.match(tasks, /\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'PROCESSING', 'COMPLETION_FAILED'\]\.includes\(task\.workflowStatus\)/)
|
||||||
assert.match(tasks, /暂无可领取的拣货任务/)
|
assert.match(tasks, /\['AWAITING_REVIEW', 'COMPLETION_FAILED'\]\.includes\(task\.workflowStatus\) \|\| \(task\.assignmentVersion !== null/)
|
||||||
assert.match(tasks, /class="task-label">集装箱号<\/text><text class="task-code">\{\{ task\.currentContainerNo \}\}/)
|
assert.match(tasks, /暂无待处理的拣货任务/)
|
||||||
assert.match(tasks, /柜次号 \{\{ task\.cabinetNo \}\}/)
|
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, /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, /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, /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/)
|
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([
|
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')
|
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/)
|
assert.doesNotMatch(home, /BottomNav|status-strip|task-card/)
|
||||||
for (const text of ['业务模块', '业务功能', '第一版本仅开放']) assert.match(module, new RegExp(text))
|
for (const text of ['业务模块', '业务功能', '第一版本仅开放']) assert.match(module, new RegExp(text))
|
||||||
assert.match(home, /:disabled="!operation\.available"/)
|
assert.match(home, /:disabled="!operation\.available"/)
|
||||||
assert.match(home, /home-operation-disabled/)
|
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.match(home, /openOperation\(operation\) \{ if \(operation\.available\) uni\.navigateTo/)
|
||||||
assert.doesNotMatch(home, /pages\/operation\/operation\?module=/)
|
assert.doesNotMatch(home, /pages\/operation\/operation\?module=/)
|
||||||
assert.match(module, /:disabled="!operation\.available"/)
|
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 () => {
|
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')])
|
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, /saveCurrentBoxDraft\(\)[\s\S]*?this\.boxDrafts =/)
|
||||||
assert.match(page, /loadBoxState\(index\)[\s\S]*?const draft = this\.boxDrafts\[index\]/)
|
assert.match(page, /loadBoxState\(index\)[\s\S]*?const draft = this\.boxDrafts\[index\]/)
|
||||||
assert.match(page, /requestBoxSwitch\(index\)[\s\S]*?this\.openOverlay\('switchBoxConfirm'\)/)
|
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, /lastScanResult\.boxCode/)
|
||||||
assert.match(page, /boxCode: entry\.boxCode \?\? entry\.sku \?\? ''/)
|
assert.match(page, /boxCode: entry\.boxCode \?\? entry\.sku \?\? ''/)
|
||||||
assert.doesNotMatch(page, /lastScanResult\.sku|entry\.sku\s*}}/)
|
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/)
|
const processScanStart = page.indexOf("processScannedValue(scanValue, source = 'broadcast') {")
|
||||||
assert.ok(processScanMethod)
|
const processScanEnd = page.indexOf('\n\t\t\tevidenceEntries', processScanStart)
|
||||||
assert.doesNotMatch(processScanMethod[0], /this\.scanValue = ''/)
|
assert.ok(processScanStart >= 0 && processScanEnd > processScanStart)
|
||||||
|
assert.doesNotMatch(page.slice(processScanStart, processScanEnd), /this\.scanValue = ''/)
|
||||||
assert.match(scanner, /android\.intent\.action\.SCANRESULT/)
|
assert.match(scanner, /android\.intent\.action\.SCANRESULT/)
|
||||||
assert.match(scanner, /SCAN_BROADCAST_EXTRA = 'value'/)
|
assert.match(scanner, /SCAN_BROADCAST_EXTRA = 'value'/)
|
||||||
assert.match(scanner, /registerReceiver\(receiver, filter\)/)
|
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, /<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, /<PopupHeader title="确认退出登录?"[\s\S]*?@close="closeLogout"/)
|
||||||
assert.match(profile, /class="logout-dialog-confirm"/)
|
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.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, /openOverlay\(name\)[\s\S]*?this\.\$refs\[`\$\{name\}Popup`\]/)
|
||||||
assert.match(picking, /closeOverlay\(\)[\s\S]*?popup\.close\(\)/)
|
assert.match(picking, /closeOverlay\(\)[\s\S]*?popup\.close\(\)/)
|
||||||
assert.match(picking, /handleOverlayChange\(name, event\)[\s\S]*?!event\.show[\s\S]*?this\.resetOverlayState\(\)/)
|
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.match(app, /@import '@\/styles\/pda\.scss'/)
|
||||||
assert.doesNotMatch(styles, /<\/?style\b/)
|
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;`))
|
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.doesNotMatch(styles, /\.scan-btn\s*\{/)
|
||||||
assert.match(styles, /\.modal-cancel-btn,[\s\S]*?height:\s*48px;/)
|
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.match(styles, /\.modal-sheet\s*\{[\s\S]*?padding:\s*20px 18px 18px;/)
|
||||||
assert.doesNotMatch(styles, /\.modal-sheet\s*\{[\s\S]*?padding:[^;]*env\(/)
|
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, /\.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(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\s*\{[^}]*margin:\s*0 0 12px;/)
|
||||||
assert.match(tasks, /\.task-card:last-child\s*\{[^}]*margin-bottom:\s*0;/)
|
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(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-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, /\.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-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(styles, /\.header-menu-close\s*\{[^}]*width:\s*calc\(100% - 32px\);/)
|
||||||
assert.match(picking, /aria-modal="true"/)
|
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'
|
import { readFile } from 'node:fs/promises'
|
||||||
|
|
||||||
const source = await readFile(new URL('../api/pda-loading.js', import.meta.url), 'utf8')
|
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')
|
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 [
|
for (const path of [
|
||||||
'/first-leg/pda-loading/tasks/pending',
|
'/first-leg/pda-loading/tasks/pending'
|
||||||
'/loading/picking?tenantId=',
|
|
||||||
'/loading/picking/start?tenantId=',
|
|
||||||
'/loading/picking/complete?tenantId=',
|
|
||||||
'/loading/evidence/pda?tenantId=',
|
|
||||||
'/loading/complete?tenantId='
|
|
||||||
]) assert.match(source, new RegExp(path.replace(/[?]/g, '\\?')))
|
]) 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, /'Idempotency-Key'/)
|
||||||
assert.match(source, /PHOTO_STEP_1[\s\S]*PHOTO_STEP_4[\s\S]*SEAL/)
|
assert.match(source, /PHOTO_STEP_1[\s\S]*PHOTO_STEP_4[\s\S]*SEAL/)
|
||||||
for (const label of ['空仓照片', '半仓照片', '满仓照片', '锁仓照片', '带锁码照']) assert.match(source, new RegExp(label))
|
for (const label of ['空仓照片', '半仓照片', '满仓照片', '锁仓照片', '带锁码照']) assert.match(source, new RegExp(label))
|
||||||
assert.match(source, /uni\.uploadFile/)
|
assert.match(source, /apiUpload\(/)
|
||||||
assert.match(source, /\/files\/loading-evidence\//)
|
assert.match(http, /uni\.uploadFile/)
|
||||||
assert.match(source, /loading\/evidence\/pda\?tenantId=/)
|
assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\?\$\{query\.join\('&'\)\}/)
|
||||||
assert.match(source, /loading\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/void\/pda\?tenantId=/)
|
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, /export function completeContainerPickingTask\(taskId, payload = \{\}\)/)
|
||||||
assert.match(source, /first-leg\/pda-loading\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/complete/)
|
assert.match(source, /first-leg\/pda-loading\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/complete/)
|
||||||
assert.match(source, /expectedTaskVersion/)
|
assert.match(source, /expectedTaskVersion/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PDA adapter does not treat non-200 response bodies or network failures as success', () => {
|
test('PDA adapter does not treat non-200 response bodies or network failures as success', () => {
|
||||||
assert.match(source, /if \(code !== '200'\)/)
|
assert.match(http, /if \(code !== '200'\)/)
|
||||||
assert.match(source, /next\.network = true/)
|
assert.match(http, /next\.network = true/)
|
||||||
assert.match(source, /throw new Error\('缺少客户隔离标识 tenantId/)
|
assert.match(source, /throw new Error\('缺少装柜任务标识,无法上传装柜照片/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('C02 identifiers and separate picking/inbound versions are normalized explicitly', () => {
|
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, /inboundVersion: row\.inboundVersion/)
|
||||||
assert.match(source, /pickingVersion: row\.pickingVersion \?\? row\.loading\?\.pickingVersion/)
|
assert.match(source, /pickingVersion: row\.pickingVersion \?\? row\.loading\?\.pickingVersion/)
|
||||||
assert.match(source, /pickingVersion: task\.pickingVersion \?\? task\.version/)
|
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, /ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates/)
|
||||||
assert.match(page, /taskItemId: item\.id \?\? item\.itemId/)
|
assert.match(page, /taskItemId: item\.id \?\? item\.itemId/)
|
||||||
assert.match(page, /submit\.batch/)
|
assert.match(page, /submit\.batch/)
|
||||||
assert.match(page, /item\.itemStatus === 'PICKED'/)
|
assert.match(page, /item\.itemStatus === 'PICKED'/)
|
||||||
assert.match(page, /item\.itemStatus === 'PARTIAL'/)
|
assert.match(page, /item\.itemStatus === 'PARTIAL'/)
|
||||||
assert.match(page, /!pickingCompleted && !summary\.canSubmit/)
|
assert.match(page, /!pickingCompleted && !summary\.canSubmit/)
|
||||||
assert.match(page, /this\.loadingVersion = this\.remotePick\.inboundVersion \?\? this\.remotePick\.loadingVersion/)
|
assert.doesNotMatch(page, /loadingVersion/)
|
||||||
assert.match(page, /状态尚未同步,请刷新重试/)
|
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', () => {
|
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(`${source}\n${page}`, new RegExp(text))
|
||||||
}
|
}
|
||||||
assert.match(page, /createEvidenceUploadQueue/)
|
assert.match(page, /createEvidenceUploadQueue/)
|
||||||
assert.match(page, /retryEvidence/)
|
assert.match(page, /retryEvidence/)
|
||||||
assert.match(page, /uploadEvidenceFile/)
|
assert.match(page, /uploadEvidenceFile/)
|
||||||
assert.match(page, /submitting \|\| \(!pickingCompleted && !summary\.canSubmit\)/)
|
assert.match(page, /submitting \|\| \(!pickingCompleted && !summary\.canSubmit\)/)
|
||||||
assert.match(page, /completePicking\(/)
|
assert.doesNotMatch(page, /completePicking\(/)
|
||||||
assert.match(page, /sourceType: \['camera'\]/)
|
assert.match(page, /sourceType: \['camera'\]/)
|
||||||
assert.doesNotMatch(page, /sourceType: \['camera', 'album'\]/)
|
assert.doesNotMatch(page, /sourceType: \['camera', 'album'\]/)
|
||||||
assert.match(page, /evidenceEntries\(slot\.type\)/)
|
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/)
|
assert.match(page, /evidenceComplete/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PDA evidence binding unwraps the nested loading action response before rendering saved files', () => {
|
test('PDA evidence binding normalizes string ids from the task-scoped DTO', () => {
|
||||||
assert.match(page, /evidenceItems\(result\)[\s\S]*?Array\.isArray\(result\?\.evidences\?\.evidences\)[\s\S]*?result\.evidences\.evidences/)
|
assert.match(page, /evidenceItems\(result\)[\s\S]*?id: String\(item\.evidenceId \?\? item\.id/)
|
||||||
assert.match(page, /this\.evidenceFiles = this\.evidenceItems\(result\)[\s\S]*?\.reduce/)
|
assert.match(page, /fileId: String\(item\.fileId/)
|
||||||
assert.match(page, /cacheEvidencePreview\(current\.fileId, current\.localPath/)
|
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', () => {
|
test('PDA downloads authenticated task-scoped evidence previews', () => {
|
||||||
assert.match(source, /export function getEvidencePreviewUrl\(inboundId, evidenceId, tenantId\)/)
|
assert.match(source, /export function getEvidencePreviewUrl\(taskId, evidenceId, warehouseId\)/)
|
||||||
assert.match(source, /loading\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content\/pda\?tenantId=/)
|
assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content\?warehouseId=/)
|
||||||
assert.match(source, /export function downloadEvidencePreview\(previewUrl\)/)
|
assert.match(source, /export function downloadEvidencePreview\(previewUrl\)/)
|
||||||
assert.match(source, /uni\.downloadFile\(/)
|
assert.match(source, /apiDownload\(\{ url \}\)/)
|
||||||
assert.match(source, /headers\.Authorization = `Bearer \$\{session\.token\}`/)
|
assert.match(http, /uni\.downloadFile\(/)
|
||||||
|
assert.match(http, /headers\.Authorization = `Bearer \$\{accessToken\}`/)
|
||||||
assert.match(source, /response\.tempFilePath/)
|
assert.match(source, /response\.tempFilePath/)
|
||||||
assert.match(source, /evidence\.preview\.response/)
|
assert.match(source, /evidence\.preview\.response/)
|
||||||
assert.match(source, /\^image\\\//)
|
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', () => {
|
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, /拣货完成未提交,请查看错误提示/)
|
assert.match(page, /拣货完成未提交,请查看错误提示/)
|
||||||
assert.match(page, /if \(remoteTask\.workflowStatus === 'UNASSIGNED'\)[\s\S]*claimContainerPickingTask/)
|
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, /taskRequestId/)
|
||||||
assert.match(page, /resetEvidenceState/)
|
assert.match(page, /resetEvidenceState/)
|
||||||
assert.match(page, /availablePickQuantity/)
|
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')
|
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
||||||
|
|
||||||
test('picking and photo workspaces are independently selectable', () => {
|
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, /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, /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', () => {
|
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 '拣货已完成,请继续装柜取证'/)
|
assert.match(page, /submitStatusHint\(\)[\s\S]*?if \(this\.pickingCompleted\) return '拣货已完成,请继续装柜取证'/)
|
||||||
const submit = page.match(/async submitPicking\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
const submit = page.match(/async submitPicking\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
||||||
assert.doesNotMatch(submit, /completedBoxes/)
|
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', () => {
|
test('picking completion leads to current cabinet evidence instead of an all-complete summary', () => {
|
||||||
assert.match(page, /if \(this\.loadingStageReady\) \{[\s\S]*?await this\.loadLoadingEvidence\(\)/)
|
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] || ''
|
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(loader, /await getEvidence\(/)
|
||||||
assert.match(page, /this\.completedBoxes = \{[\s\S]*?this\.loadLoadingEvidence\(\)/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('operators receive actionable refresh guidance without internal version wording', () => {
|
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/)
|
assert.doesNotMatch(complete, /completeLoading\(inboundId/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('AWAITING_REVIEW alone cannot request or mutate server evidence without a server loading version', () => {
|
test('AWAITING_REVIEW uses the canonical task version for final completion', () => {
|
||||||
assert.match(page, /loadingStageReady\(\)[\s\S]*?this\.pickingCompleted[\s\S]*?this\.loadingVersion !== null[\s\S]*?Number\.isFinite/)
|
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, /拣货已完成,装柜状态尚未同步,请刷新重试/)
|
||||||
assert.match(page, /if \(!this\.loadingStageReady \|\| !this\.evidenceIdentity/)
|
assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?requireEvidenceStage/)
|
||||||
assert.doesNotMatch(page, /if \(this\.pickingCompleted\) await this\.loadLoadingEvidence\(\)/)
|
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, /class="stage-switch" role="tablist"/)
|
||||||
assert.match(page, /@tap="selectStage\('picking'\)"/)
|
assert.match(page, /@tap="selectStage\('picking'\)"/)
|
||||||
assert.match(page, /@tap="selectStage\('evidence'\)"/)
|
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.doesNotMatch(page, /stage === 'evidence' && !this\.loadingStageReady/)
|
||||||
assert.match(page, /class="stage-tab"[\s\S]*?:disabled="!loadingStageReady"[\s\S]*?装柜取证/)
|
assert.doesNotMatch(page, /请先完成新增待拣明细/)
|
||||||
assert.match(page, /activeStage === 'picking'/)
|
assert.match(page, /activeStage === 'picking'/)
|
||||||
assert.match(page, /:disabled="submitting \|\| !loadingStageReady \|\| pickingVersion === null/)
|
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 previousTaskVersion = this\.pickingVersion/)
|
||||||
assert.match(page, /const previousItemIds = new Set/)
|
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, /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/)
|
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', () => {
|
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 \(item\.itemStatus === 'PICKED'\) serverStates\[item\.id\] = ITEM_STATUS\.PICKED/)
|
||||||
assert.match(page, /if \(!this\.pickingCompleted\) this\.restoreRemoteDraft\(\)/)
|
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] || ''
|
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/)
|
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] || ''
|
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('))
|
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', () => {
|
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] || ''
|
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)
|
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', () => {
|
test('seal photo upload waits for an explicit seal-number save without waiting for picking completion', () => {
|
||||||
const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
const chooser = page.match(/chooseEvidence\(slot\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
||||||
const retry = page.match(/async retryEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
assert.match(chooser, /slot\.type === 'SEAL'[\s\S]*?照片已拍摄,请输入封签号并保存上传[\s\S]*?return/)
|
||||||
const save = page.match(/async saveEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
assert.match(page, /async saveSealNo\(\)[\s\S]*?await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/)
|
||||||
assert.match(queue, /if \(type === 'SEAL'\)[\s\S]*?rememberEvidenceDraft\(identity\)[\s\S]*?return[\s\S]*?saveEvidence\(type, null, path\)/)
|
const saver = page.match(/async saveSealNo\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
||||||
assert.match(retry, /if \(type === 'SEAL'\)[\s\S]*?rememberEvidenceDraft\(identity\)[\s\S]*?return[\s\S]*?saveEvidence\(type\)/)
|
assert.doesNotMatch(saver, /if \(!this\.loadingStageReady\)/)
|
||||||
assert.match(save, /type === 'SEAL' && !String\(this\.sealNo \|\| ''\)\.trim\(\)[\s\S]*?请输入封签号后点击保存封签/)
|
assert.match(page, /sealReadyToSave\(\)[\s\S]*?hasPendingLocalEvidence\('SEAL'\)/)
|
||||||
assert.match(page, /async saveSealNo\(\)[\s\S]*?await this\.saveEvidence\('SEAL'/)
|
assert.match(page, /slot\.type === 'SEAL' \? '请填写封签号,保存后上传' : '照片待自动上传'/)
|
||||||
assert.match(page, /hasUnboundUploadedEvidence\(slot\.type\)[\s\S]*?照片已上传,请输入封签号并保存/)
|
assert.match(page, /带锁码照上传成功,封签号已保存/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('evidence identity is bound to task, inbound, loading plan and container booking', () => {
|
test('cabinet evidence identity is task-scoped and never selects an inbound owner', () => {
|
||||||
assert.match(page, /captureEvidenceIdentity\(\)[\s\S]*?taskId:[\s\S]*?inboundId:[\s\S]*?loadingPlanId:[\s\S]*?containerBookingId:/)
|
const capture = page.match(/captureEvidenceIdentity\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
||||||
assert.match(page, /isSameEvidenceIdentity\(left, right\)[\s\S]*?taskId[\s\S]*?inboundId[\s\S]*?loadingPlanId[\s\S]*?containerBookingId/)
|
const same = page.match(/isSameEvidenceIdentity\(left, right\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
||||||
assert.match(page, /this\.currentBox\.inboundId = task\.inboundId/)
|
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, /String\(detailTaskId \|\| ''\) !== String\(requestedTaskId\)/)
|
||||||
assert.match(page, /!task\.inboundId \|\| !task\.loadingPlanId \|\| !task\.containerBookingId/)
|
assert.match(page, /!task\.loadingPlanId \|\| !task\.containerBookingId/)
|
||||||
assert.match(page, /evidenceItem[\s\S]*?String\(task\.inboundId\)/)
|
assert.doesNotMatch(page, /const evidenceItem|String\(task\.inboundId\)/)
|
||||||
assert.match(page, /this\.evidenceIdentity = this\.captureEvidenceIdentity\(\)/)
|
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/)
|
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] || ''
|
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, /taskId: previousIdentity\?\.taskId \?\? task\?\.taskId/)
|
||||||
assert.match(clear, /inboundId: previousIdentity\?\.inboundId \?\? task\?\.inboundId/)
|
|
||||||
assert.match(clear, /loadingPlanId: previousIdentity\?\.loadingPlanId \?\? task\?\.loadingPlanId/)
|
assert.match(clear, /loadingPlanId: previousIdentity\?\.loadingPlanId \?\? task\?\.loadingPlanId/)
|
||||||
assert.match(clear, /containerBookingId: previousIdentity\?\.containerBookingId \?\? task\?\.containerBookingId/)
|
assert.match(clear, /containerBookingId: previousIdentity\?\.containerBookingId \?\? task\?\.containerBookingId/)
|
||||||
assert.match(clear, /clearPickingDraft\(staleIdentity\)/)
|
assert.match(clear, /clearPickingDraft\(staleIdentity\)/)
|
||||||
assert.match(clear, /this\.evidenceDrafts = Object\.fromEntries/)
|
assert.match(clear, /this\.evidenceDrafts = Object\.fromEntries/)
|
||||||
assert.match(clear, /this\.evidencePreviewCache = 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\)/)
|
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] || ''
|
const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
||||||
assert.match(queue, /if \(!this\.loadingStageReady\) return/)
|
assert.match(queue, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, path, identity\)/)
|
||||||
for (const method of ['retryEvidence', 'saveEvidence', 'voidEvidencePhoto', 'completeLoadingTask']) {
|
assert.doesNotMatch(queue, /if \(!this\.loadingStageReady\)/)
|
||||||
const body = page.match(new RegExp(`(?:async )?${method}\\([^)]*\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] || ''
|
assert.match(page, /chooseEvidence\(slot\)[\s\S]*?this\.queueEvidenceUpload\(slot\.type, path\)/)
|
||||||
assert.match(body, /if \(!this\.requireEvidenceStage\(\)\)(?: return| \{)/, `${method} should guard the evidence stage`)
|
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\.rememberEvidenceDraft\(\)/)
|
||||||
assert.match(page, /this\.restoreEvidenceDraft\(\)/)
|
assert.match(page, /this\.restoreEvidenceDraft\(\)/)
|
||||||
assert.match(page, /async flushPendingEvidence\(\)[\s\S]*?queueEvidenceUpload/)
|
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', () => {
|
test('evidence photos render thumbnails and support full-screen preview', () => {
|
||||||
assert.match(page, /class="evidence-thumbnail" :src="evidencePreviewSource\(photo\)"/)
|
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-photo-grid"/)
|
||||||
assert.match(page, /class="evidence-count"/)
|
assert.match(page, /class="evidence-count"/)
|
||||||
assert.match(page, /@tap="previewEvidencePhoto\(photo, slot\.type\)"/)
|
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, /previewEvidencePhoto\(photo, type\)[\s\S]*?uni\.previewImage\([\s\S]*?current[\s\S]*?urls/)
|
||||||
assert.match(page, /cacheEvidencePreview\(fileId, localPath/)
|
assert.match(page, /cacheEvidencePreview\(fileId, localPath/)
|
||||||
assert.match(page, /withCachedEvidencePreview\(photo[\s\S]*?evidencePreviewCache/)
|
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, /['"]list\.loaded['"]/)
|
||||||
assert.match(page, /loadEvidencePreviews\(contextId, identity\)[\s\S]*?downloadEvidencePreview\(item\.previewUrl\)/)
|
assert.match(page, /loadEvidencePreviews\(contextId, identity\)[\s\S]*?downloadEvidencePreview\(item\.previewUrl\)/)
|
||||||
assert.match(page, /['"]preview\.downloaded['"]/)
|
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] || ''
|
const selector = page.match(/selectBox\(index\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
|
||||||
assert.match(selector, /this\.taskRequestId \+= 1/)
|
assert.match(selector, /this\.taskRequestId \+= 1/)
|
||||||
assert.match(selector, /this\.pickingVersion = null/)
|
assert.match(selector, /this\.pickingVersion = null/)
|
||||||
assert.match(selector, /this\.loadingVersion = null/)
|
|
||||||
assert.match(selector, /this\.submitting = false/)
|
assert.match(selector, /this\.submitting = false/)
|
||||||
assert.match(selector, /this\.resetEvidenceState\(\)/)
|
assert.match(selector, /this\.resetEvidenceState\(\)/)
|
||||||
assert.match(page, /!this\.isEvidenceContextActive\(contextId, identity\) \|\| queue !== this\.evidenceQueue/)
|
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', () => {
|
test('evidence save, void and loading completion ignore stale cabinet responses', () => {
|
||||||
for (const [method, awaitedCall] of [
|
for (const [method, awaitedCall] of [
|
||||||
['saveEvidence', 'upsertEvidence'],
|
|
||||||
['voidEvidencePhoto', 'voidEvidence'],
|
['voidEvidencePhoto', 'voidEvidence'],
|
||||||
['completeLoadingTask', 'completeContainerPickingTask']
|
['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, /remoteTask\.workflowStatus !== 'AWAITING_REVIEW' && this\.assignmentVersion === null/)
|
||||||
assert.match(page, /if \(remoteTask\.workflowStatus === 'UNASSIGNED'\) \{[\s\S]*?claimContainerPickingTask/)
|
assert.match(page, /if \(remoteTask\.workflowStatus === 'UNASSIGNED'\) \{[\s\S]*?claimContainerPickingTask/)
|
||||||
assert.match(page, /else if \(!isPickingCompleteTask\(remoteTask\)\)/)
|
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', () => {
|
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]*?任务尚未领取完成/)
|
assert.match(page, /requirePickingOperation\(\)[\s\S]*?任务尚未领取完成/)
|
||||||
for (const method of ['processScannedValue', 'pickItem', 'openSkip', 'openPartial', 'submitPicking']) {
|
for (const method of ['processScannedValue', 'pickItem', 'openSkip', 'openPartial', 'submitPicking']) {
|
||||||
const body = page.match(new RegExp(`${method}\\([^)]*\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] || ''
|
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`)
|
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(taskIdentitySource, /containerNo|currentContainerNo|boxNo/)
|
||||||
assert.doesNotMatch(restoreGuard, /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