11 Commits
22 changed files with 250 additions and 209 deletions
-13
View File
@@ -1,13 +0,0 @@
# Only compiled H5 assets and image configuration enter the build context.
**
!deploy/
!deploy/Dockerfile.h5
!deploy/nginx.conf
!unpackage/
!unpackage/dist/
!unpackage/dist/build/
!unpackage/dist/build/web/
!unpackage/dist/build/web/**
!deploy/Dockerfile.h5.production
!deploy/nginx.production.conf.template
+1
View File
@@ -8,6 +8,7 @@
## 开发与发布边界 ## 开发与发布边界
- H5 在 TEST/灰度均上传静态文件至宿主机 Nginx 站点;禁止重建 PDA H5 容器或将页面入口转发到旧容器。发布步骤见 `README.md`,站点片段见 `deploy/nginx.conf.template`;历史灰度脚本不作现行入口。
- 当前已有 uni-app Vue 3 页面、`uni_modules/`、`vite.config.js`、`.env.example` 和 H5 同源代理配置。首页当前只展示“入库 → 一件代发”和“出库 → 出库拣货”;“库位查询”等其余能力保留源码与路由但不展示。项目仍没有根级依赖清单、仓库内 npm 构建命令或可核实的 Android 构建命令。H5 已使用 HBuilderX 5.15 自带编译器和 `cli.exe launch web` 验证,具体命令见 `README.md`。现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。 - 当前已有 uni-app Vue 3 页面、`uni_modules/`、`vite.config.js`、`.env.example` 和 H5 同源代理配置。首页当前只展示“入库 → 一件代发”和“出库 → 出库拣货”;“库位查询”等其余能力保留源码与路由但不展示。项目仍没有根级依赖清单、仓库内 npm 构建命令或可核实的 Android 构建命令。H5 已使用 HBuilderX 5.15 自带编译器和 `cli.exe launch web` 验证,具体命令见 `README.md`。现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。
- PDA 采用 uni-app 开发并支持在线更新;Android 硬件扫描已确认使用广播模式并已接入:Action=`android.intent.action.SCANRESULT`、字符串 Extra=`value`。设备型号不构成开发、发布或验收门禁;继续通过设备适配层接入,不绑定单一厂商 SDK。 - PDA 采用 uni-app 开发并支持在线更新;Android 硬件扫描已确认使用广播模式并已接入:Action=`android.intent.action.SCANRESULT`、字符串 Extra=`value`。设备型号不构成开发、发布或验收门禁;继续通过设备适配层接入,不绑定单一厂商 SDK。
- 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。 - 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。
+23 -17
View File
@@ -109,32 +109,38 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查国内 PDA 的独
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。 3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
4. 国内和海外更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。 4. 国内和海外更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
## TEST H5 静态托管 ## H5 静态托管(TEST 与灰度)
发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。开发服务仍使用根路径;不能将 Vite 开发代理当作静态服务器配置。 国内、海外 PDA H5 统一由宿主机已有 Nginx/宝塔站点直接提供静态文件,不再构建或发布 PDA H5 Docker 镜像、Compose 服务或容器。正式部署约束见 [测试环境单机部署与配置契约](../../docs/测试环境单机部署与配置契约.md) 的“PDA H5 发布方式”;历史灰度报告不再作为发布入口。
国内使用本机已安装 HBuilderX:`cli.exe project open --path <本仓库绝对路径>` 后执行 `cli.exe publish web --project <本仓库绝对路径> --webHosting false --sourceMap false`,仅导出本地 Web 静态文件;本次验证编译器 5.26,不上传云托管。 发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。不能放到子目录后继续共用根路径资源,也不能将 Vite 开发代理当作线上配置。
`deploy/Dockerfile.h5` 只复制已构建 `unpackage/dist/build/web/` 及静态 Nginx 配置,不在镜像中复制源码、依赖或重新编译。`.dockerignore` 只放行这两类输入。容器静态目录为 `/srv/pda-h5/`。 国内使用本机已安装 HBuilderX:`cli.exe project open --path <本仓库绝对路径>` 后执行 `cli.exe publish web --project <本仓库绝对路径> --webHosting false --sourceMap false`,仅导出本地 Web 静态文件,不上传云托管。
从本仓库执行 `docker compose -f deploy/docker-compose.h5.test.yml build --pull=false`,再由获授权的部署流程执行 `docker compose -f deploy/docker-compose.h5.test.yml up -d --wait --wait-timeout 120`。必须设置 `IMAGE_TAG`,`NGINX_BASE_IMAGE` 可指定已缓存的兼容 Alpine Nginx 镜像。容器名及网络别名为 `pda-domestic-h5-test`,仅在外部 `firstleg-test-network` 暴露容器 80,不发布宿主端口。健康检查同时请求 `/healthz` 与 `/` 的实际首页。 发布步骤:
独立域名入口将 `/` 代理至本应用静态容器根目录,或将构建目录内容直接发布到站点根目录;不再挂载 PDA 子路径。缺失资源返回 404,不回退首页,HTML 使用 `Cache-Control: no-store`。域名入口还须配置同源 API 代理:两端均将 `/gateway-api/` 转统一 Gateway `/api/`。入口须保留 Authorization,支持文件上传下载及至少 11m 请求体。域名与上游只在部署侧配置,不编入 PDA 源码。 1. 构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径及同源 API。构建、测试仍按根规则经 `local-check.ps1` 执行。
2. 将 `unpackage/dist/build/web/` **目录内的文件**上传到该 PDA 站点现有 `root`。先上传 `assets/`、`static/` 等资源,最后替换 `index.html`,保留站点证书验证目录和站点配置;不要把外层构建目录一起嵌套上传。
3. 页面 `location /` 使用 `try_files $uri $uri/ =404` 直接读取静态文件,设置 `Cache-Control: no-store`;不得再转发到旧 PDA 容器。接口 `/gateway-api/` 独立转发到当前环境统一 Gateway `/api/`,保留 Authorization 和上传能力。
4. 首次切换前备份实际生效站点配置,合并下述片段并执行 `nginx -t`,成功后才重载。已有独立 API 规则先核对并复用,不能直接追加重复 `location`。后续仅更新静态文件时无需新建容器或重载 Nginx。
5. 检查域名首页及实际引用的 JS/CSS 均返回 200,并将响应内容的 SHA-256 与本次产物逐项比较;确认响应不是旧容器内容。再检查登录和受影响页面。首页 200 不等于接口或业务验收通过。
部署配置检查:`node --test tests/h5-deployment.test.mjs`。没有 Docker 运行时的本地合同检查不能替代目标主机 `docker compose config`、Nginx 启动与真实 HTTP 验收。 ### 站点配置片段
构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径、同源 API 和无本机/旧海外配置泄漏。该检查与本地构建仅证明产物;TEST 页面登录、仓权限、业务操作和 Android 真机另行验收。 `deploy/nginx.conf.template` 是合并到已有 `server` 的参考片段,不是完整站点配置。保留现有域名、HTTPS、证书与宝塔 include;替换原有 root/index 及同名 location。环境地址、端口及网站目录只在部署侧配置,不提交到源码。
在部署机器上将 `PDA_SITE_ROOT` 设置为该站点真实根目录,`PDA_GATEWAY_ORIGIN` 设置为宿主机可达的当前环境 Gateway 源地址(协议、主机、端口,不含末尾 `/` 或 `/api`)。不要使用仅 Docker 网络可解析的服务名。显式只渲染这两个变量,保留 `$uri`、`$host` 和代理请求头变量:
```bash
envsubst '${PDA_SITE_ROOT} ${PDA_GATEWAY_ORIGIN}' < deploy/nginx.conf.template
```
输出仅供核对和合并,不直接覆盖完整站点。若机器没有 `envsubst`,手工替换这两个占位符;未替换的模板不能直接加载。
### 两端统一 API 配置 ### 两端统一 API 配置
两端的 `VITE_API_BASE_URL` 均为统一 Gateway 绝对地址(含 `/api`);`VITE_H5_API_BASE_URL=/gateway-api`;`API_PROXY_TARGET` 为同一 Gateway 源地址(不含 `/api`)。实际地址只放在未提交的 `.env.local`;修改后重启开发服务,发布前重新打包。 App 的 `VITE_API_BASE_URL` 为统一 Gateway 绝对地址(含 `/api`);H5 使用 `VITE_H5_API_BASE_URL=/gateway-api`。`API_PROXY_TARGET` 仅供本地 Vite 开发代理使用,不随静态包发布。实际地址放在未提交环境文件;变更编译变量后须重新构建。
两端独立域名入口均配置以下规则;`gateway:9000` 仅适用于同一 Docker 网络,其他部署须替换成实际统一 Gateway 上游。静态容器只提供页面文件,Vite 代理不会随静态包发布。 站点 API 规则须使用 `location ^~ /gateway-api/`,防止驾驶证等包含 `LICENSE` 的业务路径被站点敏感文件正则截获。仅更新静态包不能修复代理配置;域名入口和每层代理均须核对。
```nginx 部署定向检查:`node --test tests/h5-deployment.test.mjs`,经 `local-check.ps1` 执行。该检查覆盖静态发布约束、资源 base 和开发代理重写,不替代目标主机 Nginx 校验、真实 HTTP、人工页面及 Android 真机验收。
location ^~ /gateway-api/ {
proxy_pass http://gateway:9000/api/;
proxy_set_header Authorization $http_authorization;
client_max_body_size 11m;
}
```
-5
View File
@@ -1,5 +0,0 @@
ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
FROM ${NGINX_BASE_IMAGE}
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY unpackage/dist/build/web/ /srv/pda-h5/
EXPOSE 80
-5
View File
@@ -1,5 +0,0 @@
ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
FROM ${NGINX_BASE_IMAGE}
COPY deploy/nginx.production.conf.template /etc/nginx/templates/default.conf.template
COPY unpackage/dist/build/web/ /srv/pda-h5/
EXPOSE 80
-29
View File
@@ -1,29 +0,0 @@
name: firstleg-production-pda-domestic-h5
services:
pda-domestic-h5:
build:
context: ..
dockerfile: deploy/Dockerfile.h5.production
args:
NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
image: pda-domestic-h5-prod:${IMAGE_TAG:?IMAGE_TAG is required}
container_name: pda-domestic-h5-prod
restart: unless-stopped
environment:
API_UPSTREAM: "http://gateway:9000"
ports:
- "127.0.0.1:${HOST_PORT:-29005}:80"
healthcheck:
test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
networks:
backend:
aliases:
- pda-domestic-h5-prod
networks:
backend:
external: true
name: firstleg-production-network
-27
View File
@@ -1,27 +0,0 @@
name: firstleg-test-pda-domestic-h5
services:
pda-domestic-h5:
build:
context: ..
dockerfile: deploy/Dockerfile.h5
args:
NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
image: pda-domestic-h5-test:${IMAGE_TAG:?IMAGE_TAG is required}
container_name: pda-domestic-h5-test
restart: unless-stopped
expose:
- "80"
healthcheck:
test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
networks:
test-backend:
aliases:
- pda-domestic-h5-test
networks:
test-backend:
external: true
name: firstleg-test-network
-16
View File
@@ -1,16 +0,0 @@
server {
listen 80;
server_name _;
root /srv/pda-h5;
location = /healthz {
default_type text/plain;
add_header Cache-Control "no-store" always;
return 200 "ok\n";
}
location / {
index index.html;
try_files $uri $uri/ =404;
add_header Cache-Control "no-store" always;
}
}
+21
View File
@@ -0,0 +1,21 @@
# Merge into the existing site's server block; preserve TLS and panel includes.
# Render only PDA_SITE_ROOT and PDA_GATEWAY_ORIGIN (see README).
# Replace existing root/index and matching locations; do not append duplicates.
root ${PDA_SITE_ROOT};
index index.html;
location / {
try_files $uri $uri/ =404;
add_header Cache-Control "no-store" always;
}
location ^~ /gateway-api/ {
proxy_pass ${PDA_GATEWAY_ORIGIN}/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 55m;
}
-25
View File
@@ -1,25 +0,0 @@
server {
listen 80;
server_name _;
root /srv/pda-h5;
location = /healthz {
default_type text/plain;
add_header Cache-Control "no-store" always;
return 200 "ok\n";
}
location /gateway-api/ {
client_max_body_size 55m;
proxy_pass ${API_UPSTREAM}/api/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
index index.html;
try_files $uri $uri/ =404;
add_header Cache-Control "no-store" always;
}
}
@@ -88,7 +88,7 @@
import ScanTargetInput from '@/components/ScanTargetInput.vue' import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, getDropshipPalletizationTask, moveDropshipCarton, resolveDropshipWorkspaceCarton, resolveDropshipWorkspacePallet, sealDropshipPallet, unsealDropshipPallet } from '@/api/pda-dropship.js' import { bindDropshipPendingCartons, claimDropshipPalletizationTask, countDropshipCarton, getDropshipPalletizationTask, moveDropshipCarton, resolveDropshipWorkspaceCarton, resolveDropshipWorkspacePallet, sealDropshipPallet, unsealDropshipPallet } from '@/api/pda-dropship.js'
import { session, requireSession } from '@/stores/session.js' import { session, requireSession } from '@/stores/session.js'
import { formatApiError } from '@/utils/user-facing.js' import { sanitizeUserMessage } from '@/utils/user-facing.js'
import { stableOperationId } from '@/utils/idempotency.js' import { stableOperationId } from '@/utils/idempotency.js'
import { isOnline } from '@/utils/network.js' import { isOnline } from '@/utils/network.js'
import { clearDropshipPalletizingDraft, restoreDropshipPalletizingDraft, saveDropshipPalletizingDraft } from '@/stores/dropship-palletizing.js' import { clearDropshipPalletizingDraft, restoreDropshipPalletizingDraft, saveDropshipPalletizingDraft } from '@/stores/dropship-palletizing.js'
@@ -119,6 +119,10 @@
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() }, onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
onBackPress() { if (!this.unsealOpen) return false; this.closeUnseal(); return true }, onBackPress() { if (!this.unsealOpen) return false; this.closeUnseal(); return true },
methods: { methods: {
formatOperationError(error, fallback) {
console.error('[PDA dropship inbound]', { code: error?.code, requestId: error?.requestId, traceId: error?.traceId })
return sanitizeUserMessage(error?.message, fallback)
},
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: () => { this.error = '扫码头连接失败,可使用输入框继续作业' } }) }, startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: () => { this.error = '扫码头连接失败,可使用输入框继续作业' } }) },
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null }, stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
handleScan(value) { if (this.loading || this.submitting) return; if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return } const plan = planDropshipScan(createDropshipScanState({ online: isOnline() }), value, { unboundCartons: this.task?.unboundCartons }); if ([DROPSHIP_SCAN_INTENT.BLOCKED, DROPSHIP_SCAN_INTENT.INVALID].includes(plan.intent)) { this.error = plan.message; return } const code = plan.palletNo || plan.boxCode; if (plan.palletNo) { this.palletCode = code; this.selectPallet(code) } else { this.boxCode = code; this.resolveCarton() } }, handleScan(value) { if (this.loading || this.submitting) return; if (!this.palletSetupReady) { this.error = this.palletSetupMessage; return } const plan = planDropshipScan(createDropshipScanState({ online: isOnline() }), value, { unboundCartons: this.task?.unboundCartons }); if ([DROPSHIP_SCAN_INTENT.BLOCKED, DROPSHIP_SCAN_INTENT.INVALID].includes(plan.intent)) { this.error = plan.message; return } const code = plan.palletNo || plan.boxCode; if (plan.palletNo) { this.palletCode = code; this.selectPallet(code) } else { this.boxCode = code; this.resolveCarton() } },
@@ -127,7 +131,7 @@
if (!this.taskId || !this.warehouseId) { this.error = '缺少合托任务或仓库信息,请返回任务列表'; return } if (!this.taskId || !this.warehouseId) { this.error = '缺少合托任务或仓库信息,请返回任务列表'; return }
this.loading = true; this.resetMessages() this.loading = true; this.resetMessages()
try { this.task = await getDropshipPalletizationTask(this.taskId, this.warehouseId); await this.refreshPallet({ restorePallet }); if (this.task.taskStatus === 'COMPLETED') clearDropshipPalletizingDraft(this.draftIdentity()) } try { this.task = await getDropshipPalletizationTask(this.taskId, this.warehouseId); await this.refreshPallet({ restorePallet }); if (this.task.taskStatus === 'COMPLETED') clearDropshipPalletizingDraft(this.draftIdentity()) }
catch (error) { this.error = formatApiError(error, '一件代发入库任务加载失败') } catch (error) { this.error = this.formatOperationError(error, '一件代发入库任务加载失败') }
finally { this.loading = false } finally { this.loading = false }
}, },
async claimTask() { await this.runMutation(() => claimDropshipPalletizationTask(this.taskId, this.warehouseId), '任务领取成功') }, async claimTask() { await this.runMutation(() => claimDropshipPalletizationTask(this.taskId, this.warehouseId), '任务领取成功') },
@@ -137,7 +141,7 @@
const code = String(value || this.boxCode || '').trim().toUpperCase(); if (!code || this.loading) return const code = String(value || this.boxCode || '').trim().toUpperCase(); if (!code || this.loading) return
this.boxCode = code; this.loading = true; this.resetMessages(); this.carton = null this.boxCode = code; this.loading = true; this.resetMessages(); this.carton = null
try { const resolved = await resolveDropshipWorkspaceCarton(this.warehouseId, code); this.scanTask = resolved.task; this.carton = resolved.carton; this.skuCounts = (this.carton.skuLines || []).map((line) => ({ ...line, actualQuantity: String(line.actualQuantity ?? line.expectedQuantity ?? ''), discrepancyRemark: line.discrepancyRemark || '' })) } try { const resolved = await resolveDropshipWorkspaceCarton(this.warehouseId, code); this.scanTask = resolved.task; this.carton = resolved.carton; this.skuCounts = (this.carton.skuLines || []).map((line) => ({ ...line, actualQuantity: String(line.actualQuantity ?? line.expectedQuantity ?? ''), discrepancyRemark: line.discrepancyRemark || '' })) }
catch (error) { this.error = formatApiError(error, '箱码解析失败') } catch (error) { this.error = this.formatOperationError(error, '箱码解析失败') }
finally { this.loading = false } finally { this.loading = false }
}, },
async countCarton() { async countCarton() {
@@ -167,7 +171,7 @@
this.palletCode = code this.palletCode = code
this.loading = true; this.resetMessages(); this.workspace = null this.loading = true; this.resetMessages(); this.workspace = null
try { this.workspace = await resolveDropshipWorkspacePallet(this.warehouseId, code); this.selectedPalletNo = code; this.palletCode = code; this.selectedPalletId = String(this.workspace.pallet.palletId); this.activeScan = 'boxCode'; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: code }) } try { this.workspace = await resolveDropshipWorkspacePallet(this.warehouseId, code); this.selectedPalletNo = code; this.palletCode = code; this.selectedPalletId = String(this.workspace.pallet.palletId); this.activeScan = 'boxCode'; saveDropshipPalletizingDraft(this.draftIdentity(), { activePalletNo: code }) }
catch (error) { this.selectedPalletNo = ''; this.error = formatApiError(error, '托盘校验失败'); return } catch (error) { this.selectedPalletNo = ''; this.error = this.formatOperationError(error, '托盘校验失败'); return }
finally { this.loading = false } finally { this.loading = false }
const pallet = this.currentPallet const pallet = this.currentPallet
if (pallet?.status === 'SEALED') { this.notice = `托盘 ${code} 已封托,可在托盘操作区解除封托`; return } if (pallet?.status === 'SEALED') { this.notice = `托盘 ${code} 已封托,可在托盘操作区解除封托`; return }
@@ -235,10 +239,10 @@
? result ? result
: await getDropshipPalletizationTask(this.taskId, this.warehouseId) : await getDropshipPalletizationTask(this.taskId, this.warehouseId)
this.notice = message this.notice = message
try { await this.refreshPallet() } catch (error) { this.error = formatApiError(error, '操作已成功,托盘汇总刷新失败,请刷新后继续') } try { await this.refreshPallet() } catch (error) { this.error = this.formatOperationError(error, '操作已成功,托盘汇总刷新失败,请刷新后继续') }
return result return result
} }
catch (error) { this.error = formatApiError(error, '操作失败'); return null } catch (error) { this.error = this.formatOperationError(error, '操作失败'); return null }
finally { this.submitting = false } finally { this.submitting = false }
} }
} }
@@ -262,7 +266,7 @@
.section-title { display: block; color: #12233f; font-size: 18px; font-weight: 700; }.section-heading { align-items: center; }.refresh-button { display: flex; align-items: center; justify-content: center; flex-shrink: 0; box-sizing: border-box; min-width: 88px; height: 44px; margin: 0; padding: 0 16px; border: 1px solid #9dc0f4; border-radius: 8px; color: #155eef; font-size: 16px; font-weight: 700; line-height: normal; background: #eaf2ff; }.refresh-button::after { border: 0; }.refresh-button[disabled] { color: #718095; border-color: #dce5f0; background: #e8edf3; } .section-title { display: block; color: #12233f; font-size: 18px; font-weight: 700; }.section-heading { align-items: center; }.refresh-button { display: flex; align-items: center; justify-content: center; flex-shrink: 0; box-sizing: border-box; min-width: 88px; height: 44px; margin: 0; padding: 0 16px; border: 1px solid #9dc0f4; border-radius: 8px; color: #155eef; font-size: 16px; font-weight: 700; line-height: normal; background: #eaf2ff; }.refresh-button::after { border: 0; }.refresh-button[disabled] { color: #718095; border-color: #dce5f0; background: #e8edf3; }
.primary-button, .secondary-button { box-sizing: border-box; width: 100%; height: 46px; margin-top: 10px; border-radius: 9px; font-size: 14px; font-weight: 700; line-height: 44px; }.primary-button { border: 0; color: #fff; background: #1a73e8; }.secondary-button { border: 1px solid #8ab8ef; color: #155eef; background: #f6f9ff; }.primary-button[disabled],.secondary-button[disabled] { opacity: .45; } .primary-button, .secondary-button { box-sizing: border-box; width: 100%; height: 46px; margin-top: 10px; border-radius: 9px; font-size: 14px; font-weight: 700; line-height: 44px; }.primary-button { border: 0; color: #fff; background: #1a73e8; }.secondary-button { border: 1px solid #8ab8ef; color: #155eef; background: #f6f9ff; }.primary-button[disabled],.secondary-button[disabled] { opacity: .45; }
.unseal-button { border-color: #fda29b; color: #b42318; background: #fff5f4; }.pallet-hint.blocked { color: #b42318; } .unseal-button { border-color: #fda29b; color: #b42318; background: #fff5f4; }.pallet-hint.blocked { color: #b42318; }
.unseal-dialog { box-sizing: border-box; width: min(350px, calc(100vw - 32px)); overflow: hidden; border-radius: 12px; background: #fff; }.unseal-copy { display: block; padding: 18px 16px; color: #475467; font-size: 14px; line-height: 21px; }.unseal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 16px 16px; }.unseal-actions .secondary-button { margin-top: 0; }.danger-button { height: 46px; margin: 0; border: 0; border-radius: 9px; color: #fff; font-size: 14px; font-weight: 700; background: #d92d20; }.danger-button[disabled] { opacity: .45; } .unseal-dialog { box-sizing: border-box; width: min(350px, calc(100vw - 32px)); overflow: hidden; border-radius: 12px; background: #fff; }.unseal-copy { display: block; padding: 18px 16px; color: #475467; font-size: 14px; line-height: 21px; }.unseal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 16px 16px; }.unseal-actions .secondary-button { margin-top: 0; }.danger-button { display: flex; align-items: center; justify-content: center; padding: 0; line-height: 46px; height: 46px; margin: 0; border: 0; border-radius: 9px; color: #fff; font-size: 14px; font-weight: 700; background: #d92d20; }.danger-button[disabled] { opacity: .45; }
.count-entry { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; padding: 5px 7px; border: 0; border-radius: 0; color: #667085; font-size: 13px; background: #f6f9ff; }.compact-action { width: 86px; height: 34px; margin: 0; border: 0; border-radius: 6px; color: #fff; font-size: 13px; line-height: 34px; background: #1a73e8; }.compact-action[disabled] { opacity: .35; }.count-unavailable { padding: 12px 8px; color: #b42318; font-size: 13px; line-height: 18px; text-align: center; background: #fff1f0; }.pallet-scan-block { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e6edf5; }.field-label { display: block; margin-bottom: 5px; color: #344054; font-size: 15px; font-weight: 700; }.field-label text { margin-left: 3px; color: #98a2b3; font-size: 12px; font-weight: 400; } .count-entry { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; padding: 5px 7px; border: 0; border-radius: 0; color: #667085; font-size: 13px; background: #f6f9ff; }.compact-action { width: 86px; height: 34px; margin: 0; border: 0; border-radius: 6px; color: #fff; font-size: 13px; line-height: 34px; background: #1a73e8; }.compact-action[disabled] { opacity: .35; }.count-unavailable { padding: 12px 8px; color: #b42318; font-size: 13px; line-height: 18px; text-align: center; background: #fff1f0; }.pallet-scan-block { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e6edf5; }.field-label { display: block; margin-bottom: 5px; color: #344054; font-size: 15px; font-weight: 700; }.field-label text { margin-left: 3px; color: #98a2b3; font-size: 12px; font-weight: 400; }
.carton-card, .pallet-card { margin-top: 8px; padding: 8px 0 0; border: 0; border-top: 1px solid #e3eaf2; border-radius: 0; background: transparent; }.sku-count-row { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #edf1f5; }.sku-copy { min-width: 0; }.sku-label { display: block; color: #667085; font-size: 11px; }.sku-code { display: block; margin-top: 2px; color: #12233f; font-size: 16px; font-weight: 700; overflow-wrap: anywhere; }.pallet-meta { display: block; margin-top: 5px; color: #667085; font-size: 14px; line-height: 20px; } .carton-card, .pallet-card { margin-top: 8px; padding: 8px 0 0; border: 0; border-top: 1px solid #e3eaf2; border-radius: 0; background: transparent; }.sku-count-row { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #edf1f5; }.sku-copy { min-width: 0; }.sku-label { display: block; color: #667085; font-size: 11px; }.sku-code { display: block; margin-top: 2px; color: #12233f; font-size: 16px; font-weight: 700; overflow-wrap: anywhere; }.pallet-meta { display: block; margin-top: 5px; color: #667085; font-size: 14px; line-height: 20px; }
.quantity-stepper { display: grid; grid-template-columns: 38px 56px 38px; overflow: hidden; border: 1px solid #b8c6d8; border-radius: 8px; background: #fff; }.quantity-stepper button,.quantity-stepper input { box-sizing: border-box; height: 40px; margin: 0; border: 0; border-radius: 0; text-align: center; background: #fff; }.quantity-stepper button { color: #155eef; font-size: 20px; line-height: 40px; }.quantity-stepper input { width: 56px; border-right: 1px solid #dce4ed; border-left: 1px solid #dce4ed; color: #12233f; font-size: 15px; }.difference-field { grid-column: 1 / -1; margin-top: 0; }.pallet-hint { display: block; margin-top: 10px; color: #155eef; font-size: 11px; text-align: center; } .quantity-stepper { display: grid; grid-template-columns: 38px 56px 38px; overflow: hidden; border: 1px solid #b8c6d8; border-radius: 8px; background: #fff; }.quantity-stepper button,.quantity-stepper input { box-sizing: border-box; height: 40px; margin: 0; border: 0; border-radius: 0; text-align: center; background: #fff; }.quantity-stepper button { color: #155eef; font-size: 20px; line-height: 40px; }.quantity-stepper input { width: 56px; border-right: 1px solid #dce4ed; border-left: 1px solid #dce4ed; color: #12233f; font-size: 15px; }.difference-field { grid-column: 1 / -1; margin-top: 0; }.pallet-hint { display: block; margin-top: 10px; color: #155eef; font-size: 11px; text-align: center; }
+8 -2
View File
@@ -34,16 +34,22 @@
import ScanTargetInput from '@/components/ScanTargetInput.vue' import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { listDropshipPalletizationTasks, resolveDropshipWorkspacePallet } from '@/api/pda-dropship.js' import { listDropshipPalletizationTasks, resolveDropshipWorkspacePallet } from '@/api/pda-dropship.js'
import { session, requireSession } from '@/stores/session.js' import { session, requireSession } from '@/stores/session.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { formatApiError } from '@/utils/user-facing.js' import { formatApiError } from '@/utils/user-facing.js'
export default { export default {
components: { PortalHeader, ScanTargetInput }, components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, keyword: '', tasks: [], loading: false, error: '', palletNo: '' } }, data() { return { user: session.currentUser, keyword: '', tasks: [], loading: false, error: '', palletNo: '' } },
onLoad(options) { this.palletNo = String(options.palletNo || '') }, onLoad(options) { this.palletNo = String(options.palletNo || '') },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); this.loadTasks() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: { methods: {
// 入仓码广播直接驱动查询,不依赖输入框焦点。
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => { if (this.loading) return; this.keyword = String(value).trim(); this.loadTasks() }, onError: () => { this.error = '扫码头连接失败,可输入入仓码查询' } }) },
stopScanner() { this._stopScanner?.(); this._stopScanner = null },
statusLabel(status) { return status === 'CLAIMED' ? '作业中' : '待领取' }, statusLabel(status) { return status === 'CLAIMED' ? '作业中' : '待领取' },
async loadTasks() { async loadTasks(value) {
if (typeof value === 'string') this.keyword = value.trim()
if (this.loading) return if (this.loading) return
if (!this.user?.warehouseId) { this.error = '请返回首页选择作业仓库'; return } if (!this.user?.warehouseId) { this.error = '请返回首页选择作业仓库'; return }
this.loading = true; this.error = '' this.loading = true; this.error = ''
+23 -2
View File
@@ -693,7 +693,8 @@
return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409 return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409
}, },
remoteFailureMessage(error, fallback = '操作失败') { remoteFailureMessage(error, fallback = '操作失败') {
return formatApiError(error, fallback) console.error('[PDA picking] failed', { message: error?.message, code: error?.code, requestId: error?.requestId, traceId: error?.traceId })
return formatApiError(error, fallback, false)
}, },
async refreshAfterTaskConflict(message) { async refreshAfterTaskConflict(message) {
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
@@ -921,8 +922,13 @@
}) })
}, },
persistSession() { persistSession() {
const identity = this.draftIdentity()
const completed = this.remotePick?.taskStatus === 'COMPLETED'
if (completed) this.completedBoxes = { ...this.completedBoxes, [this.currentBoxIndex]: true }
this.saveCurrentBoxDraft() this.saveCurrentBoxDraft()
savePickingSession({ boxes: this.boxes, currentBoxIndex: this.currentBoxIndex, itemStates: this.itemStates, partialQuantities: this.partialQuantities, scannedBoxCodes: this.scannedBoxCodes, boxDrafts: this.boxDrafts, completedBoxes: this.completedBoxes, scanValue: this.scanValue, scanHistory: this.scanHistory }, this.draftIdentity()) // 完成任务仍更新作业记录的内存快照,空身份阻止重新写入其持久化草稿。
savePickingSession({ boxes: this.boxes, currentBoxIndex: this.currentBoxIndex, itemStates: this.itemStates, partialQuantities: this.partialQuantities, scannedBoxCodes: this.scannedBoxCodes, boxDrafts: this.boxDrafts, completedBoxes: this.completedBoxes, scanValue: this.scanValue, scanHistory: this.scanHistory }, completed ? {} : identity)
if (completed) clearPickingDraft(identity)
}, },
leavePicking() { this.persistSession(); this.activeOverlay = ''; navigateBackOr('/pages/home/home') }, leavePicking() { this.persistSession(); this.activeOverlay = ''; navigateBackOr('/pages/home/home') },
clearTimers() { clearTimeout(this._toastTimer); clearTimeout(this._evidencePreviewOpenTimer) }, clearTimers() { clearTimeout(this._toastTimer); clearTimeout(this._evidencePreviewOpenTimer) },
@@ -1185,6 +1191,16 @@
this.currentBox.boxNo = task.currentContainerNo this.currentBox.boxNo = task.currentContainerNo
this.currentBox.currentContainerNo = task.currentContainerNo this.currentBox.currentContainerNo = task.currentContainerNo
const remoteTask = normalizePickingTask(task) const remoteTask = normalizePickingTask(task)
// 刷新确认后台已完成时直接返回任务列表,不再停留在已完成柜子内。
if (remoteTask.taskStatus === 'COMPLETED') {
this.remotePick = remoteTask
this.pickingVersion = remoteTask.pickingVersion ?? remoteTask.version
if (this.activeOverlay) this.closeOverlay()
this.boxDropdownOpen = false
this.persistSession()
navigateBackOr('/pages/tasks/tasks')
return
}
this.assignmentVersion = remoteTask.assignment?.version ?? remoteTask.assignmentVersion ?? (remoteTask.workflowStatus === 'UNASSIGNED' ? 0 : null) this.assignmentVersion = remoteTask.assignment?.version ?? remoteTask.assignmentVersion ?? (remoteTask.workflowStatus === 'UNASSIGNED' ? 0 : null)
if (remoteTask.workflowStatus !== 'AWAITING_REVIEW' && this.assignmentVersion === null) throw new Error('任务领取状态尚未同步,请刷新重试') if (remoteTask.workflowStatus !== 'AWAITING_REVIEW' && this.assignmentVersion === null) throw new Error('任务领取状态尚未同步,请刷新重试')
let claimed = null let claimed = null
@@ -1469,6 +1485,11 @@
} }
this.remoteError = '' this.remoteError = ''
this.showToast('装柜已完成', 'success') this.showToast('装柜已完成', 'success')
// 收起页面内浮层,避免 onBackPress 拦截完成后的程序化返回。
if (this.activeOverlay) this.closeOverlay()
this.boxDropdownOpen = false
this.persistSession()
navigateBackOr('/pages/tasks/tasks')
} catch (error) { } catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return if (!this.isEvidenceContextActive(contextId, identity)) return
if (accepted) { if (accepted) {
+39 -1
View File
@@ -2,6 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import vm from 'node:vm' import vm from 'node:vm'
import { sanitizeUserMessage } from '../utils/user-facing.js'
const source = await readFile(new URL('../pages/dropship-palletization/dropship-palletization.vue', import.meta.url), 'utf8') const source = await readFile(new URL('../pages/dropship-palletization/dropship-palletization.vue', import.meta.url), 'utf8')
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/gm, '').replace('export default', 'globalThis.page =') const script = source.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/gm, '').replace('export default', 'globalThis.page =')
@@ -18,7 +19,7 @@ function harness(overrides = {}) {
countDropshipCarton: async (id, payload) => { calls.push(['count', id, payload]); return { carton: {} } }, countDropshipCarton: async (id, payload) => { calls.push(['count', id, payload]); return { carton: {} } },
moveDropshipCarton: async (id, cartonId, payload) => { calls.push(['move', id, cartonId, payload]); return { ...target, taskId: id } }, moveDropshipCarton: async (id, cartonId, payload) => { calls.push(['move', id, cartonId, payload]); return { ...target, taskId: id } },
saveDropshipPalletizingDraft: () => {}, clearDropshipPalletizingDraft: () => {}, stableOperationId: () => 'stable-key', saveDropshipPalletizingDraft: () => {}, clearDropshipPalletizingDraft: () => {}, stableOperationId: () => 'stable-key',
isOnline: () => true, formatApiError: (error) => error.message, uni: { redirectTo: (value) => calls.push(['redirect', value]) }, isOnline: () => true, sanitizeUserMessage, console: { error: (...args) => calls.push(['diagnostic', ...args]) }, uni: { redirectTo: (value) => calls.push(['redirect', value]) },
...overrides ...overrides
} }
vm.runInNewContext(script, context) vm.runInNewContext(script, context)
@@ -28,6 +29,19 @@ function harness(overrides = {}) {
return { state, calls, pallet, target } return { state, calls, pallet, target }
} }
test('scan failures show the business message and retain correlation evidence in diagnostics', async () => {
const failure = { message: '箱码不属于当前一件代发入仓单', code: 'A0404', requestId: 'request-test', traceId: 'trace-test' }
const { state, calls } = harness({ resolveDropshipWorkspaceCarton: async () => { throw failure } })
await state.resolveCarton('WRONG-BOX')
assert.equal(state.error, failure.message)
assert.equal(state.loading, false)
const evidence = calls.find(call => call[0] === 'diagnostic')[2]
assert.equal(evidence.code, failure.code)
assert.equal(evidence.requestId, failure.requestId)
assert.equal(evidence.traceId, failure.traceId)
assert.equal(state.formatOperationError({}, '箱码解析失败'), '箱码解析失败')
})
test('cross customer scan remains read only until confirmation and then claims original task', async () => { test('cross customer scan remains read only until confirmation and then claims original task', async () => {
const { state, calls, pallet, target } = harness() const { state, calls, pallet, target } = harness()
state.workspace = { pallet, members: [] }; state.selectedPalletNo = pallet.palletNo state.workspace = { pallet, members: [] }; state.selectedPalletNo = pallet.palletNo
@@ -116,3 +130,27 @@ test('task list stops carrying a pallet sealed while another ticket was being wo
assert.equal(state.palletNo, '') assert.equal(state.palletNo, '')
assert.ok(!navigations[1].includes('DP-261019-0014')) assert.ok(!navigations[1].includes('DP-261019-0014'))
}) })
test('task list hardware scan searches inbound code and releases receiver when hidden', async () => {
const taskSource = await readFile(new URL('../pages/dropship-tasks/dropship-tasks.vue', import.meta.url), 'utf8')
const script = taskSource.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/gm, '').replace('export default', 'globalThis.page =')
let receiver, stopped = 0
const searches = []
const context = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: { warehouseId: '100' } }, requireSession: () => true,
startScanBroadcast: options => { receiver = options; return () => { stopped++ } },
listDropshipPalletizationTasks: async (code, warehouse) => { searches.push([code, warehouse]); return [] },
formatApiError: error => error.message }
vm.runInNewContext(script, context)
const state = Object.assign(context.page.data(), context.page.methods)
context.page.onShow.call(state)
await new Promise(resolve => setImmediate(resolve))
receiver.onScan(' IN-2611050001 ')
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(searches.at(-1), ['IN-2611050001', '100'])
state.loading = true
receiver.onScan('SECOND')
assert.equal(searches.length, 2)
context.page.onHide.call(state)
context.page.onUnload.call(state)
assert.equal(stopped, 1)
})
+20 -47
View File
@@ -1,43 +1,28 @@
import test from 'node:test' import test from 'node:test'
import { runInNewContext } from 'node:vm' import { runInNewContext } from 'node:vm'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises' import { readFile, readdir } 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('H5 image copies only compiled assets and static nginx configuration', async () => { test('PDA H5 has no container publishing entry', async () => {
const dockerfile = await read('../deploy/Dockerfile.h5') const files = await readdir(new URL('../deploy/', import.meta.url))
const copies = dockerfile.split(/\r?\n/).filter(line => line.startsWith('COPY ')) assert.deepEqual(files.filter(name => /docker|compose/i.test(name)), [])
assert.deepEqual(copies, ['COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf', 'COPY unpackage/dist/build/web/ /srv/pda-h5/']) await assert.rejects(read('../.dockerignore'), { code: 'ENOENT' })
assert.match(dockerfile, /ARG NGINX_BASE_IMAGE=/)
assert.match(dockerfile, /FROM \$\{NGINX_BASE_IMAGE\}/)
assert.doesNotMatch(dockerfile, /RUN |npm|node_modules|COPY \. /)
const ignore = await read('../.dockerignore')
assert.ok(ignore.split(/\r?\n/).includes('**'))
assert.ok(ignore.includes('!unpackage/dist/build/web/**'))
assert.doesNotMatch(ignore, /!\.env|!node_modules|!src/)
}) })
test('static server serves the domain root and returns genuine missing-resource errors', async () => {
const nginx = await read('../deploy/nginx.conf') test('host nginx serves uploaded files and proxies only the API prefix', async () => {
assert.match(nginx, /listen 80;/) const nginx = await read('../deploy/nginx.conf.template')
assert.ok(nginx.includes('location /')) assert.ok(nginx.includes('root ${PDA_SITE_ROOT};'))
assert.match(nginx, /root \/srv\/pda-h5;/) const page = nginx.match(/location \/ \{([^{}]*)\}/)?.[1]
assert.match(nginx, /try_files \$uri \$uri\/ =404;/) assert.ok(page)
assert.match(nginx, /Cache-Control "no-store" always/) assert.match(page, /try_files \$uri \$uri\/ =404;/)
assert.doesNotMatch(nginx, /pda-domestic|pda-overseas/) assert.match(page, /Cache-Control "no-store" always/)
assert.match(nginx, /location = \/healthz/) assert.doesNotMatch(page, /proxy_pass|try_files[^;]*index\.html/)
assert.doesNotMatch(nginx, /proxy_pass|gateway-api|try_files[^;]*index\.html/) assert.match(nginx, /location \^~ \/gateway-api\//)
}) assert.ok(nginx.includes('proxy_pass ${PDA_GATEWAY_ORIGIN}/api/;'))
test('TEST H5 deployment has a required image tag and internal-only dual health checks', async () => { assert.equal((nginx.match(/proxy_pass/g) || []).length, 1)
const compose = await read('../deploy/docker-compose.h5.test.yml') assert.match(nginx, /proxy_set_header Authorization \$http_authorization;/)
assert.ok(compose.includes('${IMAGE_TAG:?IMAGE_TAG is required}')) assert.match(nginx, /client_max_body_size 55m;/)
assert.match(compose, /context: \.\./) assert.doesNotMatch(nginx, /29005|29006|\/srv\/pda-h5|gateway:9000/)
assert.match(compose, /dockerfile: deploy\/Dockerfile\.h5/)
assert.match(compose, /container_name: pda-domestic-h5-test/)
assert.match(compose, /aliases:\s*\n\s*- pda-domestic-h5-test/)
assert.match(compose, /external: true/)
assert.match(compose, /name: firstleg-test-network/)
assert.match(compose, /expose:\s*\n\s*- "80"/)
assert.ok(compose.includes('http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/'))
assert.doesNotMatch(compose, /\bports:|volumes:|API_UPSTREAM|privileged:|pid: host/)
}) })
test('only H5 production overrides the toolchain asset base', async () => { test('only H5 production overrides the toolchain asset base', async () => {
@@ -72,15 +57,3 @@ test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async
assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`) assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`)
} }
}) })
test('gray H5 runs on an isolated network with same-origin Gateway API', async () => {
const compose = await read('../deploy/docker-compose.h5.production.yml')
const nginx = await read('../deploy/nginx.production.conf.template')
const docker = await read('../deploy/Dockerfile.h5.production')
assert.match(compose, /name: firstleg-production-network/)
assert.match(compose, /127\.0\.0\.1:/)
assert.doesNotMatch(compose, /test-network|gateway-overseas/)
assert.match(nginx, /location \/gateway-api\//)
assert.ok(nginx.includes('proxy_pass ${API_UPSTREAM}/api/;'))
assert.match(docker, /COPY deploy\/nginx.production.conf.template \/etc\/nginx\/templates\/default.conf.template/)
})
+4 -2
View File
@@ -63,18 +63,19 @@ test('query failure is not retried as a completion mutation and completed accept
readTask: async () => assert.fail('no query after authoritative completion'), isActive: () => true, onTask() {} }), completed) readTask: async () => assert.fail('no query after authoritative completion'), isActive: () => true, onTask() {} }), completed)
}) })
test('page only announces success without persisting a replay request after authoritative completion', async () => { test('page only returns to the task list after authoritative completion without persisting a replay request', async () => {
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')
const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0]
assert.ok(method) assert.ok(method)
for (const status of ['COMPLETED', 'COMPLETION_FAILED', 'COMPLETING', 'LOADING', 'QUERY_ERROR', 'NO_RESPONSE']) { for (const status of ['COMPLETED', 'COMPLETION_FAILED', 'COMPLETING', 'LOADING', 'QUERY_ERROR', 'NO_RESPONSE']) {
const messages = [], cleared = [], saved = [] const messages = [], cleared = [], saved = [], destinations = []
const bindings = { const bindings = {
COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion, COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
isRemoteReady: () => true, restoreLoadingCompletionAttempt: () => null, isRemoteReady: () => true, restoreLoadingCompletionAttempt: () => null,
stableTaskOperationIdentity: () => 'stable-task', stableOperationId: () => 'original-operation', stableTaskOperationIdentity: () => 'stable-task', stableOperationId: () => 'original-operation',
saveLoadingCompletionAttempt: (_, attempt) => saved.push(attempt), saveLoadingCompletionAttempt: (_, attempt) => saved.push(attempt),
clearLoadingCompletionAttempt: identity => cleared.push(identity), clearLoadingCompletionAttempt: identity => cleared.push(identity),
navigateBackOr: url => destinations.push(url),
completeContainerPickingTask: async () => status === 'NO_RESPONSE' ? null : { taskStatus: 'COMPLETING', version: 2 }, completeContainerPickingTask: async () => status === 'NO_RESPONSE' ? null : { taskStatus: 'COMPLETING', version: 2 },
normalizePickingTask: task => task, normalizePickingTask: task => task,
observeLoadingCompletion: async options => { observeLoadingCompletion: async options => {
@@ -99,6 +100,7 @@ test('page only announces success without persisting a replay request after auth
assert.equal(saved.length, 0) assert.equal(saved.length, 0)
assert.equal(cleared.length, 0) assert.equal(cleared.length, 0)
assert.equal(messages.some(message => message.message === '装柜已完成'), status === 'COMPLETED') assert.equal(messages.some(message => message.message === '装柜已完成'), status === 'COMPLETED')
assert.deepEqual(destinations, status === 'COMPLETED' ? ['/pages/tasks/tasks'] : [])
if (status !== 'COMPLETED') assert.ok(context.remoteError) if (status !== 'COMPLETED') assert.ok(context.remoteError)
} }
}) })
+4 -4
View File
@@ -85,8 +85,8 @@ test('picking page exposes five evidence slots, retry and remote completion stat
for (const text of ['PHOTO_STEP_1', 'PHOTO_STEP_4', 'SEAL', '网络失败', '确认装柜完成']) { 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, /async queueEvidenceUpload\(type, path, sealNo = null\)/)
assert.match(page, /retryEvidence/) assert.match(page, /@tap="refreshTask"/)
assert.match(page, /uploadEvidenceFile/) assert.match(page, /uploadEvidenceFile/)
assert.match(page, /submitting \|\| \(!pickingCompleted && !summary\.canSubmit\)/) assert.match(page, /submitting \|\| \(!pickingCompleted && !summary\.canSubmit\)/)
assert.doesNotMatch(page, /completePicking\(/) assert.doesNotMatch(page, /completePicking\(/)
@@ -135,8 +135,8 @@ test('cabinet evidence never selects an inbound owner or builds a container rout
const evidenceAdapter = source.match(/export function getEvidence\(taskId, warehouseId\)[\s\S]*?export function completeContainerPickingTask/)?.[0] || '' 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)) for (const field of ['inboundId', 'tenantId', 'containerBookingId']) assert.doesNotMatch(evidenceAdapter, new RegExp(field))
assert.match(page, /captureEvidenceIdentity\(\) \{[\s\S]*?taskId:/) assert.match(page, /captureEvidenceIdentity\(\) \{[\s\S]*?taskId:/)
assert.match(page, /uploadEvidenceFile\(item\.taskId, item\.warehouseId, item, item\.localPath/) assert.match(page, /uploadEvidenceFile\(identity\.taskId, identity\.warehouseId,[\s\S]*?\}, path, progress/)
assert.match(page, /queue\.add\(\{ taskId: identity\.taskId, warehouseId: identity\.warehouseId, evidenceType: type, expectedVersion/) assert.match(page, /evidenceType: type, expectedVersion: current\?\.version \?\? 0, sealNo/)
assert.doesNotMatch(page, /evidenceIdentity\?\.inboundId|identity\.inboundId/) assert.doesNotMatch(page, /evidenceIdentity\?\.inboundId|identity\.inboundId/)
assert.doesNotMatch(page, /currentBox\.warehouseId \|\| session\.currentUser/) assert.doesNotMatch(page, /currentBox\.warehouseId \|\| session\.currentUser/)
assert.match(source, /warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/) assert.match(source, /warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/)
+2 -2
View File
@@ -266,9 +266,9 @@ test('picking mutations stay closed until the canonical task has been claimed',
} }
}) })
test('completion shows a Chinese server failure with a request correlation id', () => { test('completion shows a concise Chinese failure and retains correlation ids in diagnostics', () => {
assert.match(page, /remoteFailureMessage\(error, fallback = '操作失败'\)/) assert.match(page, /remoteFailureMessage\(error, fallback = '操作失败'\)/)
assert.match(page, /formatApiError\(error, fallback\)/) assert.match(page, /formatApiError\(error, fallback, false\)/)
assert.match(page, /import \{ containerReference, formatApiError, sanitizeUserMessage \}/) assert.match(page, /import \{ containerReference, formatApiError, sanitizeUserMessage \}/)
assert.match(page, /requestIdFromServer: error\.requestId/) assert.match(page, /requestIdFromServer: error\.requestId/)
assert.match(page, /this\.remoteFailureMessage\(error, '装柜完成失败'\)/) assert.match(page, /this\.remoteFailureMessage\(error, '装柜完成失败'\)/)
+5 -3
View File
@@ -7,12 +7,13 @@ const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.
const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)[0] const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)[0]
function completion(overrides = {}, savedAttempt = null) { function completion(overrides = {}, savedAttempt = null) {
let attempt = savedAttempt let attempt = savedAttempt
const calls = [], cleared = [], messages = [] const calls = [], cleared = [], messages = [], destinations = []
const bindings = { const bindings = {
COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion, COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
isRemoteReady: () => true, stableTaskOperationIdentity: () => 'task', stableOperationId: (_action, _identity, payload) => `complete-v${payload.expectedTaskVersion}`, isRemoteReady: () => true, stableTaskOperationIdentity: () => 'task', stableOperationId: (_action, _identity, payload) => `complete-v${payload.expectedTaskVersion}`,
restoreLoadingCompletionAttempt: () => attempt, saveLoadingCompletionAttempt: (_identity, value) => { attempt = value }, restoreLoadingCompletionAttempt: () => attempt, saveLoadingCompletionAttempt: (_identity, value) => { attempt = value },
clearLoadingCompletionAttempt: identity => { cleared.push(identity); attempt = null }, clearLoadingCompletionAttempt: identity => { cleared.push(identity); attempt = null },
navigateBackOr: url => destinations.push(url),
completeContainerPickingTask: async (_task, payload) => { calls.push(payload); return { taskStatus: 'COMPLETED' } }, completeContainerPickingTask: async (_task, payload) => { calls.push(payload); return { taskStatus: 'COMPLETED' } },
normalizePickingTask: task => task, observeLoadingCompletion: async options => options.initialTask, normalizePickingTask: task => task, observeLoadingCompletion: async options => options.initialTask,
sanitizeUserMessage: value => value, ...overrides sanitizeUserMessage: value => value, ...overrides
@@ -27,15 +28,16 @@ function completion(overrides = {}, savedAttempt = null) {
remoteFailureMessage: error => error.message, showToast: message => messages.push(message) remoteFailureMessage: error => error.message, showToast: message => messages.push(message)
} }
state.complete = behavior.completeLoadingTask.bind(state) state.complete = behavior.completeLoadingTask.bind(state)
return { state, calls, cleared, messages, attempt: () => attempt } return { state, calls, cleared, messages, destinations, attempt: () => attempt }
} }
test('completion in flight blocks a second click and releases busy after the task context changes', async () => { test('completion in flight blocks a second click and releases busy after the task context changes', async () => {
let resolve let resolve
const { state, calls } = completion({ completeContainerPickingTask: (...args) => { calls.push(args); return new Promise(done => { resolve = done }) } }) const { state, calls, destinations } = completion({ completeContainerPickingTask: (...args) => { calls.push(args); return new Promise(done => { resolve = done }) } })
const first = state.complete(); await state.complete(); assert.equal(calls.length, 1) const first = state.complete(); await state.complete(); assert.equal(calls.length, 1)
state.evidenceContextId++; resolve({ taskStatus: 'COMPLETED' }); await first state.evidenceContextId++; resolve({ taskStatus: 'COMPLETED' }); await first
assert.equal(state.submitting, false); assert.equal(state.remotePick.taskStatus, undefined) assert.equal(state.submitting, false); assert.equal(state.remotePick.taskStatus, undefined)
assert.deepEqual(destinations, [])
}) })
test('an old completion response cannot release a newer picking submission', async () => { test('an old completion response cannot release a newer picking submission', async () => {
+78
View File
@@ -2,6 +2,8 @@ import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import vm from 'node:vm' import vm from 'node:vm'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { navigateBackOr } from '../utils/navigation.js'
import { COMPLETION_PHASE, observeLoadingCompletion, resolveCompletionPhase, shouldReconcileCompletion } from '../utils/loading-completion-state.js'
const source = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8') const source = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1] const script = source.match(/<script>([\s\S]*?)<\/script>/)[1]
@@ -107,6 +109,82 @@ test('failed request releases submit guard and retry keeps operation identity',
const task = () => ({ taskId: 'task', recordStatus: 'ACTIVE', loadingPlanId: 'plan', containerBookingId: 'container', const task = () => ({ taskId: 'task', recordStatus: 'ACTIVE', loadingPlanId: 'plan', containerBookingId: 'container',
currentContainerNo: 'TEST', version: 1, workflowStatus: 'UNASSIGNED', items: [{ id: 'item', packageCount: 2, itemStatus: 'PENDING' }] }) currentContainerNo: 'TEST', version: 1, workflowStatus: 'UNASSIGNED', items: [{ id: 'item', packageCount: 2, itemStatus: 'PENDING' }] })
test('refreshing a completed task returns to the list before claiming or loading evidence', async () => {
const destinations = []; let claims = 0, evidence = 0
const { ctx } = mount({ getContainerPickingTask: async () => ({ ...task(), taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' }),
navigateBackOr: url => destinations.push(url),
claimContainerPickingTask: async () => { claims++ } }, { loadLoadingEvidence: async () => { evidence++ } })
await ctx.syncRemoteTask()
assert.deepEqual(destinations, ['/pages/tasks/tasks'])
assert.equal(ctx.remotePick.taskStatus, 'COMPLETED')
assert.equal(claims, 0); assert.equal(evidence, 0); assert.equal(ctx.remoteLoading, false)
})
for (const entry of ['completion', 'refresh']) {
for (const control of ['dropdown', 'overlay']) {
test(`${entry} returns through onBackPress even when the ${control} opens before completion`, async t => {
const previousUni = globalThis.uni, previousPages = globalThis.getCurrentPages
t.after(() => { globalThis.uni = previousUni; globalThis.getCurrentPages = previousPages })
const response = deferred(), destinations = []; let popupClosed = 0
const completed = { ...task(), taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' }
const { page, ctx } = mount({ navigateBackOr, COMPLETION_PHASE, observeLoadingCompletion,
resolveCompletionPhase, shouldReconcileCompletion,
completeContainerPickingTask: () => response.promise, getContainerPickingTask: () => response.promise },
{ evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceComplete: true,
currentBoxIndex: 0, activeOverlay: '', boxDropdownOpen: false,
requireEvidenceStage: () => true, isEvidenceContextActive: () => true,
$nextTick: callback => callback(), $refs: { boxListPopup: { open() {}, close() { popupClosed++ } } } })
ctx.openOverlay = page.methods.openOverlay.bind(ctx)
globalThis.getCurrentPages = () => [{ route: 'pages/tasks/tasks' }, { route: 'pages/picking/picking' }]
globalThis.uni = {
// App 将返回钩子的拦截视为成功;仅调用导航函数不能证明页面已返回。
navigateBack() { if (!page.onBackPress.call(ctx)) destinations.push('/pages/tasks/tasks') },
reLaunch: ({ url }) => destinations.push(url)
}
const pending = entry === 'completion' ? ctx.completeLoadingTask() : ctx.syncRemoteTask()
if (control === 'dropdown') ctx.toggleBoxDropdown()
else ctx.openOverlay('boxList')
response.resolve(completed)
await pending
assert.deepEqual(destinations, ['/pages/tasks/tasks'])
assert.equal(ctx.boxDropdownOpen, false); assert.equal(ctx.activeOverlay, '')
assert.equal(popupClosed, control === 'overlay' ? 1 : 0)
})
}
}
test('completed tasks update the real history state while clearing only their own persisted draft', async t => {
const previousUni = globalThis.uni
t.after(() => { globalThis.uni = previousUni })
const moduleUrl = value => `data:text/javascript;base64,${Buffer.from(value).toString('base64')}`
let storeSource = await readFile(new URL('../stores/picking.js', import.meta.url), 'utf8')
for (const path of ['data/demo-data.js', 'utils/picking-state.js', 'utils/task-identity.js']) {
storeSource = storeSource.replace(`@/${path}`, moduleUrl(await readFile(new URL(`../${path}`, import.meta.url), 'utf8')))
}
const store = await import(moduleUrl(storeSource)), storage = new Map(), writes = []
globalThis.uni = { getStorageSync: key => storage.get(key),
setStorageSync: (key, value) => { writes.push(key); storage.set(key, value) }, removeStorageSync: key => storage.delete(key) }
const identity = { operatorId: 'operator', taskId: 'task', loadingPlanId: 'plan', containerBookingId: 'container' }
const { page, ctx } = mount({ clearPickingDraft: store.clearPickingDraft, savePickingSession: store.savePickingSession },
{ remotePick: { taskStatus: 'LOADING' }, draftIdentity: () => identity, currentBoxIndex: 0,
boxes: [{ taskId: 'task', cabinetNo: 'D2126', items: [] }], boxDrafts: {}, scanHistory: [] })
ctx.persistSession = page.methods.persistSession.bind(ctx)
ctx.persistSession()
const draftKey = store.getPickingDraftKey(identity)
assert.equal(storage.has(draftKey), true); assert.equal(store.getPickingOverview().completedCount, 0)
storage.set('another-task-draft', { keep: true })
writes.length = 0
ctx.remotePick = { taskStatus: 'COMPLETED' }
ctx.persistSession()
page.onUnload.call(ctx)
assert.equal(store.getPickingOverview().completedCount, 1)
assert.equal(store.getPickingOverview().completedBoxes[0], true)
assert.equal(store.getPickingOverview().boxes[0].taskId, 'task')
assert.equal(storage.has(draftKey), false); assert.equal(storage.has('another-task-draft'), true)
assert.deepEqual(writes, [])
})
for (const hook of ['onHide', 'onUnload']) { for (const hook of ['onHide', 'onUnload']) {
test(`${hook} while detail is pending prevents claim and evidence requests`, async () => { test(`${hook} while detail is pending prevents claim and evidence requests`, async () => {
const detail = deferred(); let claims = 0, evidence = 0 const detail = deferred(); let claims = 0, evidence = 0
+8
View File
@@ -27,6 +27,9 @@ test('operator errors hide internal ids but retain request and trace evidence',
assert.match(message, /错误码:S0500/) assert.match(message, /错误码:S0500/)
assert.match(message, /requestId:dbde0d81/) assert.match(message, /requestId:dbde0d81/)
assert.match(message, /traceId:5b0ba4f3/) assert.match(message, /traceId:5b0ba4f3/)
const concise = formatApiError(error, '任务处理失败', false)
assert.doesNotMatch(concise, /错误码|requestId|traceId|S0500/)
assert.match(concise, /处理失败/)
assert.equal(sanitizeUserMessage('operation 2092889048731414530 failed'), 'operation 内部标识已隐藏 failed') assert.equal(sanitizeUserMessage('operation 2092889048731414530 failed'), 'operation 内部标识已隐藏 failed')
}) })
@@ -47,3 +50,8 @@ test('priority PDA templates do not interpolate internal ids', async () => {
assert.match(pages[2], /containerReference\(currentBox\)/) assert.match(pages[2], /containerReference\(currentBox\)/)
assert.match(pages[3], /containerReference\(entry\.box\)/) assert.match(pages[3], /containerReference\(entry\.box\)/)
}) })
test('operator messages strip inline diagnostic suffixes', () => {
assert.equal(formatApiError({ message: '装柜证据文件上传失败(错误码:S0503;requestId:request-1;traceId:trace-1)' }, '上传失败', false), '装柜证据文件上传失败')
assert.equal(formatApiError({ message: '请先完成车辆取货送仓\nrequestId: request-1' }, '操作失败', false), '请先完成车辆取货送仓')
})
+3 -2
View File
@@ -16,7 +16,7 @@ export function containerReference(source = {}) {
} }
export function sanitizeUserMessage(message, fallback = '操作失败') { export function sanitizeUserMessage(message, fallback = '操作失败') {
const raw = String(message ?? '').trim() const raw = String(message ?? '').replace(/[((]\s*(?:错误码|code|requestId|traceId)\s*[::=][\s\S]*$/i, '').replace(/(?:requestId|traceId)\s*[::=][\s\S]*$/i, '').trim()
if (!raw) return fallback if (!raw) return fallback
if (/^internal server error$/i.test(raw)) return '服务端处理异常,请联系管理员' if (/^internal server error$/i.test(raw)) return '服务端处理异常,请联系管理员'
const sanitized = raw const sanitized = raw
@@ -25,8 +25,9 @@ export function sanitizeUserMessage(message, fallback = '操作失败') {
return sanitized || fallback return sanitized || fallback
} }
export function formatApiError(error, fallback = '操作失败') { export function formatApiError(error, fallback = '操作失败', includeDiagnostics = true) {
const message = sanitizeUserMessage(error?.message, fallback) const message = sanitizeUserMessage(error?.message, fallback)
if (!includeDiagnostics) return message
const code = String(error?.code ?? '').trim() const code = String(error?.code ?? '').trim()
const requestId = String(error?.requestId ?? '').trim() const requestId = String(error?.requestId ?? '').trim()
const traceId = String(error?.traceId ?? '').trim() const traceId = String(error?.traceId ?? '').trim()