merge overseas PDA frontend changes

This commit is contained in:
2026-10-08 15:45:25 +08:00
26 changed files with 890 additions and 67 deletions
+19 -3
View File
@@ -1,6 +1,8 @@
import { API_ROOT } from '../config/runtime.js'
function authRequest(path, data) {
const pendingRefreshes = new Map()
function authRequest(path, data, accessToken = '') {
return new Promise((resolve, reject) => {
const url = `${API_ROOT}${path}`
console.info('[Overseas PDA auth] request', { method: 'POST', url })
@@ -8,6 +10,8 @@ function authRequest(path, data) {
url,
method: 'POST',
data,
header: { Accept: 'application/json', 'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
success(response) {
const body = response.data || {}
console.info('[Overseas PDA auth] response', { url, statusCode: response.statusCode, code: body.code,
@@ -15,9 +19,10 @@ function authRequest(path, data) {
contentType: response.header?.['Content-Type'] || response.header?.['content-type'],
requestId: body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'],
traceId: body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] })
if (String(body.code ?? response.statusCode) !== '200') {
if (response.statusCode !== 200 || String(body.code) !== '200') {
const error = new Error(body.message || '认证服务请求失败')
error.code = body.code || response.statusCode
error.statusCode = response.statusCode
error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id']
error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id']
reject(error)
@@ -40,5 +45,16 @@ export function login(loginId, password) {
}
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(accessToken, refreshToken) {
return authRequest('/auth/logout', { refreshToken }, accessToken)
}
+14 -1
View File
@@ -24,8 +24,14 @@ export function normalizeDispatchTask(value) {
taskNo: String(value?.taskNo || ''),
dispatchNo: String(value?.dispatchNo || ''),
vehiclePlate: String(value?.vehiclePlate || ''),
tripNo: String(value?.tripNo || ''),
tripRecordVersion: value?.tripRecordVersion == null ? null : String(value.tripRecordVersion),
tripDispatchCount: count(value?.tripDispatchCount),
driverWarning: String(value?.driverWarning || ''),
status: String(value?.status || ''),
canDepart: value?.canDepart === true,
canPick: value?.canPick === true,
pickBlockReason: String(value?.pickBlockReason || ''),
departBlockReason: String(value?.departBlockReason || ''),
items: asList(value?.items).map((item) => ({
...item,
@@ -98,6 +104,13 @@ export function departDispatch(dispatchNo, request) {
return overseasRequest('POST', `${DISPATCH_BASE}/${encoded(dispatchNo)}/depart`, request)
}
export function tripDepartureRequest(task, idempotencyKey) {
if (!task?.tripNo || task.tripRecordVersion == null || !/^\d+$/.test(String(task.tripRecordVersion))) {
throw new Error('车次信息不完整,请刷新任务后再发车')
}
return { recordVersion: String(task.tripRecordVersion), idempotencyKey, remark: '海外 PDA 整车核平后发车' }
}
export async function listOutboundTasks(status = '') {
const suffix = status ? `?status=${encoded(status)}` : ''
return asList(await overseasRequest('GET', `${OUTBOUND_BASE}${suffix}`)).map(normalizeOutboundTask)
@@ -123,7 +136,7 @@ export function outboundPickPayload(item, position, packages, scannedCode = '')
const skuScan = item.skuCode && scannedCode === item.skuCode
return {
outboundPalletItemId: item.outboundPalletItemId,
inventoryPositionId: position.inventoryPositionId,
inventoryPositionId: position.inventoryPositionId || null,
packages: Number(packages),
cartonMarks: skuScan ? [] : position.cartonMark ? [position.cartonMark] : [],
...(skuScan ? { skuCode: item.skuCode } : {})
+53
View File
@@ -0,0 +1,53 @@
import { logout, refresh, getPendingRefresh } from './auth.js'
import { session, signOut, getSessionRevision, decodeAccessToken } from '../stores/session.js'
function replacementForCapturedSession(captured, tokens) {
if (!tokens?.accessToken || typeof tokens.refreshToken !== 'string' || !tokens.refreshToken.trim()) {
throw new Error('退出刷新响应缺少会话凭据')
}
const previous = decodeAccessToken(captured.accessToken)
const replacement = decodeAccessToken(tokens.accessToken)
if (!Number.isFinite(Number(replacement?.exp)) || Number(replacement.exp) * 1000 <= Date.now()) {
throw new Error('退出刷新凭据无效或已过期')
}
for (const name of ['user_id', 'membership_id', 'tenant_id', 'app_code', 'overseas_warehouse_id']) {
if (previous[name] == null || String(previous[name]) !== String(replacement[name])) {
throw new Error('退出刷新返回了不同作业主体,无法确认原会话撤销')
}
}
return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }
}
// Active user logout only. Ordinary authentication failures continue to use local signOut.
export function logoutCurrentSession() {
const captured = {
accessToken: session.token, refreshToken: session.refreshToken,
pendingRefresh: getPendingRefresh(session.refreshToken)
}
signOut()
const clearedRevision = getSessionRevision()
const revoke = async () => {
if (!captured.accessToken || !captured.refreshToken) return { revoked: false, reason: 'NO_CAPTURED_SESSION' }
let credentials = captured
let refreshed = false
if (captured.pendingRefresh) {
// Auth rotates access/refresh credentials. Revoke the captured refresh's result,
// while the business refresh wrapper's revision guard prevents local restoration.
refreshed = true
credentials = replacementForCapturedSession(captured, await captured.pendingRefresh)
}
try {
await logout(credentials.accessToken, credentials.refreshToken)
} catch (error) {
if (error.network || error.statusCode !== 401 || refreshed) throw error
// Only an explicit expired-access HTTP 401 permits one refresh and one revoke.
credentials = replacementForCapturedSession(captured, await refresh(captured.refreshToken))
await logout(credentials.accessToken, credentials.refreshToken)
}
return { revoked: true }
}
return revoke().then(result => ({ ...result, clearedRevision }), error => {
error.logoutRevision = clearedRevision
throw error
})
}
+40 -13
View File
@@ -1,7 +1,18 @@
import { refresh } from './auth.js'
import { session, replaceTokens, signOut } from '../stores/session.js'
import { session, replaceTokens, signOut, getSessionRevision } from '../stores/session.js'
let refreshPromise = null
let refreshRevision = -1
function requireCurrentSession(revision) {
if (revision !== getSessionRevision()) throw new Error('登录会话已变更,请重新操作')
}
function expireSession(revision) {
if (revision !== getSessionRevision()) return
signOut()
uni.reLaunch({ url: '/pages/login/login' })
}
function errorFrom(response, fallback) {
const body = response?.data || {}
@@ -21,23 +32,27 @@ function successful(response, body) {
return response?.statusCode === 200 && ['00000', '200'].includes(String(body?.code))
}
async function refreshOverseasToken() {
async function refreshOverseasToken(revision) {
requireCurrentSession(revision)
if (!session.refreshToken) {
signOut()
expireSession(revision)
throw new Error('登录状态已过期,请重新登录')
}
if (!refreshPromise) {
refreshPromise = refresh(session.refreshToken)
.then((tokens) => { replaceTokens(tokens); return session.token })
.catch((error) => { signOut(); throw error })
.finally(() => { refreshPromise = null })
if (!refreshPromise || refreshRevision !== revision) {
refreshRevision = revision
const pending = refresh(session.refreshToken)
.then((tokens) => { requireCurrentSession(revision); replaceTokens(tokens); return session.token })
.catch((error) => { expireSession(revision); throw error })
.finally(() => { if (refreshPromise === pending) refreshPromise = null })
refreshPromise = pending
}
return refreshPromise
}
async function tokenAfterUnauthorized(failedToken) {
async function tokenAfterUnauthorized(failedToken, revision) {
requireCurrentSession(revision)
if (session.token && session.token !== failedToken) return session.token
return refreshOverseasToken()
return refreshOverseasToken(revision)
}
function overseasUrl(path) {
@@ -70,13 +85,17 @@ function rawRequest(method, path, data, token) {
}
export async function overseasRequest(method, path, data, retry = true) {
const revision = getSessionRevision()
console.info('[Overseas PDA API] request', { method, path })
const attemptedToken = session.token
let response = await rawRequest(method, path, data, attemptedToken)
requireCurrentSession(revision)
if (retry && unauthorized(response)) {
const replacementToken = await tokenAfterUnauthorized(attemptedToken)
const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
response = await rawRequest(method, path, data, replacementToken)
}
requireCurrentSession(revision)
if (unauthorized(response)) expireSession(revision)
const body = response?.data || {}
console.info('[Overseas PDA API] response', { method, path, statusCode: response?.statusCode, code: body?.code,
message: body?.message, requestId: body?.requestId || response?.header?.['x-request-id'] || response?.header?.['X-Request-Id'],
@@ -149,27 +168,35 @@ function rawDownload(path, token) {
}
export async function overseasUpload(path, filePath, name = 'file', retry = true) {
const revision = getSessionRevision()
const attemptedToken = session.token
let response = await rawUpload(path, filePath, name, attemptedToken)
requireCurrentSession(revision)
let body = null
try { body = JSON.parse(response.data || '{}') } catch (_) { /* handled below */ }
if (retry && unauthorized({ ...response, data: body })) {
const replacementToken = await tokenAfterUnauthorized(attemptedToken)
const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
response = await rawUpload(path, filePath, name, replacementToken)
try { body = JSON.parse(response.data || '{}') } catch (_) { body = null }
}
requireCurrentSession(revision)
if (unauthorized({ ...response, data: body })) expireSession(revision)
if (!successful(response, body)) throw errorFrom({ ...response, data: body }, '海外作业文件上传失败')
return body.data
}
export async function overseasDownload(path, options = {}) {
const revision = getSessionRevision()
const { retry = true, expectedContentTypePrefix = '' } = options
const attemptedToken = session.token
let response = await rawDownload(path, attemptedToken)
requireCurrentSession(revision)
if (retry && unauthorized(response)) {
const replacementToken = await tokenAfterUnauthorized(attemptedToken)
const replacementToken = await tokenAfterUnauthorized(attemptedToken, revision)
response = await rawDownload(path, replacementToken)
}
requireCurrentSession(revision)
if (unauthorized(response)) expireSession(revision)
if (response?.statusCode !== 200 || !response?.tempFilePath) {
throw errorFrom({ ...response, data: {} }, '签收照片下载失败')
}
+28
View File
@@ -0,0 +1,28 @@
import { overseasRequest } from './overseas.js'
const text = value => typeof value === 'string' ? value : ''
const transport = value => ({ vehiclePlate: text(value?.vehiclePlate), driverName: text(value?.driverName), driverPhone: text(value?.driverPhone) })
export function normalizeHandoverStatus(value) {
if (!value?.dispatchNo || !value?.tripNo || !Array.isArray(value.deliveries)) throw new Error('交接状态缺少排车或派送依据')
return {
dispatchNo: text(value.dispatchNo), tripNo: text(value.tripNo), tripStatus: text(value.tripStatus),
tripRecordVersion: value.tripRecordVersion == null ? '' : String(value.tripRecordVersion),
originalDeparture: transport(value.originalDeparture),
deliveries: value.deliveries.map(row => {
const packages = Number(row.packages)
if (!row.deliveryNo || row.packages == null || String(row.packages).trim() === '' || !Number.isSafeInteger(packages) || packages <= 0) throw new Error('交接状态中的派送箱数无效')
const remainingPackages = Number(row.remainingPackages)
if (row.remainingPackages == null || String(row.remainingPackages).trim() === '' || !Number.isSafeInteger(remainingPackages) || remainingPackages < 0 || remainingPackages > packages) throw new Error('交接状态中的剩余箱数无效')
return { deliveryNo: text(row.deliveryNo), status: text(row.status), packages, remainingPackages, terminalAt: text(row.terminalAt), transport: transport(row.transport) }
}),
latestHandover: value.latestHandover ? { handoverNo: text(value.latestHandover.handoverNo), handedOverAt: text(value.latestHandover.handedOverAt),
location: text(value.latestHandover.location), previous: transport(value.latestHandover.previous), replacement: transport(value.latestHandover.replacement) } : null
}
}
export async function getDispatchHandoverStatus(dispatchNo) {
const number = String(dispatchNo || '').trim()
if (!number) throw new Error('请先打开排车任务')
const result = normalizeHandoverStatus(await overseasRequest('GET', `/pda/v1/last-leg/dispatches/${encodeURIComponent(number)}/handover-status`))
if (result.dispatchNo !== number) throw new Error('返回的交接记录不属于当前排车任务')
return result
}