diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..8d714a4
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,13 @@
+# 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
diff --git a/README.md b/README.md
index 5a9293e..d741926 100644
--- a/README.md
+++ b/README.md
@@ -106,3 +106,33 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查国内 PDA 的独
2. wgt 发布至少递增 `versionName`,并使用与已安装原生包兼容的 uni-app 编译器生成;新增原生模块、权限或不兼容运行时能力时必须发布 APK,不能只发 wgt。
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
4. 国内和海外更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
+
+## TEST H5 静态托管
+
+发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。开发服务仍使用根路径;不能将 Vite 开发代理当作静态服务器配置。
+
+国内使用本机已安装 HBuilderX:`cli.exe project open --path <本仓库绝对路径>` 后执行 `cli.exe publish web --project <本仓库绝对路径> --webHosting false --sourceMap false`,仅导出本地 Web 静态文件;本次验证编译器 5.26,不上传云托管。
+
+`deploy/Dockerfile.h5` 只复制已构建 `unpackage/dist/build/web/` 及静态 Nginx 配置,不在镜像中复制源码、依赖或重新编译。`.dockerignore` 只放行这两类输入。容器静态目录为 `/srv/pda-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-domestic-h5-test`,仅在外部 `firstleg-test-network` 暴露容器 80,不发布宿主端口。健康检查同时请求 `/healthz` 与 `/` 的实际首页。
+
+独立域名入口将 `/` 代理至本应用静态容器根目录,或将构建目录内容直接发布到站点根目录;不再挂载 PDA 子路径。缺失资源返回 404,不回退首页,HTML 使用 `Cache-Control: no-store`。域名入口还须配置同源 API 代理:两端均将 `/gateway-api/` 转统一 Gateway `/api/`。入口须保留 Authorization,支持文件上传下载及至少 11m 请求体。域名与上游只在部署侧配置,不编入 PDA 源码。
+
+部署配置检查:`node --test tests/h5-deployment.test.mjs`。没有 Docker 运行时的本地合同检查不能替代目标主机 `docker compose config`、Nginx 启动与真实 HTTP 验收。
+
+构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径、同源 API 和无本机/旧海外配置泄漏。该检查与本地构建仅证明产物;TEST 页面登录、仓权限、业务操作和 Android 真机另行验收。
+
+### 两端统一 API 配置
+
+两端的 `VITE_API_BASE_URL` 均为统一 Gateway 绝对地址(含 `/api`);`VITE_H5_API_BASE_URL=/gateway-api`;`API_PROXY_TARGET` 为同一 Gateway 源地址(不含 `/api`)。实际地址只放在未提交的 `.env.local`;修改后重启开发服务,发布前重新打包。
+
+两端独立域名入口均配置以下规则;`gateway:9000` 仅适用于同一 Docker 网络,其他部署须替换成实际统一 Gateway 上游。静态容器只提供页面文件,Vite 代理不会随静态包发布。
+
+```nginx
+location ^~ /gateway-api/ {
+ proxy_pass http://gateway:9000/api/;
+ proxy_set_header Authorization $http_authorization;
+ client_max_body_size 11m;
+}
+```
diff --git a/api/auth.js b/api/auth.js
index 3e34e93..784ac4a 100644
--- a/api/auth.js
+++ b/api/auth.js
@@ -10,9 +10,10 @@ function authRequest(path, data, options = {}) {
timeout: options.timeout || 15000,
success(response) {
const body = response.data || {}
- if (String(body.code ?? response.statusCode) !== '200') {
+ if (response.statusCode !== 200 || String(body.code ?? response.statusCode) !== '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)
@@ -33,9 +34,17 @@ export function login(loginId, password) {
return authRequest('/auth/login', { loginId, password })
}
+const pendingRefreshes = new Map()
export function refresh(refreshToken) {
- return authRequest('/auth/refresh', { refreshToken })
+ if (pendingRefreshes.has(refreshToken)) return pendingRefreshes.get(refreshToken)
+ const pending = authRequest('/auth/refresh', { refreshToken }).finally(() => {
+ if (pendingRefreshes.get(refreshToken) === pending) pendingRefreshes.delete(refreshToken)
+ })
+ pendingRefreshes.set(refreshToken, pending)
+ return pending
}
+export function getPendingRefresh(refreshToken) { return pendingRefreshes.get(refreshToken) || null }
+
export function logout(refreshToken, accessToken) {
return authRequest('/auth/logout', { refreshToken }, {
diff --git a/api/http.js b/api/http.js
index eb27260..d2c56d0 100644
--- a/api/http.js
+++ b/api/http.js
@@ -1,8 +1,9 @@
import { refresh as refreshRequest } from '@/api/auth.js'
-import { isAccessTokenExpiring, session, signOut, updateTokenPair } from '@/stores/session.js'
+import { isAccessTokenExpiring, session, signOut, updateTokenPair, getSessionRevision } from '@/stores/session.js'
import { requireOnline } from '@/utils/network.js'
let refreshPromise = null
+let refreshRevision = null
let redirectingToLogin = false
function responseCode(response) {
@@ -66,14 +67,21 @@ function rawRequest(options, accessToken) {
})
}
+function requireSameSession(revision) {
+ if (getSessionRevision() !== revision) throw new Error('登录会话已变更,请重新操作')
+}
+
async function refreshAccessToken() {
+ const revision = getSessionRevision()
const observedRefreshToken = session.refreshToken
if (!observedRefreshToken) return expireSession(null, observedRefreshToken)
- if (!refreshPromise) {
+ if (!refreshPromise || refreshRevision !== revision) {
+ refreshRevision = revision
refreshPromise = (async () => {
if (session.refreshToken !== observedRefreshToken && session.token) return session.token
try {
const tokens = await refreshRequest(observedRefreshToken)
+ requireSameSession(revision)
if (session.refreshToken !== observedRefreshToken) {
if (session.token) return session.token
throw sessionExpiredError()
@@ -82,10 +90,14 @@ async function refreshAccessToken() {
redirectingToLogin = false
return session.token
} catch (error) {
+ requireSameSession(revision)
if (isUnauthorized(error)) return expireSession(error, observedRefreshToken)
throw error
}
- })().finally(() => { refreshPromise = null })
+ })()
+ const pending = refreshPromise
+ const release = () => { if (refreshPromise === pending) { refreshPromise = null; refreshRevision = null } }
+ pending.then(release, release) // Original pending promise still propagates failures to callers.
}
return refreshPromise
}
@@ -101,12 +113,17 @@ export function ensureAccessToken() {
async function runAuthenticated(execute) {
requireOnline()
+ const revision = getSessionRevision()
let accessToken = await validAccessToken()
+ requireSameSession(revision)
let response = await execute(accessToken)
+ requireSameSession(revision)
if (!isUnauthorized(response)) return response
accessToken = session.token && session.token !== accessToken ? session.token : await refreshAccessToken()
+ requireSameSession(revision)
const activeRefreshToken = session.refreshToken
response = await execute(accessToken)
+ requireSameSession(revision)
if (isUnauthorized(response)) expireSession(response, activeRefreshToken)
return response
}
diff --git a/api/logout-session.js b/api/logout-session.js
new file mode 100644
index 0000000..fdc8a6f
--- /dev/null
+++ b/api/logout-session.js
@@ -0,0 +1,33 @@
+import { logout, refresh, getPendingRefresh } from './auth.js'
+import { session, signOut, getSessionRevision, decodeAccessToken } from '../stores/session.js'
+
+function replacement(captured, tokens) {
+ if (!tokens?.accessToken || typeof tokens.refreshToken !== 'string' || !tokens.refreshToken.trim()) throw new Error('退出刷新响应缺少会话凭据')
+ const previous = decodeAccessToken(captured.accessToken), next = decodeAccessToken(tokens.accessToken)
+ if (!Number.isFinite(Number(next.exp)) || Number(next.exp) * 1000 <= Date.now()) throw new Error('退出刷新凭据无效或已过期')
+ for (const field of ['user_id', 'membership_id', 'tenant_id', 'app_code']) {
+ if (previous[field] == null || String(previous[field]) !== String(next[field])) throw new Error('退出刷新返回了不同作业主体,无法确认原会话撤销')
+ }
+ if (String(previous.domestic_warehouse_id ?? '') !== String(next.domestic_warehouse_id ?? '')) throw new Error('退出刷新返回了不同作业仓库,无法确认原会话撤销')
+ return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }
+}
+export function logoutCurrentSession() {
+ const captured = { accessToken: session.token, refreshToken: session.refreshToken, pendingRefresh: getPendingRefresh(session.refreshToken) }
+ // Clear sensitive data and invalidate business requests while revocation is pending.
+ signOut()
+ const clearedRevision = getSessionRevision()
+ const revoke = async () => {
+ if (!captured.accessToken && !captured.refreshToken) return { revoked: false, reason: 'NO_CAPTURED_SESSION' }
+ if (!captured.accessToken || !captured.refreshToken) throw new Error('原会话凭据不完整,服务端退出未确认')
+ let credentials = captured, refreshed = false
+ if (captured.pendingRefresh) { refreshed = true; credentials = replacement(captured, await captured.pendingRefresh) }
+ try { await logout(credentials.refreshToken, credentials.accessToken) }
+ catch (error) {
+ if (error.network || error.statusCode !== 401 || refreshed) throw error
+ credentials = replacement(captured, await refresh(captured.refreshToken))
+ await logout(credentials.refreshToken, credentials.accessToken)
+ }
+ return { revoked: true }
+ }
+ return revoke().then(result => ({ ...result, clearedRevision }), error => { error.logoutRevision = clearedRevision; throw error })
+}
diff --git a/config/runtime.js b/config/runtime.js
index c293d03..5610dc8 100644
--- a/config/runtime.js
+++ b/config/runtime.js
@@ -1,7 +1,9 @@
-const configuredApiRoot = import.meta.env.VITE_API_BASE_URL || ''
-let selectedApiRoot = configuredApiRoot || '/api'
+let selectedApiRoot = ''
+// #ifndef H5
+selectedApiRoot = import.meta.env.VITE_API_BASE_URL || '/api'
+// #endif
-// H5 必须请求同源地址,由 Vite 开发服务器代理到国内 Gateway,避免浏览器 CORS。
+// H5 必须请求同源地址,由 Vite 开发服务器代理到统一 Gateway,避免浏览器 CORS。
// 前缀不能使用 /api,否则会截获本项目 api/ 目录下的前端模块请求。
// #ifdef H5
selectedApiRoot = import.meta.env.VITE_H5_API_BASE_URL || '/gateway-api'
diff --git a/deploy/Dockerfile.h5 b/deploy/Dockerfile.h5
new file mode 100644
index 0000000..56d7173
--- /dev/null
+++ b/deploy/Dockerfile.h5
@@ -0,0 +1,5 @@
+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
diff --git a/deploy/Dockerfile.h5.production b/deploy/Dockerfile.h5.production
new file mode 100644
index 0000000..f08e893
--- /dev/null
+++ b/deploy/Dockerfile.h5.production
@@ -0,0 +1,5 @@
+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
diff --git a/deploy/docker-compose.h5.production.yml b/deploy/docker-compose.h5.production.yml
new file mode 100644
index 0000000..60dbdfc
--- /dev/null
+++ b/deploy/docker-compose.h5.production.yml
@@ -0,0 +1,29 @@
+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
diff --git a/deploy/docker-compose.h5.test.yml b/deploy/docker-compose.h5.test.yml
new file mode 100644
index 0000000..5e9342c
--- /dev/null
+++ b/deploy/docker-compose.h5.test.yml
@@ -0,0 +1,27 @@
+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
diff --git a/deploy/nginx.conf b/deploy/nginx.conf
new file mode 100644
index 0000000..bdef818
--- /dev/null
+++ b/deploy/nginx.conf
@@ -0,0 +1,16 @@
+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;
+ }
+}
diff --git a/deploy/nginx.production.conf.template b/deploy/nginx.production.conf.template
new file mode 100644
index 0000000..4ce203e
--- /dev/null
+++ b/deploy/nginx.production.conf.template
@@ -0,0 +1,25 @@
+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;
+ }
+}
diff --git a/manifest.json b/manifest.json
index 596ade2..e71d9b7 100644
--- a/manifest.json
+++ b/manifest.json
@@ -91,6 +91,9 @@
},
"vueVersion" : "3",
"h5" : {
+ "router" : {
+ "mode" : "hash"
+ },
"devServer" : {
"port" : 5175
}
diff --git a/pages/home/home.vue b/pages/home/home.vue
index 7727584..0b31b70 100644
--- a/pages/home/home.vue
+++ b/pages/home/home.vue
@@ -1,6 +1,6 @@
-
+
-
+
图片处理中
{{ photo.fileName || '已记录' }}
-
+
@@ -149,11 +149,12 @@
-
-
+
+
{{ remoteError }}
-
+ {{ loadingCompletionState.label }}
+
状态尚未同步,请刷新重试
@@ -166,7 +167,7 @@
- {{ summary.pending }} 箱待拣
+ {{ remainingCartons }} 箱待拣
{{ submitStatusHint }}
@@ -281,7 +282,7 @@
- 确认提交后,以下待拣商品会被明确标记为“未拣出”,本次演示提交不可撤销。
+ 确认提交后,以下待拣商品会标记为“未拣出”,并记录所选原因。
{{ item.location ? `${item.sku} · ${item.location}` : item.sku }}
另有 {{ pendingItems.length - 3 }} 件
@@ -292,7 +293,7 @@
-
+
@@ -336,7 +337,7 @@