2 Commits
14 changed files with 82 additions and 195 deletions
-12
View File
@@ -1,12 +0,0 @@
# Only compiled H5 assets and image configuration enter the build context.
**
!deploy/
!deploy/Dockerfile.h5
!deploy/nginx.conf
!dist/
!dist/build/
!dist/build/h5/
!dist/build/h5/**
!deploy/Dockerfile.h5.production
!deploy/nginx.production.conf.template
+1
View File
@@ -15,6 +15,7 @@
## 服务与发布边界
- H5只上传Nginx站点,禁止容器发布及重跑旧灰度脚本;步骤见`README.md`。
- Auth 唯一签发账号/Token;认证/业务共用 Gateway,App 根地址只读 VITE_API_BASE_URL,H5 用 VITE_H5_API_BASE_URL。
- 登录/刷新/会话校验Token平台主体、成员、有效期、冻结仓;`OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate` 仅读存Auth登录/刷新顶层快照,禁解析JWT权限。快照缺失/无效/过期/跨主体或撤权均fail-closed清会话,不能只藏入口。
- 并发401仅一次refresh,完整校验Token/快照再原子替换;主体/仓变化清会话重登录,原请求/照片上传最多重放一次。刷新失败清会话;业务/网络错留code、requestId、traceId或network诊断。
+23 -19
View File
@@ -75,34 +75,38 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
4. 海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
## TEST H5 静态托管
## H5 静态托管(TEST 与灰度)
发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。开发服务仍使用根路径;不能将 Vite 开发代理当作静态服务器配置。
国内、海外 PDA H5 统一由宿主机已有 Nginx/宝塔站点直接提供静态文件,不再构建或发布 PDA H5 Docker 镜像、Compose 服务或容器。正式部署约束见 [测试环境单机部署与配置契约](../../docs/测试环境单机部署与配置契约.md) 的“PDA H5 发布方式”;历史灰度报告不再作为发布入口。
执行 `npm run build:h5`。无需启动 Vite 开发服务器;`npm run build:app` 不属于 H5 发布。
发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。不能放到子目录后继续共用根路径资源,也不能将 Vite 开发代理当作线上配置。
`deploy/Dockerfile.h5` 只复制已构建 `dist/build/h5/` 及静态 Nginx 配置,不在镜像中复制源码、依赖或重新编译。`.dockerignore` 只放行这两类输入。容器静态目录为 `/srv/pda-h5/`。
执行 `npm run build:h5` 导出 Web 静态文件;`npm run build:app` 不属于 H5 发布。
从本仓库执行 `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-overseas-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. 将 `dist/build/h5/` **目录内的文件**上传到该 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 配置
两端的 `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
location ^~ /gateway-api/ {
proxy_pass http://gateway:9000/api/;
proxy_set_header Authorization $http_authorization;
client_max_body_size 11m;
}
```
域名入口及承接 API 的容器均须保留 `^~`,使 API 前缀优先于站点的敏感文件正则。若站点存在匹配 `LICENSE` 结尾的文件拦截规则,普通 `location /gateway-api/` 会导致驾驶证绑定路径 `loading-documents/DRIVER_LICENSE` 返回 Nginx HTML 404,而同槽位的 `/uploads` 仍可上传成功。TEST 静态容器不代理 API,应修改实际域名入口配置;仅更新应用静态包不能修复入口规则。获授权应用配置前先执行 `nginx -t`,重载后确认驾驶证绑定路径能进入 Gateway(未登录应返回 `401` JSON),再使用 PDA 登录态验证保存。
部署定向检查:`node --test tests/h5-deployment.test.mjs`,经 `local-check.ps1` 执行。该检查覆盖静态发布约束、资源 base 和开发代理重写,不替代目标主机 Nginx 校验、真实 HTTP、人工页面及 Android 真机验收。
-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 dist/build/h5/ /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 dist/build/h5/ /srv/pda-h5/
EXPOSE 80
-29
View File
@@ -1,29 +0,0 @@
name: firstleg-production-pda-overseas-h5
services:
pda-overseas-h5:
build:
context: ..
dockerfile: deploy/Dockerfile.h5.production
args:
NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
image: pda-overseas-h5-prod:${IMAGE_TAG:?IMAGE_TAG is required}
container_name: pda-overseas-h5-prod
restart: unless-stopped
environment:
API_UPSTREAM: "http://gateway:9000"
ports:
- "127.0.0.1:${HOST_PORT:-29006}: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-overseas-h5-prod
networks:
backend:
external: true
name: firstleg-production-network
-27
View File
@@ -1,27 +0,0 @@
name: firstleg-test-pda-overseas-h5
services:
pda-overseas-h5:
build:
context: ..
dockerfile: deploy/Dockerfile.h5
args:
NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
image: pda-overseas-h5-test:${IMAGE_TAG:?IMAGE_TAG is required}
container_name: pda-overseas-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-overseas-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;
}
-26
View File
@@ -1,26 +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";
}
# API优先于站点敏感文件正则,避免DRIVER_LICENSE被LICENSE规则误拦截。
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;
}
}
+6 -7
View File
@@ -3,7 +3,7 @@
<PortalHeader :title="inboundMode ? '拆柜收货' : operationName" :user="user" />
<view class="portal-content receiving-content" :class="{ 'receiving-content-with-action': phase === 'detail' || phase === 'sign' || (phase === 'receive' && canFinishOnsite) }">
<template v-if="inboundMode">
<view v-if="phase !== 'candidates' && phase !== 'receive'" class="receiving-heading"><view><text class="receiving-title">{{ pageTitle }}</text><text v-if="pageSubtitle" class="receiving-subtitle">{{ pageSubtitle }}</text></view></view>
<view v-if="phase !== 'candidates' && phase !== 'receive'" class="receiving-heading"><view><text class="receiving-title">{{ pageTitle }}</text></view></view>
<view v-if="loading" class="state-card"><uni-load-more status="loading" /><text>正在恢复当前柜作业…</text></view>
@@ -42,7 +42,7 @@
<template v-else-if="phase === 'sign'">
<ReceivingSessionSummary :session="current" />
<view class="photo-card domestic-photo-card">
<view class="card-head"><view><text class="card-title">国内装柜照片(只读)</text><text class="container-meta">现场对照国内装柜过程与封柜状态</text></view><text class="status-tag status-good">{{ domesticPhotoCount }} / 5</text></view>
<view class="card-head"><view><text class="card-title">国内装柜照片(只读)</text></view><text class="status-tag status-good">{{ domesticPhotoCount }} / 5</text></view>
<view class="domestic-photo-grid">
<view v-for="photo in domesticLoadingPhotos" :key="photo.evidenceType" class="domestic-photo-item" @tap="previewDomesticPhoto(photo)">
<image v-if="photo.localPath" class="domestic-photo-thumb" :src="photo.localPath" mode="aspectFill" @error="invalidatePhotoPreview(photo)" />
@@ -52,7 +52,7 @@
</view>
</view>
<view class="photo-card">
<view class="card-head"><view><text class="card-title">卸货签收照片</text><text class="container-meta">三个固定槽位各拍摄 1 张现场照片</text></view><text class="status-tag" :class="photosReady ? 'status-good' : 'status-wait'">{{ uploadedPhotoCount }} / 3</text></view>
<view class="card-head"><view><text class="card-title">卸货签收照片</text></view><text class="status-tag" :class="photosReady ? 'status-good' : 'status-wait'">{{ uploadedPhotoCount }} / 3</text></view>
<view class="photo-grid">
<view v-for="(step, index) in photoSteps" :key="step.role" class="photo-role" :class="{ complete: photoSlots[index]?.fileId }">
<view class="photo-preview" :class="{ failed: photoSlots[index]?.status === 'error' }" @tap="previewPhoto(index)">
@@ -61,7 +61,7 @@
</view>
<view class="photo-role-info">
<view class="photo-role-head"><text>{{ step.label }}</text><text :class="photoSlots[index]?.fileId ? 'photo-valid' : 'photo-required'">{{ photoSlots[index]?.fileId ? '已上传' : '必传' }}</text></view>
<text class="photo-state">{{ photoStateLabel(photoSlots[index]) }}</text>
<text v-if="photoSlots[index]?.status === 'uploading' || photoSlots[index]?.status === 'error'" class="photo-state">{{ photoStateLabel(photoSlots[index]) }}</text>
<text v-if="photoSlots[index]?.status === 'error'" class="photo-error">{{ photoSlots[index].errorMessage || '请重试' }}</text>
</view>
<view class="photo-actions"><button :disabled="busy" @tap="choosePhoto(index)">{{ photoSlots[index]?.fileId ? '重拍' : '拍照' }}</button></view>
@@ -160,8 +160,7 @@
canSubmitReceipt() { if (!this.activeInbound || !this.resolvedPallet?.compatible) return false; if (this.activeInbound.destinationType === 'FBA') { const count = Number(this.packageCount); return Number.isInteger(count) && count > 0 } return Boolean(this.activeInbound.cartonMark) },
allInboundsReconciled() { const values = this.current?.inbounds || []; return values.length > 0 && !this.current?.hasDifference && values.every((item) => Number(item.pendingPackages) === 0) },
canFinishOnsite() { return Boolean(this.current && ['SIGNED', 'RECEIVING'].includes(this.current.status)) },
pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] },
pageSubtitle() { return { candidates: '选择集装箱后拍摄 3 张照片完成签收', detail: '', sign: '完成现场取证后进入收货', receive: '扫描箱码定位入仓码后开始收货', completed: '' }[this.phase] }
pageTitle() { return { candidates: '待卸货集装箱', detail: '柜号详情', sign: '拆柜拍照签收', receive: '当前柜收货', completed: '入库完成' }[this.phase] }
},
onLoad(options) { this.moduleKey = options.module || 'query'; const key = options.op || (this.moduleKey === 'inbound' ? 'receiving' : 'inventoryQuery'); this.operationName = ({ receiving: '拆柜收货', stocktake: '库存盘点', move: '商品移库', pallet: '托盘管理', relabelReceive: '换标列表', relabelExecute: '换标执行', relabelReview: '换标复核', inventoryQuery: '库存查询', locationQuery: '库位查询', palletQuery: '托盘查询' })[key] || '库存查询' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this._pageActive = true; this.startScanner(); if (this.inboundMode && (!this.bootstrapped || this.phase === 'candidates' || this._resumeReceiving)) { if (!this.busy) { this._resumeReceiving = false; this.bootstrap() } } },
@@ -175,7 +174,7 @@
receivingWarehouseLabel(item) { const code = String(item?.overseasWarehouseCode || '').trim(); const name = String(item?.overseasWarehouseName || '').trim(); return code && name ? `${code} · ${name}` : '—' },
validateReceivingWarehouse(value, label) { return assertReceivingWarehouse(value, this.user, label) },
async refreshBoundWarehouse() { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()); return this.user },
photoStateLabel(photo) { if (photo?.status === 'uploading') return '正在上传…'; if (photo?.status === 'error') return photo.errorMessage || (photo.fileId ? '重拍失败,已保留原图' : '上传失败,请重试'); return photo?.fileId ? '点击左侧照片可查看大图' : '请拍摄现场照片' },
photoStateLabel(photo) { if (photo?.status === 'uploading') return '正在上传…'; if (photo?.status === 'error') return photo.errorMessage || (photo.fileId ? '重拍失败,已保留原图' : '上传失败,请重试'); return '' },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleHardwareScan(value), onError: (error) => { this.error = error; this.showToast('硬件扫码接收器异常,请检查扫描服务', 'warning') } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
handleHardwareScan(value) { if (!this._pageActive || this.loading) return; const scan = { value: String(value || '').trim(), target: this.activeTarget }; if (!scan.value) return; if (!this.inboundMode) return this.showToast('该功能缺少海外 PDA 专用接口,扫码未提交', 'warning'); if (this.phase === 'candidates') return this.showToast('请直接点击待卸货集装箱进入详情', 'warning'); if (this.phase !== 'receive') return; if (this.busy || this.activeTarget === 'quantity') { if (this.scanQueue.length >= 100) return this.showToast('扫码队列已满,请先处理当前托盘', 'warning'); this.scanQueue.push(scan); return this.showToast(`已排队 ${this.scanQueue.length} 个扫码`, 'warning') } return this.dispatchHardwareScan(scan) },
dispatchHardwareScan(scan) { if (!this._pageActive || this.loading) return; const palletWorkflowActive = Boolean(this.activeInbound || this.resolvedPallet); const target = palletWorkflowActive ? 'pallet' : scan.target; console.info('[Overseas PDA scan] dispatch', { value: scan.value, target, queuedTarget: scan.target }); if (target === 'pallet') { this.palletInput = scan.value; return this.resolvePallet(scan.value) } this.cartonInput = scan.value; return this.resolveCarton(scan.value) },
+2 -2
View File
@@ -16,14 +16,14 @@
<template v-if="!loading && !current && !discardCurrent">
<view class="list-toolbar"><view><text class="list-title">待处理出库任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
<view v-if="tasks.length" class="task-list">
<button v-for="task in tasks" :key="task.taskNo" class="task-card" @tap="openTask(task.taskNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.taskNo || '—' }}</text><text v-if="task.taskNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view><text class="task-meta">出库单 {{ task.outboundNo || '—' }} · {{ taskType(task) }}</text><text class="task-enter">查看并开始出库 ›</text></button>
<button v-for="task in tasks" :key="task.taskNo" class="task-card" @tap="openTask(task.taskNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.outboundNo || '—' }}</text><text v-if="task.taskNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view><text class="task-meta">{{ taskType(task) }} · 任务号 {{ task.taskNo || '—' }}</text><text class="task-enter">查看并开始出库 ›</text></button>
</view>
<view v-else-if="!error && !discardTasks.length" class="fulfillment-state"><text class="state-title">暂无出库任务</text><text>可刷新列表,或扫描任务号恢复未完成作业。</text></view>
<view v-if="discardTasks.length" class="discard-section"><view class="list-toolbar"><view><text class="list-title">废弃订单</text><text class="list-count">{{ discardOrderCount }} 个订单</text></view></view><view class="task-list"><button v-for="order in discardOrders" :key="order.discardNo" class="task-card" @tap="openDiscardOrder(order.discardNo)"><view class="task-head"><view class="task-code-row"><text class="business-code">{{ order.discardNo || '—' }}</text></view><text class="status-badge">废弃订单</text></view><text v-if="order.outboundNo" class="task-meta">出库单 {{ order.outboundNo }} · 废弃出库</text><text class="task-meta">托盘 {{ order.palletCount }} 个 · 按箱废弃 {{ order.cartonCount }} 箱 · 待作业 {{ order.pendingPalletCount }} 个</text><text class="task-enter">点击进入废弃出库作业 ›</text></button></view></view>
</template>
<template v-if="current">
<view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.taskNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view><text class="task-meta">出库单 {{ current.outboundNo || '—' }} · {{ taskType(current) }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view></view>
<view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.outboundNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view><text class="task-meta">{{ taskType(current) }} · 任务号 {{ current.taskNo || '—' }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>已拣箱数</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>待拣箱数</text></view></view></view>
<view v-if="current.items.length" class="item-list">
<view v-for="item in displayItems" :key="item.displayKey || item.outboundPalletItemId" class="fulfillment-card item-card">
+9
View File
@@ -23,6 +23,15 @@ test('dispatch network failures show recovery guidance and retain diagnostics',
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('outbound list and detail lead with the outbound number and label the task number separately', async () => {
const page = await read('../src/pages/outbound/outbound.vue')
assert.match(page, /class="business-code">\{\{ task\.outboundNo \|\| '—' \}\}/)
assert.match(page, /class="business-code">\{\{ current\.outboundNo \|\| '—' \}\}/)
assert.match(page, /任务号 \{\{ task\.taskNo \|\| '—' \}\}/)
assert.match(page, /任务号 \{\{ current\.taskNo \|\| '—' \}\}/)
assert.match(page, /@tap="openTask\(task\.taskNo\)"/)
})
test('native dispatch versions preserve explicit zero and reject missing or invalid versions', () => {
assert.equal(requireDispatchVersion(0), 0)
assert.equal(requireDispatchVersion('9007199254740993'), '9007199254740993')
+20 -47
View File
@@ -1,43 +1,28 @@
import test from 'node:test'
import { runInNewContext } from 'node:vm'
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')
test('H5 image copies only compiled assets and static nginx configuration', async () => {
const dockerfile = await read('../deploy/Dockerfile.h5')
const copies = dockerfile.split(/\r?\n/).filter(line => line.startsWith('COPY '))
assert.deepEqual(copies, ['COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf', 'COPY dist/build/h5/ /srv/pda-h5/'])
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('!dist/build/h5/**'))
assert.doesNotMatch(ignore, /!\.env|!node_modules|!src/)
test('PDA H5 has no container publishing entry', async () => {
const files = await readdir(new URL('../deploy/', import.meta.url))
assert.deepEqual(files.filter(name => /docker|compose/i.test(name)), [])
await assert.rejects(read('../.dockerignore'), { code: 'ENOENT' })
})
test('static server serves the domain root and returns genuine missing-resource errors', async () => {
const nginx = await read('../deploy/nginx.conf')
assert.match(nginx, /listen 80;/)
assert.ok(nginx.includes('location /'))
assert.match(nginx, /root \/srv\/pda-h5;/)
assert.match(nginx, /try_files \$uri \$uri\/ =404;/)
assert.match(nginx, /Cache-Control "no-store" always/)
assert.doesNotMatch(nginx, /pda-domestic|pda-overseas/)
assert.match(nginx, /location = \/healthz/)
assert.doesNotMatch(nginx, /proxy_pass|gateway-api|try_files[^;]*index\.html/)
})
test('TEST H5 deployment has a required image tag and internal-only dual health checks', async () => {
const compose = await read('../deploy/docker-compose.h5.test.yml')
assert.ok(compose.includes('${IMAGE_TAG:?IMAGE_TAG is required}'))
assert.match(compose, /context: \.\./)
assert.match(compose, /dockerfile: deploy\/Dockerfile\.h5/)
assert.match(compose, /container_name: pda-overseas-h5-test/)
assert.match(compose, /aliases:\s*\n\s*- pda-overseas-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('host nginx serves uploaded files and proxies only the API prefix', async () => {
const nginx = await read('../deploy/nginx.conf.template')
assert.ok(nginx.includes('root ${PDA_SITE_ROOT};'))
const page = nginx.match(/location \/ \{([^{}]*)\}/)?.[1]
assert.ok(page)
assert.match(page, /try_files \$uri \$uri\/ =404;/)
assert.match(page, /Cache-Control "no-store" always/)
assert.doesNotMatch(page, /proxy_pass|try_files[^;]*index\.html/)
assert.match(nginx, /location \^~ \/gateway-api\//)
assert.ok(nginx.includes('proxy_pass ${PDA_GATEWAY_ORIGIN}/api/;'))
assert.equal((nginx.match(/proxy_pass/g) || []).length, 1)
assert.match(nginx, /proxy_set_header Authorization \$http_authorization;/)
assert.match(nginx, /client_max_body_size 55m;/)
assert.doesNotMatch(nginx, /29005|29006|\/srv\/pda-h5|gateway:9000/)
})
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}`)
}
})
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/)
})