Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aae3b8135 | ||
|
|
54764156bf | ||
|
|
e699e9d9ba | ||
|
|
7aebbd2a70 | ||
|
|
8d2d80ec11 | ||
|
|
7ba6a24505 | ||
|
|
707711b3bd | ||
|
|
a84f263452 | ||
|
|
fb61ada3ea |
@@ -8,3 +8,6 @@
|
|||||||
!unpackage/dist/build/
|
!unpackage/dist/build/
|
||||||
!unpackage/dist/build/web/
|
!unpackage/dist/build/web/
|
||||||
!unpackage/dist/build/web/**
|
!unpackage/dist/build/web/**
|
||||||
|
|
||||||
|
!deploy/Dockerfile.h5.production
|
||||||
|
!deploy/nginx.production.conf.template
|
||||||
|
|||||||
@@ -109,16 +109,30 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查国内 PDA 的独
|
|||||||
|
|
||||||
## TEST H5 静态托管
|
## TEST H5 静态托管
|
||||||
|
|
||||||
发布构建使用 `/pda-domestic/` 资源 base 与 hash 路由;开发服务仍使用根路径。浏览器入口为 `/pda-domestic/#/`,不能将 Vite 开发代理当作静态服务器配置。
|
发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。开发服务仍使用根路径;不能将 Vite 开发代理当作静态服务器配置。
|
||||||
|
|
||||||
国内使用本机已安装 HBuilderX:`cli.exe project open --path <本仓库绝对路径>` 后执行 `cli.exe publish web --project <本仓库绝对路径> --webHosting false --sourceMap false`,仅导出本地 Web 静态文件;本次验证编译器 5.26,不上传云托管。
|
国内使用本机已安装 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/pda-domestic/`。
|
`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-domestic/` 的实际首页。
|
从本仓库执行 `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` 与 `/` 的实际首页。
|
||||||
|
|
||||||
Admin Nginx 将 `/pda-domestic/` 原样代理至 `http://pda-domestic-h5-test:80`,不去掉路径前缀;无尾斜杠入口重定向为 `/pda-domestic/`。PDA 容器只提供本应用静态目录,其他路径返回 404,缺失资源不回退首页,HTML 使用 `Cache-Control: no-store`。API 全部由 Admin 入口代理:国内 `/gateway-api/` 转统一 Gateway `/api/`,海外同源 `/api/` 保持路径。入口须保留 Authorization,支持文件上传下载及至少 11m 请求体。环境域名与上游只在部署侧配置,不修改宿主 Nginx 权限,也不编入 PDA 源码。
|
独立域名入口将 `/` 代理至本应用静态容器根目录,或将构建目录内容直接发布到站点根目录;不再挂载 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 --test tests/h5-deployment.test.mjs`。没有 Docker 运行时的本地合同检查不能替代目标主机 `docker compose config`、Nginx 启动与真实 HTTP 验收。
|
||||||
|
|
||||||
构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径、同源 API 和无本机/旧海外配置泄漏。该检查与本地构建仅证明产物;TEST 页面登录、仓权限、业务操作和 Android 真机另行验收。
|
构建后执行 `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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
+11
-2
@@ -10,9 +10,10 @@ function authRequest(path, data, options = {}) {
|
|||||||
timeout: options.timeout || 15000,
|
timeout: options.timeout || 15000,
|
||||||
success(response) {
|
success(response) {
|
||||||
const body = response.data || {}
|
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 || '认证服务请求失败')
|
const error = new Error(body.message || '认证服务请求失败')
|
||||||
error.code = body.code || response.statusCode
|
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.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']
|
error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
|
||||||
reject(error)
|
reject(error)
|
||||||
@@ -33,9 +34,17 @@ export function login(loginId, password) {
|
|||||||
return authRequest('/auth/login', { loginId, password })
|
return authRequest('/auth/login', { loginId, password })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pendingRefreshes = new Map()
|
||||||
export function refresh(refreshToken) {
|
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) {
|
export function logout(refreshToken, accessToken) {
|
||||||
return authRequest('/auth/logout', { refreshToken }, {
|
return authRequest('/auth/logout', { refreshToken }, {
|
||||||
|
|||||||
+20
-3
@@ -1,8 +1,9 @@
|
|||||||
import { refresh as refreshRequest } from '@/api/auth.js'
|
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'
|
import { requireOnline } from '@/utils/network.js'
|
||||||
|
|
||||||
let refreshPromise = null
|
let refreshPromise = null
|
||||||
|
let refreshRevision = null
|
||||||
let redirectingToLogin = false
|
let redirectingToLogin = false
|
||||||
|
|
||||||
function responseCode(response) {
|
function responseCode(response) {
|
||||||
@@ -66,14 +67,21 @@ function rawRequest(options, accessToken) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireSameSession(revision) {
|
||||||
|
if (getSessionRevision() !== revision) throw new Error('登录会话已变更,请重新操作')
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshAccessToken() {
|
async function refreshAccessToken() {
|
||||||
|
const revision = getSessionRevision()
|
||||||
const observedRefreshToken = session.refreshToken
|
const observedRefreshToken = session.refreshToken
|
||||||
if (!observedRefreshToken) return expireSession(null, observedRefreshToken)
|
if (!observedRefreshToken) return expireSession(null, observedRefreshToken)
|
||||||
if (!refreshPromise) {
|
if (!refreshPromise || refreshRevision !== revision) {
|
||||||
|
refreshRevision = revision
|
||||||
refreshPromise = (async () => {
|
refreshPromise = (async () => {
|
||||||
if (session.refreshToken !== observedRefreshToken && session.token) return session.token
|
if (session.refreshToken !== observedRefreshToken && session.token) return session.token
|
||||||
try {
|
try {
|
||||||
const tokens = await refreshRequest(observedRefreshToken)
|
const tokens = await refreshRequest(observedRefreshToken)
|
||||||
|
requireSameSession(revision)
|
||||||
if (session.refreshToken !== observedRefreshToken) {
|
if (session.refreshToken !== observedRefreshToken) {
|
||||||
if (session.token) return session.token
|
if (session.token) return session.token
|
||||||
throw sessionExpiredError()
|
throw sessionExpiredError()
|
||||||
@@ -82,10 +90,14 @@ async function refreshAccessToken() {
|
|||||||
redirectingToLogin = false
|
redirectingToLogin = false
|
||||||
return session.token
|
return session.token
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
requireSameSession(revision)
|
||||||
if (isUnauthorized(error)) return expireSession(error, observedRefreshToken)
|
if (isUnauthorized(error)) return expireSession(error, observedRefreshToken)
|
||||||
throw error
|
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
|
return refreshPromise
|
||||||
}
|
}
|
||||||
@@ -101,12 +113,17 @@ export function ensureAccessToken() {
|
|||||||
|
|
||||||
async function runAuthenticated(execute) {
|
async function runAuthenticated(execute) {
|
||||||
requireOnline()
|
requireOnline()
|
||||||
|
const revision = getSessionRevision()
|
||||||
let accessToken = await validAccessToken()
|
let accessToken = await validAccessToken()
|
||||||
|
requireSameSession(revision)
|
||||||
let response = await execute(accessToken)
|
let response = await execute(accessToken)
|
||||||
|
requireSameSession(revision)
|
||||||
if (!isUnauthorized(response)) return response
|
if (!isUnauthorized(response)) return response
|
||||||
accessToken = session.token && session.token !== accessToken ? session.token : await refreshAccessToken()
|
accessToken = session.token && session.token !== accessToken ? session.token : await refreshAccessToken()
|
||||||
|
requireSameSession(revision)
|
||||||
const activeRefreshToken = session.refreshToken
|
const activeRefreshToken = session.refreshToken
|
||||||
response = await execute(accessToken)
|
response = await execute(accessToken)
|
||||||
|
requireSameSession(revision)
|
||||||
if (isUnauthorized(response)) expireSession(response, activeRefreshToken)
|
if (isUnauthorized(response)) expireSession(response, activeRefreshToken)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
+1
-1
@@ -3,7 +3,7 @@ let selectedApiRoot = ''
|
|||||||
selectedApiRoot = import.meta.env.VITE_API_BASE_URL || '/api'
|
selectedApiRoot = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
// H5 必须请求同源地址,由 Vite 开发服务器代理到国内 Gateway,避免浏览器 CORS。
|
// H5 必须请求同源地址,由 Vite 开发服务器代理到统一 Gateway,避免浏览器 CORS。
|
||||||
// 前缀不能使用 /api,否则会截获本项目 api/ 目录下的前端模块请求。
|
// 前缀不能使用 /api,否则会截获本项目 api/ 目录下的前端模块请求。
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
selectedApiRoot = import.meta.env.VITE_H5_API_BASE_URL || '/gateway-api'
|
selectedApiRoot = import.meta.env.VITE_H5_API_BASE_URL || '/gateway-api'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
|
ARG NGINX_BASE_IMAGE=nginx:1.27-alpine
|
||||||
FROM ${NGINX_BASE_IMAGE}
|
FROM ${NGINX_BASE_IMAGE}
|
||||||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY unpackage/dist/build/web/ /srv/pda-h5/pda-domestic/
|
COPY unpackage/dist/build/web/ /srv/pda-h5/
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -12,7 +12,7 @@ services:
|
|||||||
expose:
|
expose:
|
||||||
- "80"
|
- "80"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/pda-domestic/"]
|
test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 12
|
retries: 12
|
||||||
|
|||||||
+1
-3
@@ -8,11 +8,9 @@ server {
|
|||||||
add_header Cache-Control "no-store" always;
|
add_header Cache-Control "no-store" always;
|
||||||
return 200 "ok\n";
|
return 200 "ok\n";
|
||||||
}
|
}
|
||||||
location = /pda-domestic { return 302 /pda-domestic/; }
|
location / {
|
||||||
location ^~ /pda-domestic/ {
|
|
||||||
index index.html;
|
index index.html;
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
add_header Cache-Control "no-store" always;
|
add_header Cache-Control "no-store" always;
|
||||||
}
|
}
|
||||||
location / { return 404; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
-22
@@ -129,14 +129,14 @@
|
|||||||
<text class="evidence-count" :class="{ complete: evidenceEntries(slot.type).length }">{{ evidenceEntries(slot.type).length ? `已拍 ${evidenceEntries(slot.type).length} 张` : '待拍摄' }}</text>
|
<text class="evidence-count" :class="{ complete: evidenceEntries(slot.type).length }">{{ evidenceEntries(slot.type).length ? `已拍 ${evidenceEntries(slot.type).length} 张` : '待拍摄' }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<button class="evidence-btn evidence-add-btn" @tap="chooseEvidence(slot)">{{ evidenceUploadLabel(slot.type) }}</button>
|
<button class="evidence-btn evidence-add-btn" :disabled="evidenceReadOnly" @tap="chooseEvidence(slot)">{{ evidenceUploadLabel(slot.type) }}</button>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="evidenceEntries(slot.type).length" class="evidence-photo-grid">
|
<view v-if="evidenceEntries(slot.type).length" class="evidence-photo-grid">
|
||||||
<view v-for="photo in evidenceEntries(slot.type)" :key="photo.id || photo.fileId || photo.localPath" class="evidence-photo-item">
|
<view v-for="photo in evidenceEntries(slot.type)" :key="photo.id || photo.fileId || photo.localPath" class="evidence-photo-item">
|
||||||
<image v-if="evidencePreviewSource(photo)" class="evidence-thumbnail" :src="evidencePreviewSource(photo)" mode="aspectFill" @tap="previewEvidencePhoto(photo, slot.type)" />
|
<image v-if="evidencePreviewSource(photo)" class="evidence-thumbnail" :src="evidencePreviewSource(photo)" mode="aspectFill" @tap="previewEvidencePhoto(photo, slot.type)" />
|
||||||
<view v-else class="evidence-thumbnail evidence-thumbnail-placeholder" @tap="previewEvidencePhoto(photo, slot.type)">图片处理中</view>
|
<view v-else class="evidence-thumbnail evidence-thumbnail-placeholder" @tap="previewEvidencePhoto(photo, slot.type)">图片处理中</view>
|
||||||
<text class="evidence-file" @tap="previewEvidencePhoto(photo, slot.type)">{{ photo.fileName || '已记录' }}</text>
|
<text class="evidence-file" @tap="previewEvidencePhoto(photo, slot.type)">{{ photo.fileName || '已记录' }}</text>
|
||||||
<button v-if="photo.id && photo.status !== 'VOID' && photo.status !== 'VOIDED'" class="evidence-delete-btn" aria-label="删除照片" @tap="deleteEvidencePhoto(photo)">×</button>
|
<button v-if="!evidenceReadOnly && photo.id && photo.status !== 'VOID' && photo.status !== 'VOIDED'" class="evidence-delete-btn" aria-label="删除照片" @tap="deleteEvidencePhoto(photo)">×</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text v-if="evidenceQueueEntry(slot.type)" class="evidence-upload-state" :class="evidenceQueueEntry(slot.type).status.toLowerCase()">
|
<text v-if="evidenceQueueEntry(slot.type)" class="evidence-upload-state" :class="evidenceQueueEntry(slot.type).status.toLowerCase()">
|
||||||
@@ -149,11 +149,12 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="seal-row">
|
<view class="seal-row">
|
||||||
<input v-model="sealNo" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
|
<input v-model="sealNo" :disabled="evidenceReadOnly" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
|
||||||
<button class="evidence-btn seal-btn" :disabled="sealSaving || !sealReadyToSave" @tap="saveSealNo">{{ sealSaving ? '上传中…' : '保存并上传' }}</button>
|
<button class="evidence-btn seal-btn" :disabled="evidenceReadOnly || sealSaving || !sealReadyToSave" @tap="saveSealNo">{{ sealSaving ? '上传中…' : '保存并上传' }}</button>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="remoteError" class="remote-error-row"><text class="remote-error" role="alert">{{ remoteError }}</text><button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button></view>
|
<view v-if="remoteError" class="remote-error-row"><text class="remote-error" role="alert">{{ remoteError }}</text><button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button></view>
|
||||||
<button class="loading-complete-btn" :disabled="submitting || !loadingStageReady || pickingVersion === null || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button>
|
<text v-if="evidenceReadOnly" class="version-hint" role="status">{{ loadingCompletionState.label }}</text>
|
||||||
|
<button v-else class="loading-complete-btn" :disabled="submitting || !loadingStageReady || pickingVersion === null || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button>
|
||||||
<view v-if="pickingVersion === null" class="remote-error-row">
|
<view v-if="pickingVersion === null" class="remote-error-row">
|
||||||
<text class="version-hint" role="status">状态尚未同步,请刷新重试</text>
|
<text class="version-hint" role="status">状态尚未同步,请刷新重试</text>
|
||||||
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
|
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
|
||||||
@@ -166,7 +167,7 @@
|
|||||||
|
|
||||||
<view v-if="activeStage === 'picking'" class="submit-section">
|
<view v-if="activeStage === 'picking'" class="submit-section">
|
||||||
<view class="submit-info">
|
<view class="submit-info">
|
||||||
<view class="submit-pending"><text class="submit-pending-number">{{ summary.pending }}</text><text class="submit-pending-label"> 箱待拣</text></view>
|
<view class="submit-pending"><text class="submit-pending-number">{{ remainingCartons }}</text><text class="submit-pending-label"> 箱待拣</text></view>
|
||||||
<text class="submit-status-hint">{{ submitStatusHint }}</text>
|
<text class="submit-status-hint">{{ submitStatusHint }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="remoteError && !pickingCompleted" class="remote-error-row submit-error-row">
|
<view v-if="remoteError && !pickingCompleted" class="remote-error-row submit-error-row">
|
||||||
@@ -281,7 +282,7 @@
|
|||||||
<uni-popup ref="submitConfirmPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('submitConfirm', $event)">
|
<uni-popup ref="submitConfirmPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('submitConfirm', $event)">
|
||||||
<view class="modal-sheet decision-modal submit-decision-modal" role="dialog" aria-modal="true" aria-label="确认未完成提交" @tap.stop>
|
<view class="modal-sheet decision-modal submit-decision-modal" role="dialog" aria-modal="true" aria-label="确认未完成提交" @tap.stop>
|
||||||
<PopupHeader :title="`仍有 ${summary.pending} 件未完成`" tone="danger" @close="closeOverlay" />
|
<PopupHeader :title="`仍有 ${summary.pending} 件未完成`" tone="danger" @close="closeOverlay" />
|
||||||
<text class="decision-desc submit-decision-desc">确认提交后,以下待拣商品会被明确标记为“未拣出”,本次演示提交不可撤销。</text>
|
<text class="decision-desc submit-decision-desc">确认提交后,以下待拣商品会标记为“未拣出”,并记录所选原因。</text>
|
||||||
<view class="pending-sku-list">
|
<view class="pending-sku-list">
|
||||||
<text v-for="item in pendingItems.slice(0, 3)" :key="item.id">{{ item.location ? `${item.sku} · ${item.location}` : item.sku }}</text>
|
<text v-for="item in pendingItems.slice(0, 3)" :key="item.id">{{ item.location ? `${item.sku} · ${item.location}` : item.sku }}</text>
|
||||||
<text v-if="pendingItems.length > 3">另有 {{ pendingItems.length - 3 }} 件</text>
|
<text v-if="pendingItems.length > 3">另有 {{ pendingItems.length - 3 }} 件</text>
|
||||||
@@ -292,7 +293,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="modal-actions submit-decision-actions">
|
<view class="modal-actions submit-decision-actions">
|
||||||
<button class="modal-cancel-btn" @tap="closeOverlay">继续拣货</button>
|
<button class="modal-cancel-btn" @tap="closeOverlay">继续拣货</button>
|
||||||
<button class="modal-confirm-btn danger-confirm" :disabled="!selectedIncompleteReason" @tap="confirmIncompleteSubmit">确认未拣并提交</button>
|
<button class="modal-confirm-btn danger-confirm" :disabled="submitting || !selectedIncompleteReason" @tap="confirmIncompleteSubmit">{{ submitting ? '正在提交…' : '确认未拣并提交' }}</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</uni-popup>
|
</uni-popup>
|
||||||
@@ -336,7 +337,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, isSealedPalletItem, submitStates, summarizeInventoryResources } from '@/utils/picking-state.js'
|
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, isSealedPalletItem, submitStates, summarizeInventoryResources, remainingPhysicalCartons } from '@/utils/picking-state.js'
|
||||||
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
|
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
|
||||||
import { session, requireSession } from '@/stores/session.js'
|
import { session, requireSession } from '@/stores/session.js'
|
||||||
import { clearLoadingCompletionAttempt, clearPickingDraft, getPickingSession, restoreLoadingCompletionAttempt, restorePickingSession, saveLoadingCompletionAttempt, savePickingSession } from '@/stores/picking.js'
|
import { clearLoadingCompletionAttempt, clearPickingDraft, getPickingSession, restoreLoadingCompletionAttempt, restorePickingSession, saveLoadingCompletionAttempt, savePickingSession } from '@/stores/picking.js'
|
||||||
@@ -348,7 +349,7 @@
|
|||||||
import { isOnline } from '@/utils/network.js'
|
import { isOnline } from '@/utils/network.js'
|
||||||
import { stableOperationId } from '@/utils/idempotency.js'
|
import { stableOperationId } from '@/utils/idempotency.js'
|
||||||
import { stableTaskOperationIdentity } from '@/utils/task-identity.js'
|
import { stableTaskOperationIdentity } from '@/utils/task-identity.js'
|
||||||
import { COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion } from '@/utils/loading-completion-state.js'
|
import { COMPLETION_PHASE, completionPresentation, resolveCompletionPhase, shouldReconcileCompletion } from '@/utils/loading-completion-state.js'
|
||||||
import { containerReference, formatApiError, sanitizeUserMessage } from '@/utils/user-facing.js'
|
import { containerReference, formatApiError, sanitizeUserMessage } from '@/utils/user-facing.js'
|
||||||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||||||
import PopupHeader from '@/components/PopupHeader.vue'
|
import PopupHeader from '@/components/PopupHeader.vue'
|
||||||
@@ -416,6 +417,9 @@
|
|||||||
pendingItems() {
|
pendingItems() {
|
||||||
return this.currentBox.items.filter((item) => this.itemState(item) === ITEM_STATUS.PENDING)
|
return this.currentBox.items.filter((item) => this.itemState(item) === ITEM_STATUS.PENDING)
|
||||||
},
|
},
|
||||||
|
remainingCartons() {
|
||||||
|
return remainingPhysicalCartons(this.currentBox.items, this.itemStates, this.partialQuantities)
|
||||||
|
},
|
||||||
scanInputLabel() {
|
scanInputLabel() {
|
||||||
return this.pendingItems.some((item) => isSealedPalletItem(item)) ? '托盘码或托内箱码扫码框' : '箱码扫码框'
|
return this.pendingItems.some((item) => isSealedPalletItem(item)) ? '托盘码或托内箱码扫码框' : '箱码扫码框'
|
||||||
},
|
},
|
||||||
@@ -444,6 +448,17 @@
|
|||||||
&& Boolean(this.remoteTaskId && this.currentBox.loadingPlanId && this.currentBox.containerBookingId)
|
&& Boolean(this.remoteTaskId && this.currentBox.loadingPlanId && this.currentBox.containerBookingId)
|
||||||
&& this.pickingVersion !== null
|
&& this.pickingVersion !== null
|
||||||
},
|
},
|
||||||
|
loadingCompletionState() {
|
||||||
|
const phase = resolveCompletionPhase({
|
||||||
|
taskStatus: this.remotePick?.workflowStatus || this.remotePick?.taskStatus,
|
||||||
|
commandStatus: this.remotePick?.completionCommand?.status || this.remotePick?.commandStatus,
|
||||||
|
submitting: this.submitting
|
||||||
|
})
|
||||||
|
return { ...completionPresentation(phase), phase }
|
||||||
|
},
|
||||||
|
evidenceReadOnly() {
|
||||||
|
return this.loadingCompletionState.blocked && !this.loadingCompletionState.retryable
|
||||||
|
},
|
||||||
loadingStageReady() {
|
loadingStageReady() {
|
||||||
return this.pickingCompleted
|
return this.pickingCompleted
|
||||||
&& this.evidenceIdentity
|
&& this.evidenceIdentity
|
||||||
@@ -559,12 +574,15 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this._showRefreshPromise) return this._showRefreshPromise
|
if (this._showRefreshPromise) return this._showRefreshPromise
|
||||||
this._showRefreshPromise = (async () => {
|
const showGeneration = this.taskRequestId
|
||||||
|
const refreshPromise = (async () => {
|
||||||
await this.refreshAvailableBoxes()
|
await this.refreshAvailableBoxes()
|
||||||
|
if (!this._pageVisible || showGeneration !== this.taskRequestId) return
|
||||||
await this.syncRemoteTask()
|
await this.syncRemoteTask()
|
||||||
})()
|
})()
|
||||||
try { await this._showRefreshPromise }
|
this._showRefreshPromise = refreshPromise
|
||||||
finally { this._showRefreshPromise = null }
|
try { await refreshPromise }
|
||||||
|
finally { if (this._showRefreshPromise === refreshPromise) this._showRefreshPromise = null }
|
||||||
},
|
},
|
||||||
onHide() {
|
onHide() {
|
||||||
this._pageVisible = false
|
this._pageVisible = false
|
||||||
@@ -572,10 +590,11 @@
|
|||||||
this.evidencePreviewOpening = false
|
this.evidencePreviewOpening = false
|
||||||
this.evidencePreviewReturning = true
|
this.evidencePreviewReturning = true
|
||||||
}
|
}
|
||||||
|
if (!this.cameraPicking && !this.evidencePreviewReturning) this.invalidateTaskSync()
|
||||||
this.stopHardwareScanner()
|
this.stopHardwareScanner()
|
||||||
},
|
},
|
||||||
onUnload() { this.stopHardwareScanner(); this.persistSession(); this.clearTimers() },
|
onUnload() { this._pageVisible = false; this.invalidateTaskSync(); this.evidenceContextId++; this.stopHardwareScanner(); this.persistSession(); this.clearTimers() },
|
||||||
beforeUnmount() { this.stopHardwareScanner(); this.clearTimers() },
|
beforeUnmount() { this._pageVisible = false; this.invalidateTaskSync(); this.stopHardwareScanner(); this.clearTimers() },
|
||||||
onBackPress() {
|
onBackPress() {
|
||||||
if (this.activeOverlay) { this.closeOverlay(); return true }
|
if (this.activeOverlay) { this.closeOverlay(); return true }
|
||||||
if (this.boxDropdownOpen) { this.boxDropdownOpen = false; return true }
|
if (this.boxDropdownOpen) { this.boxDropdownOpen = false; return true }
|
||||||
@@ -583,6 +602,11 @@
|
|||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
invalidateTaskSync() { this.taskRequestId++; this.remoteLoading = false; this._showRefreshPromise = null },
|
||||||
|
isTaskSyncActive(requestId, taskId) {
|
||||||
|
return this._pageVisible === true && requestId === this.taskRequestId
|
||||||
|
&& String(this.remoteTaskId || this.currentBox.taskId) === String(taskId)
|
||||||
|
},
|
||||||
containerReference,
|
containerReference,
|
||||||
isSelectableTask(task) {
|
isSelectableTask(task) {
|
||||||
return Boolean(task?.recordStatus === 'ACTIVE'
|
return Boolean(task?.recordStatus === 'ACTIVE'
|
||||||
@@ -791,6 +815,10 @@
|
|||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
requireEvidenceStage() {
|
requireEvidenceStage() {
|
||||||
|
if (this.evidenceReadOnly) {
|
||||||
|
this.showToast(this.loadingCompletionState.label, 'warning')
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (!this.pickingCompleted) {
|
if (!this.pickingCompleted) {
|
||||||
this.showToast('请先提交拣货完成', 'warning')
|
this.showToast('请先提交拣货完成', 'warning')
|
||||||
return false
|
return false
|
||||||
@@ -1003,7 +1031,7 @@
|
|||||||
[ITEM_STATUS.PARTIAL]: '已部分拣货',
|
[ITEM_STATUS.PARTIAL]: '已部分拣货',
|
||||||
[ITEM_STATUS.SKIPPED]: '已跳拣'
|
[ITEM_STATUS.SKIPPED]: '已跳拣'
|
||||||
}
|
}
|
||||||
this.recordScanResult('warning', '重复扫描', value, `该条码已${labels[result.status]},无需重复处理`)
|
this.recordScanResult('warning', '重复扫描', value, `该条码${labels[result.status]},无需重复处理`)
|
||||||
this.showToast(`该条码已处理:${value}`, 'warning')
|
this.showToast(`该条码已处理:${value}`, 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1074,7 +1102,7 @@
|
|||||||
return ''
|
return ''
|
||||||
},
|
},
|
||||||
async syncRemoteTask() {
|
async syncRemoteTask() {
|
||||||
if (!session.token) return
|
if (!session.token || !this._pageVisible) return
|
||||||
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
|
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
|
||||||
if (!requestedTaskId) { this.remoteError = '当前没有可进入的拣货任务,请返回任务列表刷新'; return }
|
if (!requestedTaskId) { this.remoteError = '当前没有可进入的拣货任务,请返回任务列表刷新'; return }
|
||||||
this.rememberEvidenceDraft()
|
this.rememberEvidenceDraft()
|
||||||
@@ -1098,7 +1126,7 @@
|
|||||||
try {
|
try {
|
||||||
const warehouseId = this.currentBox.warehouseId
|
const warehouseId = this.currentBox.warehouseId
|
||||||
const task = await getContainerPickingTask(selector, warehouseId)
|
const task = await getContainerPickingTask(selector, warehouseId)
|
||||||
if (requestId !== this.taskRequestId) return
|
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||||
if (!task) throw new Error('服务端未返回当前拣货任务')
|
if (!task) throw new Error('服务端未返回当前拣货任务')
|
||||||
if (this.isMergedContainerRecord(task)) {
|
if (this.isMergedContainerRecord(task)) {
|
||||||
this.clearMergedTask(task, previousIdentity)
|
this.clearMergedTask(task, previousIdentity)
|
||||||
@@ -1165,9 +1193,10 @@
|
|||||||
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
|
||||||
if (remoteTask.workflowStatus === 'UNASSIGNED') {
|
if (remoteTask.workflowStatus === 'UNASSIGNED') {
|
||||||
|
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||||
const operationId = `claim-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${task.loadingPlanId}-${task.containerBookingId}`
|
const operationId = `claim-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${task.loadingPlanId}-${task.containerBookingId}`
|
||||||
claimed = await claimContainerPickingTask(this.remoteTaskId, this.assignmentVersion, operationId)
|
claimed = await claimContainerPickingTask(this.remoteTaskId, this.assignmentVersion, operationId)
|
||||||
if (requestId !== this.taskRequestId) return
|
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||||
} else if (remoteTask.workflowStatus === 'IN_PROGRESS') {
|
} else if (remoteTask.workflowStatus === 'IN_PROGRESS') {
|
||||||
const assigneeId = remoteTask.assignment?.assigneeId
|
const assigneeId = remoteTask.assignment?.assigneeId
|
||||||
if (!assigneeId || String(assigneeId) !== String(session.currentUser?.id || '')) throw new Error('当前任务已由其他作业员领取,请返回任务列表刷新')
|
if (!assigneeId || String(assigneeId) !== String(session.currentUser?.id || '')) throw new Error('当前任务已由其他作业员领取,请返回任务列表刷新')
|
||||||
@@ -1199,9 +1228,10 @@
|
|||||||
if (addedItemIds.length) this.showToast(`已同步 ${addedItemIds.length} 条新增待拣明细`, 'warning')
|
if (addedItemIds.length) this.showToast(`已同步 ${addedItemIds.length} 条新增待拣明细`, 'warning')
|
||||||
}
|
}
|
||||||
await this.loadLoadingEvidence()
|
await this.loadLoadingEvidence()
|
||||||
|
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||||
if (this.loadingStageReady) this.activeStage = 'evidence'
|
if (this.loadingStageReady) this.activeStage = 'evidence'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== this.taskRequestId) return
|
if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
|
||||||
this.debugSubmit('refresh.error', { requestId, message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
|
this.debugSubmit('refresh.error', { requestId, message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
|
||||||
this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : this.remoteFailureMessage(error, '任务同步失败')
|
this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : this.remoteFailureMessage(error, '任务同步失败')
|
||||||
} finally { if (requestId === this.taskRequestId) this.remoteLoading = false }
|
} finally { if (requestId === this.taskRequestId) this.remoteLoading = false }
|
||||||
@@ -1659,10 +1689,11 @@
|
|||||||
this.performSubmit()
|
this.performSubmit()
|
||||||
},
|
},
|
||||||
confirmIncompleteSubmit() {
|
confirmIncompleteSubmit() {
|
||||||
if (!this.selectedIncompleteReason) return
|
if (this.submitting || !this.selectedIncompleteReason) return
|
||||||
this.performSubmit(this.selectedIncompleteReason)
|
return this.performSubmit(this.selectedIncompleteReason)
|
||||||
},
|
},
|
||||||
async performSubmit(incompleteReason = '') {
|
async performSubmit(incompleteReason = '') {
|
||||||
|
if (this.submitting) return
|
||||||
const beforeSubmit = this.summary
|
const beforeSubmit = this.summary
|
||||||
const remoteRequired = Boolean(session.token)
|
const remoteRequired = Boolean(session.token)
|
||||||
this.remoteError = ''
|
this.remoteError = ''
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">国内仓作业员</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">08:00–17:00</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">{{ appVersion }}</text></view><button class="profile-row profile-row-action" @tap="checkUpdate"><text>检查更新</text><text class="profile-row-arrow">›</text></button><button class="profile-row profile-row-action" @tap="showSettings"><text>系统设置</text><text class="profile-row-arrow">›</text></button></view><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
|
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">国内仓作业员</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">08:00–17:00</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">{{ appVersion }}</text></view><button class="profile-row profile-row-action" @tap="checkUpdate"><text>检查更新</text><text class="profile-row-arrow">›</text></button><button class="profile-row profile-row-action" @tap="showSettings"><text>系统设置</text><text class="profile-row-arrow">›</text></button></view><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import { logout as logoutRequest } from '@/api/auth.js'
|
import { logoutCurrentSession } from '@/api/logout-session.js'
|
||||||
import PortalHeader from '@/components/PortalHeader.vue'
|
import PortalHeader from '@/components/PortalHeader.vue'
|
||||||
import PopupHeader from '@/components/PopupHeader.vue'
|
import PopupHeader from '@/components/PopupHeader.vue'
|
||||||
import { session, requireSession, signOut } from '@/stores/session.js'
|
import { session, requireSession, getSessionRevision } from '@/stores/session.js'
|
||||||
import { checkForAppUpdate } from '@/utils/app-update.js'
|
import { checkForAppUpdate } from '@/utils/app-update.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -34,11 +34,20 @@
|
|||||||
handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
|
handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
|
||||||
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
|
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
|
||||||
logout() {
|
logout() {
|
||||||
const refreshToken = session.refreshToken
|
if (this._logoutPromise) return this._logoutPromise
|
||||||
const accessToken = session.token
|
this.closeLogout()
|
||||||
signOut()
|
const operation = logoutCurrentSession()
|
||||||
uni.reLaunch({ url: '/pages/login/login' })
|
this.user = null
|
||||||
if (refreshToken) logoutRequest(refreshToken, accessToken).catch(() => {})
|
this._logoutPromise = operation.then(result => {
|
||||||
|
if (getSessionRevision() === result.clearedRevision) uni.reLaunch({ url: '/pages/login/login' })
|
||||||
|
return result
|
||||||
|
}, error => {
|
||||||
|
if (getSessionRevision() === error.logoutRevision) {
|
||||||
|
uni.reLaunch({ url: '/pages/login/login', complete() { uni.showToast({ title: '已清除本地登录,服务端退出未确认', icon: 'none', duration: 3000 }) } })
|
||||||
|
}
|
||||||
|
return { revoked: false, error }
|
||||||
|
})
|
||||||
|
return this._logoutPromise
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
const project = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
const project = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
const output = resolve(project, 'unpackage/dist/build/web')
|
const output = resolve(project, 'unpackage/dist/build/web')
|
||||||
const base = '/pda-domestic/'
|
const base = '/'
|
||||||
function walk(dir) { return readdirSync(dir).flatMap(name => { const path = resolve(dir, name); return statSync(path).isDirectory() ? walk(path) : [path] }) }
|
function walk(dir) { return readdirSync(dir).flatMap(name => { const path = resolve(dir, name); return statSync(path).isDirectory() ? walk(path) : [path] }) }
|
||||||
assert.ok(existsSync(resolve(output, 'index.html')), 'Build H5 before verifying')
|
assert.ok(existsSync(resolve(output, 'index.html')), 'Build H5 before verifying')
|
||||||
const files = walk(output).sort()
|
const files = walk(output).sort()
|
||||||
@@ -17,7 +17,7 @@ for (const ref of refs) {
|
|||||||
assert.ok(existsSync(resolve(output, ref.slice(base.length))), `Missing asset: ${ref}`)
|
assert.ok(existsSync(resolve(output, ref.slice(base.length))), `Missing asset: ${ref}`)
|
||||||
}
|
}
|
||||||
const js = files.filter(path => path.endsWith('.js')).map(path => readFileSync(path, 'utf8')).join('\n')
|
const js = files.filter(path => path.endsWith('.js')).map(path => readFileSync(path, 'utf8')).join('\n')
|
||||||
assert.ok(js.includes(base), 'Runtime router and assets must know the deployment base')
|
assert.doesNotMatch(html + js, /\/pda-(?:domestic|overseas)\//, 'Build must not reference the retired subdirectory deployment')
|
||||||
assert.doesNotMatch(js, /VITE_ROOT_DIR|VITE_AUTH_API_BASE_URL|VITE_OVERSEAS_API_BASE_URL|overseas-api|overseasAccessToken/, 'Build contains broad local env or old overseas config')
|
assert.doesNotMatch(js, /VITE_ROOT_DIR|VITE_AUTH_API_BASE_URL|VITE_OVERSEAS_API_BASE_URL|overseas-api|overseasAccessToken/, 'Build contains broad local env or old overseas config')
|
||||||
assert.doesNotMatch(js, /https?:\/\/(?:localhost|127\.0\.0\.1):\d+/, 'Build must not depend on a local dev server')
|
assert.doesNotMatch(js, /https?:\/\/(?:localhost|127\.0\.0\.1):\d+/, 'Build must not depend on a local dev server')
|
||||||
assert.doesNotMatch(js, /https?:\/\/[^"'\s]+\/api["']/, 'API must be same-origin for this H5 deployment')
|
assert.doesNotMatch(js, /https?:\/\/[^"'\s]+\/api["']/, 'API must be same-origin for this H5 deployment')
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { decodeAccessToken } from '@/utils/jwt-claims.js'
|
|||||||
|
|
||||||
export { decodeAccessToken } from '@/utils/jwt-claims.js'
|
export { decodeAccessToken } from '@/utils/jwt-claims.js'
|
||||||
|
|
||||||
|
let sessionRevision = 0
|
||||||
|
export function getSessionRevision() { return sessionRevision }
|
||||||
|
|
||||||
const SESSION_KEY = 'pda-auth-session'
|
const SESSION_KEY = 'pda-auth-session'
|
||||||
const LOGIN_PREFERENCE_KEY = 'pda-login-account-preference'
|
const LOGIN_PREFERENCE_KEY = 'pda-login-account-preference'
|
||||||
|
|
||||||
@@ -53,6 +56,7 @@ function persistSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function signIn(account, warehouse, options = {}) {
|
export function signIn(account, warehouse, options = {}) {
|
||||||
|
sessionRevision++
|
||||||
session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse, warehouseId: account.warehouseId || null, domesticWarehouseId: account.domesticWarehouseId || null, warehouseBound: Boolean(account.domesticWarehouseId) }
|
session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse, warehouseId: account.warehouseId || null, domesticWarehouseId: account.domesticWarehouseId || null, warehouseBound: Boolean(account.domesticWarehouseId) }
|
||||||
session.token = options.token || account.token || ''
|
session.token = options.token || account.token || ''
|
||||||
session.refreshToken = options.refreshToken || account.refreshToken || ''
|
session.refreshToken = options.refreshToken || account.refreshToken || ''
|
||||||
@@ -144,6 +148,7 @@ export function restoreSession() {
|
|||||||
tenantId: claims.tenant_id == null ? (saved.tenantId || '') : String(claims.tenant_id),
|
tenantId: claims.tenant_id == null ? (saved.tenantId || '') : String(claims.tenant_id),
|
||||||
apiBase: saved.apiBase || PLATFORM_API_ROOT
|
apiBase: saved.apiBase || PLATFORM_API_ROOT
|
||||||
})
|
})
|
||||||
|
sessionRevision++
|
||||||
persistSession()
|
persistSession()
|
||||||
return true
|
return true
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -153,6 +158,7 @@ export function restoreSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function signOut() {
|
export function signOut() {
|
||||||
|
sessionRevision++
|
||||||
session.currentUser = null
|
session.currentUser = null
|
||||||
session.token = ''
|
session.token = ''
|
||||||
session.refreshToken = ''
|
session.refreshToken = ''
|
||||||
|
|||||||
@@ -57,11 +57,14 @@ test('confirmed session expiry clears sensitive state and redirects only once',
|
|||||||
assert.match(http, /if \(isUnauthorized\(error\)\) return expireSession/)
|
assert.match(http, /if \(isUnauthorized\(error\)\) return expireSession/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('manual logout revokes the refresh token without delaying local cleanup', async () => {
|
test('manual logout waits for captured-session revoke and honestly handles failure', async () => {
|
||||||
const [profile, auth] = await Promise.all([read('../pages/profile/profile.vue'), read('../api/auth.js')])
|
const [profile, helper] = await Promise.all([read('../pages/profile/profile.vue'), read('../api/logout-session.js')])
|
||||||
assert.match(auth, /export function logout\(refreshToken, accessToken\)/)
|
assert.match(profile, /logoutCurrentSession\(\)/)
|
||||||
assert.match(profile, /const refreshToken = session\.refreshToken[\s\S]*?const accessToken = session\.token[\s\S]*?signOut\(\)[\s\S]*?uni\.reLaunch/)
|
assert.match(profile, /operation\.then/)
|
||||||
assert.match(profile, /logoutRequest\(refreshToken, accessToken\)\.catch/)
|
assert.match(profile, /服务端退出未确认/)
|
||||||
|
assert.match(helper, /signOut\(\)/)
|
||||||
|
assert.match(helper, /error\.statusCode !== 401/)
|
||||||
|
assert.doesNotMatch(profile, /catch\(\(\) => \{\}\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('production login starts empty and remembers only the account preference', async () => {
|
test('production login starts empty and remembers only the account preference', async () => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const read = path => readFile(new URL(path, import.meta.url), 'utf8')
|
|||||||
test('H5 image copies only compiled assets and static nginx configuration', async () => {
|
test('H5 image copies only compiled assets and static nginx configuration', async () => {
|
||||||
const dockerfile = await read('../deploy/Dockerfile.h5')
|
const dockerfile = await read('../deploy/Dockerfile.h5')
|
||||||
const copies = dockerfile.split(/\r?\n/).filter(line => line.startsWith('COPY '))
|
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 unpackage/dist/build/web/ /srv/pda-h5/pda-domestic/'])
|
assert.deepEqual(copies, ['COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf', 'COPY unpackage/dist/build/web/ /srv/pda-h5/'])
|
||||||
assert.match(dockerfile, /ARG NGINX_BASE_IMAGE=/)
|
assert.match(dockerfile, /ARG NGINX_BASE_IMAGE=/)
|
||||||
assert.match(dockerfile, /FROM \$\{NGINX_BASE_IMAGE\}/)
|
assert.match(dockerfile, /FROM \$\{NGINX_BASE_IMAGE\}/)
|
||||||
assert.doesNotMatch(dockerfile, /RUN |npm|node_modules|COPY \. /)
|
assert.doesNotMatch(dockerfile, /RUN |npm|node_modules|COPY \. /)
|
||||||
@@ -15,14 +15,14 @@ test('H5 image copies only compiled assets and static nginx configuration', asyn
|
|||||||
assert.ok(ignore.includes('!unpackage/dist/build/web/**'))
|
assert.ok(ignore.includes('!unpackage/dist/build/web/**'))
|
||||||
assert.doesNotMatch(ignore, /!\.env|!node_modules|!src/)
|
assert.doesNotMatch(ignore, /!\.env|!node_modules|!src/)
|
||||||
})
|
})
|
||||||
test('static server preserves the app prefix and returns genuine missing-resource errors', async () => {
|
test('static server serves the domain root and returns genuine missing-resource errors', async () => {
|
||||||
const nginx = await read('../deploy/nginx.conf')
|
const nginx = await read('../deploy/nginx.conf')
|
||||||
assert.match(nginx, /listen 80;/)
|
assert.match(nginx, /listen 80;/)
|
||||||
assert.ok(nginx.includes('location ^~ /pda-domestic/'))
|
assert.ok(nginx.includes('location /'))
|
||||||
assert.match(nginx, /root \/srv\/pda-h5;/)
|
assert.match(nginx, /root \/srv\/pda-h5;/)
|
||||||
assert.match(nginx, /try_files \$uri \$uri\/ =404;/)
|
assert.match(nginx, /try_files \$uri \$uri\/ =404;/)
|
||||||
assert.match(nginx, /Cache-Control "no-store" always/)
|
assert.match(nginx, /Cache-Control "no-store" always/)
|
||||||
assert.match(nginx, /location \/ \{ return 404; \}/)
|
assert.doesNotMatch(nginx, /pda-domestic|pda-overseas/)
|
||||||
assert.match(nginx, /location = \/healthz/)
|
assert.match(nginx, /location = \/healthz/)
|
||||||
assert.doesNotMatch(nginx, /proxy_pass|gateway-api|try_files[^;]*index\.html/)
|
assert.doesNotMatch(nginx, /proxy_pass|gateway-api|try_files[^;]*index\.html/)
|
||||||
})
|
})
|
||||||
@@ -36,7 +36,7 @@ test('TEST H5 deployment has a required image tag and internal-only dual health
|
|||||||
assert.match(compose, /external: true/)
|
assert.match(compose, /external: true/)
|
||||||
assert.match(compose, /name: firstleg-test-network/)
|
assert.match(compose, /name: firstleg-test-network/)
|
||||||
assert.match(compose, /expose:\s*\n\s*- "80"/)
|
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/pda-domestic/'))
|
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/)
|
assert.doesNotMatch(compose, /\bports:|volumes:|API_UPSTREAM|privileged:|pid: host/)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -53,8 +53,34 @@ test('only H5 production overrides the toolchain asset base', async () => {
|
|||||||
process: { env: { UNI_PLATFORM: platform }, cwd: () => '/project' }
|
process: { env: { UNI_PLATFORM: platform }, cwd: () => '/project' }
|
||||||
})
|
})
|
||||||
assert.equal(factory({ mode }).base,
|
assert.equal(factory({ mode }).base,
|
||||||
platform === 'h5' && mode === 'production' ? '/pda-domestic/' : undefined,
|
platform === 'h5' && mode === 'production' ? '/' : undefined,
|
||||||
`${platform}:${mode}`)
|
`${platform}:${mode}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async () => {
|
||||||
|
const source = (await read('../vite.config.js')).replace(/^import .*$/gm, '').replace('export default ', '')
|
||||||
|
const factory = runInNewContext(source, {
|
||||||
|
defineConfig: value => value, loadEnv: () => ({ API_PROXY_TARGET: 'https://gateway.example.com' }),
|
||||||
|
uni: () => ({}), process: { env: { UNI_PLATFORM: 'h5' }, cwd: () => '/project' }
|
||||||
|
})
|
||||||
|
const proxy = factory({ mode: 'development' }).server.proxy
|
||||||
|
assert.deepEqual(Object.keys(proxy), ['/gateway-api'])
|
||||||
|
assert.equal(proxy['/gateway-api'].target, 'https://gateway.example.com')
|
||||||
|
for (const suffix of ['/auth/login', '/pda/v1/last-leg/photos/1/download?preview=1']) {
|
||||||
|
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/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
import {
|
import {
|
||||||
COMPLETION_PHASE,
|
COMPLETION_PHASE,
|
||||||
completionPresentation,
|
completionPresentation,
|
||||||
@@ -8,6 +9,31 @@ import {
|
|||||||
shouldReconcileCompletion
|
shouldReconcileCompletion
|
||||||
} from '../utils/loading-completion-state.js'
|
} from '../utils/loading-completion-state.js'
|
||||||
|
|
||||||
|
test('reopened processing and completed tasks keep evidence and completion read-only', async () => {
|
||||||
|
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
||||||
|
const methods = ['loadingCompletionState', 'evidenceReadOnly', 'requireEvidenceStage'].map(name => {
|
||||||
|
const method = page.match(new RegExp(`${name}\\(\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0]
|
||||||
|
assert.ok(method, name)
|
||||||
|
return method
|
||||||
|
})
|
||||||
|
const behavior = new Function('resolveCompletionPhase', 'completionPresentation', `return ({${methods.join('\n')}})`)(resolveCompletionPhase, completionPresentation)
|
||||||
|
for (const workflowStatus of ['PROCESSING', 'COMPLETED']) {
|
||||||
|
const messages = []
|
||||||
|
const context = { remotePick: { workflowStatus, completionCommand: { status: workflowStatus } }, submitting: false, pickingCompleted: true, loadingStageReady: true, showToast(message) { messages.push(message) } }
|
||||||
|
context.loadingCompletionState = behavior.loadingCompletionState.call(context)
|
||||||
|
context.evidenceReadOnly = behavior.evidenceReadOnly.call(context)
|
||||||
|
assert.equal(context.evidenceReadOnly, true)
|
||||||
|
assert.equal(behavior.requireEvidenceStage.call(context), false)
|
||||||
|
assert.deepEqual(messages, [workflowStatus === 'COMPLETED' ? '装柜已完成' : '完成处理中'])
|
||||||
|
}
|
||||||
|
const retry = { remotePick: { workflowStatus: 'COMPLETION_FAILED' }, submitting: false }
|
||||||
|
retry.loadingCompletionState = behavior.loadingCompletionState.call(retry)
|
||||||
|
assert.equal(behavior.evidenceReadOnly.call(retry), false)
|
||||||
|
assert.match(page, /v-if="evidenceReadOnly"[^>]*role="status"/)
|
||||||
|
assert.match(page, /v-else class="loading-complete-btn"/)
|
||||||
|
assert.match(page, /class="evidence-btn evidence-add-btn" :disabled="evidenceReadOnly"/)
|
||||||
|
})
|
||||||
|
|
||||||
test('accepted completion is processing and blocks every repeated mutation', () => {
|
test('accepted completion is processing and blocks every repeated mutation', () => {
|
||||||
for (const facts of [
|
for (const facts of [
|
||||||
{ taskStatus: 'COMPLETING' },
|
{ taskStatus: 'COMPLETING' },
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ test('workbench, module, history and profile are independent sources', async ()
|
|||||||
assert.match(module, /pages\/operation\/operation\?module=\$\{encodeURIComponent\(this\.moduleKey\)\}&name=/)
|
assert.match(module, /pages\/operation\/operation\?module=\$\{encodeURIComponent\(this\.moduleKey\)\}&name=/)
|
||||||
assert.match(history, /作业记录/)
|
assert.match(history, /作业记录/)
|
||||||
assert.match(profile, /个人中心/)
|
assert.match(profile, /个人中心/)
|
||||||
assert.match(profile, /signOut\(\)/)
|
assert.match(profile, /logoutCurrentSession\(\)/)
|
||||||
assert.match(profile, /class="logout-dialog-card"/)
|
assert.match(profile, /class="logout-dialog-card"/)
|
||||||
assert.doesNotMatch([home, module, history, profile].join('\n'), /BottomNav/)
|
assert.doesNotMatch([home, module, history, profile].join('\n'), /BottomNav/)
|
||||||
})
|
})
|
||||||
@@ -292,7 +292,10 @@ test('picking session persists per task identity and clears on logout', async ()
|
|||||||
assert.match(store, /savePickingSession/)
|
assert.match(store, /savePickingSession/)
|
||||||
assert.match(store, /resetPickingSession/)
|
assert.match(store, /resetPickingSession/)
|
||||||
assert.match(session, /signOut\(\)[\s\S]*?resetPickingSession\(\)/)
|
assert.match(session, /signOut\(\)[\s\S]*?resetPickingSession\(\)/)
|
||||||
assert.match(page, /onUnload\(\) \{ this\.stopHardwareScanner\(\); this\.persistSession\(\)/)
|
const unload = page.match(/onUnload\(\) \{([^}]+)\}/)?.[1] || ''
|
||||||
|
assert.match(unload, /this\.stopHardwareScanner\(\)/)
|
||||||
|
assert.match(unload, /this\.persistSession\(\)/)
|
||||||
|
assert.match(unload, /this\.invalidateTaskSync\(\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('shared visual contracts preserve accessible PDA targets', async () => {
|
test('shared visual contracts preserve accessible PDA targets', async () => {
|
||||||
|
|||||||
@@ -36,12 +36,14 @@ test('duplicate onShow shares one load chain while background return refreshes a
|
|||||||
const page = await read('../pages/picking/picking.vue')
|
const page = await read('../pages/picking/picking.vue')
|
||||||
const show = page.match(/async onShow\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
const show = page.match(/async onShow\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
||||||
const hide = page.match(/onHide\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
const hide = page.match(/onHide\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
||||||
assert.ok(show && hide)
|
const invalidate = page.match(/invalidateTaskSync\(\) \{[^\r\n]+\},/)?.[0]
|
||||||
const hooks = new Function('requireSession', 'session', `return ({${show}${hide}})`)(() => true, { currentUser: { id: '7' } })
|
assert.ok(show && hide && invalidate)
|
||||||
|
const hooks = new Function('requireSession', 'session', `return ({${show}${hide}${invalidate}})`)(() => true, { currentUser: { id: '7' } })
|
||||||
const calls = []
|
const calls = []
|
||||||
let release
|
let release
|
||||||
const pending = new Promise((resolve) => { release = resolve })
|
const pending = new Promise((resolve) => { release = resolve })
|
||||||
const context = { startHardwareScanner() {}, stopHardwareScanner() {}, async refreshAvailableBoxes() { calls.push('pending'); await pending }, async syncRemoteTask() { calls.push('detail+evidence') } }
|
const context = { taskRequestId: 0, invalidateTaskSync: hooks.invalidateTaskSync,
|
||||||
|
startHardwareScanner() {}, stopHardwareScanner() {}, async refreshAvailableBoxes() { calls.push('pending'); await pending }, async syncRemoteTask() { calls.push('detail+evidence') } }
|
||||||
const first = hooks.onShow.call(context)
|
const first = hooks.onShow.call(context)
|
||||||
const duplicate = hooks.onShow.call(context)
|
const duplicate = hooks.onShow.call(context)
|
||||||
assert.deepEqual(calls, ['pending'])
|
assert.deepEqual(calls, ['pending'])
|
||||||
@@ -51,6 +53,9 @@ test('duplicate onShow shares one load chain while background return refreshes a
|
|||||||
await hooks.onShow.call(context)
|
await hooks.onShow.call(context)
|
||||||
assert.equal(calls.length, 2)
|
assert.equal(calls.length, 2)
|
||||||
hooks.onHide.call(context)
|
hooks.onHide.call(context)
|
||||||
|
assert.equal(context.taskRequestId, 1)
|
||||||
|
assert.equal(context.remoteLoading, false)
|
||||||
|
assert.equal(context._showRefreshPromise, null)
|
||||||
await hooks.onShow.call(context)
|
await hooks.onShow.call(context)
|
||||||
assert.deepEqual(calls, ['pending', 'detail+evidence', 'pending', 'detail+evidence'])
|
assert.deepEqual(calls, ['pending', 'detail+evidence', 'pending', 'detail+evidence'])
|
||||||
const onLoad = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
const onLoad = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\r?\n\t\t\},/)?.[0]
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import vm from 'node:vm'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
|
||||||
|
const source = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
||||||
|
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||||
|
const stateSource = await readFile(new URL('../utils/picking-state.js', import.meta.url), 'utf8')
|
||||||
|
const pickingState = await import(`data:text/javascript;base64,${Buffer.from(stateSource).toString('base64')}`)
|
||||||
|
const noop = () => {}
|
||||||
|
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
||||||
|
function mount(overrides = {}, state = {}) {
|
||||||
|
const globals = { console, setTimeout, clearTimeout }
|
||||||
|
for (const match of script.matchAll(/import\s+(.*?)\s+from\s+['"][^'"]+['"]/g)) {
|
||||||
|
for (const name of match[1].replace(/[{}]/g, '').split(',').map(value => value.trim()).filter(Boolean)) globals[name] = noop
|
||||||
|
}
|
||||||
|
Object.assign(globals, { session: { token: 'offline', currentUser: { id: 'operator' } }, requireSession: () => true,
|
||||||
|
ITEM_STATUS: { PICKED: 'PICKED', PARTIAL: 'PARTIAL', SKIPPED: 'SKIPPED' },
|
||||||
|
isSealedPalletItem: () => false, isRemoteReady: () => true, stableOperationId: () => 'stable-operation',
|
||||||
|
stableTaskOperationIdentity: () => ({}), normalizePickingTask: value => value,
|
||||||
|
createItemStates: () => ({}), summarizeInventoryResources: () => ({}),
|
||||||
|
submitStates: () => ({ states: {}, autoSkippedIds: [] }), getPickSummary: () => ({}), ...overrides })
|
||||||
|
vm.createContext(globals)
|
||||||
|
vm.runInContext(script.replace(/^\s*import .*$/gm, '').replace('export default', 'globalThis.page ='), globals)
|
||||||
|
const page = globals.page
|
||||||
|
const ctx = { taskRequestId: 0, _pageVisible: true, evidenceContextId: 0, remoteTaskId: 'task',
|
||||||
|
currentBox: { taskId: 'task', warehouseId: 'warehouse', inboundId: 'inbound', loadingPlanId: 'plan', containerBookingId: 'container',
|
||||||
|
items: [{ id: 'item', taskItemId: 'item', itemStatus: 'PENDING', qty: 2, version: 0 }] },
|
||||||
|
evidenceFiles: {}, evidenceIdentity: null, sealNo: '', pickingVersion: 1,
|
||||||
|
summary: { pending: 1 }, itemStates: {}, partialQuantities: {}, completedBoxes: {},
|
||||||
|
selectedIncompleteReason: '现场未找到', submitting: false, loadingStageReady: false, pickingCompleted: false }
|
||||||
|
for (const [name, method] of Object.entries(page.methods)) ctx[name] = method.bind(ctx)
|
||||||
|
Object.assign(ctx, { rememberEvidenceDraft: noop, draftIdentity: () => ({}), resetEvidenceState: noop,
|
||||||
|
debugSubmit: noop, debugEvidence: noop, showToast: noop, persistSession: noop, clearTimers: noop,
|
||||||
|
stopHardwareScanner: noop, startHardwareScanner: noop, captureEvidenceIdentity: () => ({}),
|
||||||
|
restoreEvidenceDraft: noop, isSameEvidenceIdentity: () => false, restoreRemoteDraft: noop,
|
||||||
|
itemState: () => 'PENDING', saveCurrentBoxDraft: noop, openOverlay: noop,
|
||||||
|
isTaskConflict: () => false, remoteFailureMessage: () => 'failed', ...state })
|
||||||
|
return { page, ctx }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('real scan handlers display remaining 2→1→1→0, preserve pending semantics and avoid duplicate POST', async () => {
|
||||||
|
let posts = 0
|
||||||
|
const messages = []
|
||||||
|
const { page, ctx } = mount({ ...pickingState, pickContainerItem: async () => {
|
||||||
|
posts++
|
||||||
|
return { version: posts + 1, items: [{ id: 'item', plannedPackageCount: 2, pickedPackageCount: posts,
|
||||||
|
itemStatus: posts === 2 ? 'PICKED' : 'PICKING' }] }
|
||||||
|
} }, { scannedBoxCodes: {}, requirePickingOperation: () => true, recordScanResult: (...args) => messages.push(args) })
|
||||||
|
ctx.currentBox.items[0].boxCodes = ['BOX-1', 'BOX-2']
|
||||||
|
ctx.currentBox.items[0].plannedPackageCount = 2
|
||||||
|
ctx.currentBox.items[0].pickedPackageCount = 0
|
||||||
|
const remaining = () => page.computed.remainingCartons.call(ctx)
|
||||||
|
assert.equal(remaining(), 2)
|
||||||
|
await ctx.processScannedValue('BOX-1')
|
||||||
|
assert.equal(remaining(), 1)
|
||||||
|
assert.equal(page.computed.summary.call(ctx).pending, 0)
|
||||||
|
await ctx.processScannedValue('BOX-1')
|
||||||
|
assert.equal(remaining(), 1); assert.equal(posts, 1)
|
||||||
|
assert.equal(messages.at(-1)[3], '该条码已部分拣货,无需重复处理')
|
||||||
|
await ctx.processScannedValue('BOX-2')
|
||||||
|
assert.equal(remaining(), 0); assert.equal(posts, 2)
|
||||||
|
assert.match(source, /submit-pending-number">\{\{ remainingCartons \}\}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('physical remaining distinguishes skipped and partial from business pending and trusts server counts', () => {
|
||||||
|
const items = [{ id: 'partial', qty: 4, pickedPackageCount: 1 }, { id: 'skip', qty: 3, pickedPackageCount: 0 }]
|
||||||
|
const states = { partial: pickingState.ITEM_STATUS.PARTIAL, skip: pickingState.ITEM_STATUS.SKIPPED }
|
||||||
|
const quantities = { partial: 1 }
|
||||||
|
const before = JSON.stringify({ items, states, quantities })
|
||||||
|
const summary = pickingState.getPickSummary(items, states, quantities)
|
||||||
|
assert.equal(summary.pending, 0); assert.equal(summary.partial, 1); assert.equal(summary.skipped, 3)
|
||||||
|
assert.equal(pickingState.remainingPhysicalCartons(items, states, quantities), 6)
|
||||||
|
assert.equal(pickingState.getPickSummary(items, states, quantities).canSubmit, summary.canSubmit)
|
||||||
|
assert.equal(JSON.stringify({ items, states, quantities }), before)
|
||||||
|
assert.equal(pickingState.remainingPhysicalCartons([{ id: 'partial', qty: 4, pickedPackageCount: 1 }],
|
||||||
|
{ partial: pickingState.ITEM_STATUS.PICKED }, { partial: 4 }), 3)
|
||||||
|
assert.equal(pickingState.remainingPhysicalCartons([{ id: 'partial', qty: 4 }], states, quantities), 3)
|
||||||
|
assert.equal(pickingState.remainingPhysicalCartons([{ id: 'skip', qty: 3 }], states, quantities), 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('double confirmation and direct reentry send exactly one snapshot and one batch', async () => {
|
||||||
|
const snapshot = deferred(), batch = deferred(); let snapshots = 0, batches = 0
|
||||||
|
const { ctx } = mount({ submitContainerPickingBatch: async () => { batches++; return batch.promise } }, {
|
||||||
|
refreshInventorySnapshot: async () => { snapshots++; return snapshot.promise },
|
||||||
|
syncRemoteTask: async function () { this.pickingCompleted = true },
|
||||||
|
})
|
||||||
|
const first = ctx.confirmIncompleteSubmit()
|
||||||
|
ctx.confirmIncompleteSubmit(); await ctx.performSubmit('现场未找到')
|
||||||
|
assert.equal(ctx.submitting, true); assert.equal(snapshots, 1); assert.equal(batches, 0)
|
||||||
|
snapshot.resolve({ snapshotToken: 'snapshot' }); await new Promise(setImmediate)
|
||||||
|
ctx.confirmIncompleteSubmit(); assert.equal(batches, 1)
|
||||||
|
batch.resolve(null); await first
|
||||||
|
assert.equal(snapshots, 1); assert.equal(batches, 1); assert.equal(ctx.submitting, false)
|
||||||
|
assert.match(source, /:disabled="submitting \|\| !selectedIncompleteReason"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('failed request releases submit guard and retry keeps operation identity', async () => {
|
||||||
|
const keys = []; let snapshots = 0
|
||||||
|
const { ctx } = mount({ submitContainerPickingBatch: async (_task, _actions, options) => { keys.push(options.operationId); throw new Error('network') } }, {
|
||||||
|
refreshInventorySnapshot: async () => { snapshots++; return { snapshotToken: 'snapshot' } },
|
||||||
|
})
|
||||||
|
await ctx.confirmIncompleteSubmit(); assert.equal(ctx.submitting, false)
|
||||||
|
await ctx.confirmIncompleteSubmit()
|
||||||
|
assert.equal(snapshots, 2); assert.deepEqual(keys, ['stable-operation', 'stable-operation'])
|
||||||
|
})
|
||||||
|
|
||||||
|
const task = () => ({ taskId: 'task', recordStatus: 'ACTIVE', loadingPlanId: 'plan', containerBookingId: 'container',
|
||||||
|
currentContainerNo: 'TEST', version: 1, workflowStatus: 'UNASSIGNED', items: [{ id: 'item', packageCount: 2, itemStatus: 'PENDING' }] })
|
||||||
|
for (const hook of ['onHide', 'onUnload']) {
|
||||||
|
test(`${hook} while detail is pending prevents claim and evidence requests`, async () => {
|
||||||
|
const detail = deferred(); let claims = 0, evidence = 0
|
||||||
|
const { page, ctx } = mount({ getContainerPickingTask: () => detail.promise,
|
||||||
|
claimContainerPickingTask: async () => { claims++; return {} } }, { loadLoadingEvidence: async () => { evidence++ } })
|
||||||
|
const pending = ctx.syncRemoteTask(); page[hook].call(ctx); detail.resolve(task()); await pending
|
||||||
|
assert.equal(claims, 0); assert.equal(evidence, 0); assert.equal(ctx.remoteLoading, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('unload during already-sent claim prevents its response from loading evidence', async () => {
|
||||||
|
const claim = deferred(); let claims = 0, evidence = 0
|
||||||
|
const { page, ctx } = mount({ getContainerPickingTask: async () => task(),
|
||||||
|
claimContainerPickingTask: () => { claims++; return claim.promise } }, { loadLoadingEvidence: async () => { evidence++ } })
|
||||||
|
const pending = ctx.syncRemoteTask(); await new Promise(setImmediate)
|
||||||
|
assert.equal(claims, 1, ctx.remoteError)
|
||||||
|
page.onUnload.call(ctx); claim.resolve({ workflowStatus: 'IN_PROGRESS', version: 2 }); await pending
|
||||||
|
assert.equal(evidence, 0); assert.equal(ctx.remotePick, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unload during onShow list fetch prevents starting task sync afterward', async () => {
|
||||||
|
const list = deferred(); let syncs = 0
|
||||||
|
const { page, ctx } = mount({}, { _pageVisible: false, refreshAvailableBoxes: () => list.promise,
|
||||||
|
syncRemoteTask: async () => { syncs++ } })
|
||||||
|
const pending = page.onShow.call(ctx); page.onUnload.call(ctx); list.resolve(); await pending
|
||||||
|
assert.equal(syncs, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('old hidden onShow cannot clear a newer visible refresh or start another sync', async () => {
|
||||||
|
const oldList = deferred(), newList = deferred(); let lists = 0, syncs = 0
|
||||||
|
const { page, ctx } = mount({}, { _pageVisible: false,
|
||||||
|
refreshAvailableBoxes: () => (++lists === 1 ? oldList.promise : newList.promise),
|
||||||
|
syncRemoteTask: async () => { syncs++ } })
|
||||||
|
const oldShow = page.onShow.call(ctx)
|
||||||
|
page.onHide.call(ctx)
|
||||||
|
const newShow = page.onShow.call(ctx), currentPromise = ctx._showRefreshPromise
|
||||||
|
oldList.resolve(); await oldShow
|
||||||
|
assert.equal(syncs, 0); assert.equal(ctx._showRefreshPromise, currentPromise)
|
||||||
|
newList.resolve(); await newShow
|
||||||
|
assert.equal(syncs, 1); assert.equal(ctx._showRefreshPromise, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const mode of ['camera', 'preview']) {
|
||||||
|
test(`${mode} hide/return keeps existing exception and avoids refresh`, async () => {
|
||||||
|
let refreshes = 0
|
||||||
|
const { page, ctx } = mount({}, { cameraPicking: mode === 'camera', evidencePreviewOpening: mode === 'preview',
|
||||||
|
refreshAvailableBoxes: async () => { refreshes++ }, syncRemoteTask: async () => { refreshes++ } })
|
||||||
|
page.onHide.call(ctx); assert.equal(ctx.taskRequestId, 0)
|
||||||
|
await page.onShow.call(ctx); assert.equal(refreshes, 0); assert.equal(ctx._pageVisible, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { readFile } from 'node:fs/promises'
|
|||||||
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
|
||||||
|
|
||||||
test('saving a seal is single-flight and submits the pending photo through the atomic evidence command', () => {
|
test('saving a seal is single-flight and submits the pending photo through the atomic evidence command', () => {
|
||||||
assert.match(page, /:disabled="sealSaving \|\| !sealReadyToSave"/)
|
assert.match(page, /:disabled="evidenceReadOnly \|\| sealSaving \|\| !sealReadyToSave"/)
|
||||||
assert.match(page, /sealSaving \? '上传中…' : '保存并上传'/)
|
assert.match(page, /sealSaving \? '上传中…' : '保存并上传'/)
|
||||||
assert.match(page, /async saveSealNo\(\) \{[\s\S]*?if \(this\.sealSaving\) return/)
|
assert.match(page, /async saveSealNo\(\) \{[\s\S]*?if \(this\.sealSaving\) return/)
|
||||||
assert.match(page, /find\(\(item\) => item\.localPath && !item\.id\)/)
|
assert.match(page, /find\(\(item\) => item\.localPath && !item\.id\)/)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import { decodeAccessToken } from '../utils/jwt-claims.js'
|
||||||
|
const source = path => fs.readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||||
|
const plain = text => text.replace(/^\s*(?:import|export \{)[^\n]*$/gm, '').replace(/export (?=function|const)/g, '')
|
||||||
|
const pair = (version='old', identity={}) => ({ accessToken: `header.${Buffer.from(JSON.stringify({ user_id:'42', membership_id:'84', tenant_id:'0', app_code:'PLATFORM', domestic_warehouse_id:'900', exp:Math.floor(Date.now()/1000)+3600, version, ...identity })).toString('base64url')}.signature`, refreshToken:`refresh-${version}` })
|
||||||
|
const ok = data => ({statusCode:200,data:{code:'200',data},header:{}})
|
||||||
|
const denied = (statusCode=401,code='A0401') => ({statusCode,data:{code,message:'denied',requestId:'req',traceId:'trace'},header:{}})
|
||||||
|
function harness() {
|
||||||
|
const values=new Map(),calls=[],launches=[],toasts=[];let drafts={owner:'old'}
|
||||||
|
globalThis.uni={setStorageSync:(k,v)=>values.set(k,structuredClone(v)),getStorageSync:k=>values.get(k),removeStorageSync:k=>values.delete(k),reLaunch:o=>{launches.push(o.url);o.complete?.()},showToast:o=>toasts.push(o.title),request:o=>calls.push(o)}
|
||||||
|
const s=new Function('reactive','resetPickingSession','PLATFORM_API_ROOT','decodeAccessToken',plain(source('../stores/session.js'))+';return {session,signInWithTokens,signOut,updateTokenPair,isAccessTokenExpiring,requireSession,getSessionRevision:typeof getSessionRevision === "function" ? getSessionRevision : () => 0}')(x=>x,()=>{drafts=null},'/api/platform/v1',decodeAccessToken)
|
||||||
|
const auth=new Function('API_ROOT',plain(source('../api/auth.js'))+';return {logout,refresh,getPendingRefresh:typeof getPendingRefresh === "function" ? getPendingRefresh : () => null}')('/api')
|
||||||
|
const path=new URL('../api/logout-session.js',import.meta.url)
|
||||||
|
const helper=fs.existsSync(path)?new Function('logout','refresh','getPendingRefresh','session','signOut','getSessionRevision','decodeAccessToken',plain(fs.readFileSync(path,'utf8'))+';return {logoutCurrentSession}')(auth.logout,auth.refresh,auth.getPendingRefresh,s.session,s.signOut,s.getSessionRevision,decodeAccessToken):{}
|
||||||
|
const script=source('../pages/profile/profile.vue').match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import[^\n]*$/gm,'').replace('export default','return')
|
||||||
|
const page=new Function('logoutRequest','PortalHeader','PopupHeader','session','requireSession','signOut','checkForAppUpdate','logoutCurrentSession','getSessionRevision',script)(auth.logout,{},{},s.session,s.requireSession,s.signOut,()=>{},helper.logoutCurrentSession,s.getSessionRevision)
|
||||||
|
const original=pair();s.signInWithTokens(original)
|
||||||
|
const http=new Function('refreshRequest','isAccessTokenExpiring','session','signOut','updateTokenPair','requireOnline','getSessionRevision',plain(source('../api/http.js'))+';return {apiRequest}')(auth.refresh,s.isAccessTokenExpiring,s.session,s.signOut,s.updateTokenPair,()=>{},s.getSessionRevision)
|
||||||
|
return {...s,auth,helper,http,page,context:{user:s.session.currentUser,closeLogout(){}},original,values,calls,launches,toasts,get drafts(){return drafts}}
|
||||||
|
}
|
||||||
|
const tick=()=>new Promise(r=>setImmediate(r))
|
||||||
|
test('profile waits for revoke before navigating and clears drafts immediately',async()=>{
|
||||||
|
const h=harness(),pending=h.page.methods.logout.call(h.context)
|
||||||
|
assert.equal(h.calls.length,1);assert.equal(h.session.token,'');assert.equal(h.drafts,null);assert.equal(h.launches.length,0)
|
||||||
|
h.calls[0].success(ok(null));await pending;assert.deepEqual(h.launches,['/pages/login/login']);assert.equal(h.values.has('pda-auth-session'),false)
|
||||||
|
})
|
||||||
|
test('duplicate confirmation shares one pending revoke',async()=>{
|
||||||
|
const h=harness(),a=h.page.methods.logout.call(h.context),b=h.page.methods.logout.call(h.context)
|
||||||
|
assert.equal(h.calls.length,1);assert.ok(a && typeof a.then === 'function');assert.equal(a,b);h.calls[0].success(ok(null));await a
|
||||||
|
})
|
||||||
|
test('expired access 401 rotates once and revokes new pair without restoring local login',async()=>{
|
||||||
|
const h=harness(),p=h.helper.logoutCurrentSession();h.calls[0].success(denied());await tick()
|
||||||
|
assert.match(h.calls[1].url,/\/auth\/refresh$/);const rotated=pair('rotated');h.calls[1].success(ok(rotated));await tick()
|
||||||
|
assert.deepEqual(h.calls[2].data,{refreshToken:rotated.refreshToken});assert.equal(h.calls[2].header.Authorization,`Bearer ${rotated.accessToken}`)
|
||||||
|
h.calls[2].success(ok(null));assert.equal((await p).revoked,true);assert.equal(h.session.token,'');assert.equal(h.calls.length,3)
|
||||||
|
})
|
||||||
|
test('network unknown, 5xx and business rejection do not trigger blind retries',async()=>{
|
||||||
|
for(const kind of ['network','server','business']) {
|
||||||
|
const h=harness(),p=h.helper.logoutCurrentSession();const rejection=assert.rejects(p)
|
||||||
|
if(kind==='network')h.calls[0].fail({errMsg:'lost'});else h.calls[0].success(denied(kind==='server'?503:200,kind==='server'?'S0503':'A0401'))
|
||||||
|
await rejection;assert.equal(h.calls.length,1);assert.equal(h.session.token,'');assert.equal(h.drafts,null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('failed profile logout warns instead of reporting confirmed revocation',async()=>{
|
||||||
|
const h=harness(),p=h.page.methods.logout.call(h.context);h.calls[0].fail({errMsg:'lost'});const result=await p
|
||||||
|
assert.equal(result.revoked,false);assert.deepEqual(h.toasts,['已清除本地登录,服务端退出未确认']);assert.equal(h.session.refreshToken,'')
|
||||||
|
})
|
||||||
|
test('identity mismatch or malformed rotated credentials cannot be used for another revoke',async()=>{
|
||||||
|
for(const change of [{user_id:'43'},{membership_id:'85'},{domestic_warehouse_id:'901'},{exp:1}]) {
|
||||||
|
const h=harness(),p=h.helper.logoutCurrentSession(),rejection=assert.rejects(p);h.calls[0].success(denied());await tick()
|
||||||
|
h.calls[1].success(ok(pair('wrong',change)));await rejection;assert.equal(h.calls.length,2);assert.equal(h.session.token,'')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('second 401 is terminal with diagnostic evidence and no refresh loop',async()=>{
|
||||||
|
const h=harness(),p=h.helper.logoutCurrentSession(),rejection=assert.rejects(p,e=>e.statusCode===401 && e.requestId==='req' && e.traceId==='trace')
|
||||||
|
h.calls[0].success(denied());await tick();h.calls[1].success(ok(pair('rotated')));await tick();h.calls[2].success(denied());await rejection;assert.equal(h.calls.length,3)
|
||||||
|
})
|
||||||
|
test('logout joins in-flight automatic refresh, revokes its result, and never revives cleared session',async()=>{
|
||||||
|
const h=harness();h.session.token=pair('expired',{exp:1}).accessToken
|
||||||
|
const business=h.http.apiRequest({url:'/work'}).catch(e=>e);await tick();assert.equal(h.calls.length,1)
|
||||||
|
const exit=h.helper.logoutCurrentSession();const newer=pair('new',{user_id:'99',membership_id:'98',domestic_warehouse_id:'950'});h.signInWithTokens(newer)
|
||||||
|
const rotated=pair('rotated');h.calls[0].success(ok(rotated));await tick()
|
||||||
|
assert.equal(h.calls.length,2);assert.match(h.calls[1].url,/\/auth\/logout$/);assert.deepEqual(h.calls[1].data,{refreshToken:rotated.refreshToken})
|
||||||
|
h.calls[1].success(ok(null));await exit;assert.match((await business).message,/会话已变更/);assert.equal(h.session.token,newer.accessToken)
|
||||||
|
})
|
||||||
|
test('old logout success and failure cannot redirect or warn over a later new login',async()=>{
|
||||||
|
for(const fail of [false,true]) {
|
||||||
|
const h=harness(),p=h.page.methods.logout.call(h.context),newer=pair('new',{user_id:'99'});h.signInWithTokens(newer)
|
||||||
|
if(fail)h.calls[0].fail({errMsg:'lost'});else h.calls[0].success(ok(null));await p
|
||||||
|
assert.equal(h.session.token,newer.accessToken);assert.equal(h.launches.length,0);assert.equal(h.toasts.length,0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('unknown pending refresh is not repeated and its failure remains observable',async()=>{
|
||||||
|
const h=harness(),refresh=h.auth.refresh(h.original.refreshToken).catch(e=>e),exit=h.helper.logoutCurrentSession(),rejected=assert.rejects(exit,e=>e.network===true)
|
||||||
|
h.calls[0].fail({errMsg:'lost refresh'});await rejected;await refresh;assert.equal(h.calls.length,1);assert.equal(h.session.token,'')
|
||||||
|
})
|
||||||
|
test('stale business 401 after logout cannot refresh or clear a newer login',async()=>{
|
||||||
|
const h=harness(),business=h.http.apiRequest({url:'/work'}).catch(e=>e);await tick();assert.equal(h.calls.length,1)
|
||||||
|
const exit=h.helper.logoutCurrentSession(),newer=pair('new',{user_id:'99'});h.signInWithTokens(newer)
|
||||||
|
h.calls[0].success(denied());h.calls[1].success(ok(null));await exit
|
||||||
|
assert.match((await business).message,/会话已变更/);assert.equal(h.calls.length,2);assert.equal(h.session.token,newer.accessToken)
|
||||||
|
})
|
||||||
|
test('concurrent automatic requests share one refresh and are rejected after logout without business replay',async()=>{
|
||||||
|
const h=harness();h.session.token=pair('expired',{exp:1}).accessToken
|
||||||
|
const a=h.http.apiRequest({url:'/one'}).catch(e=>e),b=h.http.apiRequest({url:'/two'}).catch(e=>e);await tick();assert.equal(h.calls.length,1)
|
||||||
|
const exit=h.helper.logoutCurrentSession();h.calls[0].success(ok(pair('rotated')));await tick();assert.equal(h.calls.length,2)
|
||||||
|
h.calls[1].success(ok(null));await exit
|
||||||
|
assert.match((await a).message,/会话已变更/);assert.match((await b).message,/会话已变更/);assert.equal(h.session.token,'')
|
||||||
|
})
|
||||||
|
test('missing rotated token fields fail closed and never report a revoked session',async()=>{
|
||||||
|
for(const field of ['accessToken','refreshToken']) {
|
||||||
|
const h=harness(),exit=h.helper.logoutCurrentSession(),rejected=assert.rejects(exit);h.calls[0].success(denied());await tick()
|
||||||
|
const bad=pair('bad');delete bad[field];h.calls[1].success(ok(bad));await rejected;assert.equal(h.calls.length,2);assert.equal(h.session.token,'')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -9,6 +9,20 @@ export function isSealedPalletItem(item) {
|
|||||||
return item?.unitType === 'SEALED_PALLET'
|
return item?.unitType === 'SEALED_PALLET'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Physical cartons still absent from the picked count, independent of whether
|
||||||
|
// the operator has handled the line as partial or skipped.
|
||||||
|
export function remainingPhysicalCartons(items, states = {}, partialQuantities = {}) {
|
||||||
|
const count = (value) => Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0
|
||||||
|
return items.reduce((total, item) => {
|
||||||
|
const planned = count(item.plannedPackageCount ?? item.qty)
|
||||||
|
const hasServerCount = item.pickedPackageCount !== null && item.pickedPackageCount !== undefined && item.pickedPackageCount !== ''
|
||||||
|
const picked = hasServerCount ? count(item.pickedPackageCount)
|
||||||
|
: states[item.id] === ITEM_STATUS.PICKED ? planned
|
||||||
|
: states[item.id] === ITEM_STATUS.PARTIAL ? count(partialQuantities[item.id]) : 0
|
||||||
|
return total + Math.max(0, planned - picked)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
export function summarizeInventoryResources(resources) {
|
export function summarizeInventoryResources(resources) {
|
||||||
const list = Array.isArray(resources) ? resources : []
|
const list = Array.isArray(resources) ? resources : []
|
||||||
const locationCodes = [...new Set(list
|
const locationCodes = [...new Set(list
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
const proxyTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
|
const proxyTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
base: process.env.UNI_PLATFORM === 'h5' && mode === 'production' ? '/pda-domestic/' : undefined,
|
base: process.env.UNI_PLATFORM === 'h5' && mode === 'production' ? '/' : undefined,
|
||||||
plugins: [uni()],
|
plugins: [uni()],
|
||||||
define: {
|
define: {
|
||||||
'import.meta.env.VITE_PDA_UPDATE_URL': JSON.stringify(env.VITE_PDA_UPDATE_URL || '')
|
'import.meta.env.VITE_PDA_UPDATE_URL': JSON.stringify(env.VITE_PDA_UPDATE_URL || '')
|
||||||
|
|||||||
Reference in New Issue
Block a user