diff --git a/api/auth.js b/api/auth.js new file mode 100644 index 0000000..4ed5cb5 --- /dev/null +++ b/api/auth.js @@ -0,0 +1,36 @@ +import { API_ROOT } from '@/config/runtime.js' + +function authRequest(path, data) { + return new Promise((resolve, reject) => { + uni.request({ + url: `${API_ROOT}${path}`, + method: 'POST', + data, + success(response) { + const body = response.data || {} + if (String(body.code ?? response.statusCode) !== '200') { + const error = new Error(body.message || '认证服务请求失败') + error.code = body.code || 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) + return + } + resolve(body.data) + }, + fail(error) { + const next = new Error(error.errMsg || '网络连接失败') + next.network = true + reject(next) + } + }) + }) +} + +export function login(username, password) { + return authRequest('/auth/login', { username, password }) +} + +export function refresh(refreshToken) { + return authRequest('/auth/refresh', { refreshToken }) +} diff --git a/api/pda-loading.js b/api/pda-loading.js index 76aa551..3a0ab9d 100644 --- a/api/pda-loading.js +++ b/api/pda-loading.js @@ -1,4 +1,5 @@ import { session } from '@/stores/session.js' +import { requireOnline } from '@/utils/network.js' const EVIDENCE_TYPES = Object.freeze([ 'PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL' @@ -19,6 +20,7 @@ const EVIDENCE_SLOTS = Object.freeze([ // server success when any required identifier or version is absent. function request(options) { + requireOnline() const baseUrl = session.apiBase || '/api/platform/v1' const headers = { ...(options.header || {}) } if (session.token) headers.Authorization = `Bearer ${session.token}` @@ -57,7 +59,14 @@ function idempotencyKey(operation, inboundId) { } function stableIdempotencyKey(operationId, suffix = '') { - return `pda-${operationId}${suffix ? `-${suffix}` : ''}` + const raw = `pda-${operationId}${suffix ? `-${suffix}` : ''}` + let hash = 2166136261 + for (let index = 0; index < raw.length; index += 1) { + hash ^= raw.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + const prefix = raw.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 96) + return `${prefix}-${(hash >>> 0).toString(36)}-${raw.length}`.slice(0, 128) } function tenantQuery(tenantId) { @@ -67,14 +76,14 @@ function tenantQuery(tenantId) { } export function isRemoteReady(inboundId) { - return Boolean(session.token && session.tenantId && inboundId) + return Boolean(session.token && (session.tenantId || inboundId)) } export function getContainerPickingTask(containerNo, warehouseId) { if (!containerNo) throw new Error('缺少柜次号,无法请求现场任务') const query = [`containerNo=${encodeURIComponent(containerNo)}`] if (warehouseId) query.push(`warehouseId=${encodeURIComponent(warehouseId)}`) - query.push(`tenantId=${encodeURIComponent(tenantQuery())}`) + if (session.tenantId) query.push(`tenantId=${encodeURIComponent(session.tenantId)}`) return request({ url: `/first-leg/pda-loading/tasks?${query.join('&')}`, method: 'GET' }).then((data) => { if (Array.isArray(data)) return data[0] || null if (data?.records) return data.records[0] || null @@ -118,20 +127,26 @@ export function skipContainerItem(taskId, itemId, payload = {}) { export async function submitContainerPickingBatch(task, actions, { warehouseId, operationId } = {}) { if (!task?.taskId) throw new Error('缺少拣货任务标识,无法提交') const submitOperationId = operationId || `submit-${task.taskId}-${task.containerNo || ''}-${task.version ?? task.pickingVersion ?? ''}` - const results = [] - let version = task.version ?? task.pickingVersion - for (const action of actions || []) { - const common = { warehouseId, locationIds: action.locationIds || [], version: action.version ?? version, operationId: `${submitOperationId}-${action.itemId}` } - let result - if (action.type === 'PICK') result = await pickContainerItem(task.taskId, action.itemId, { ...common, pickedPackageCount: action.pickedPackageCount }) - else if (action.type === 'PARTIAL') result = await partialContainerItem(task.taskId, action.itemId, { ...common, finalPickedPackageCount: action.finalPickedPackageCount }) - else if (action.type === 'SKIP') result = await skipContainerItem(task.taskId, action.itemId, { ...common, reason: action.reason }) - if (result !== undefined) { - results.push(result) - version = result?.version ?? result?.taskVersion ?? result?.pickingVersion ?? version - } - } - return results + const expectedVersion = task.version ?? task.pickingVersion + if (expectedVersion == null) throw new Error('缺少拣货任务版本,无法整批提交') + if (!warehouseId) throw new Error('缺少作业仓库标识,无法整批提交') + if (!task.containerNo) throw new Error('缺少柜号,无法整批提交') + const items = (actions || []).map((action) => ({ + itemId: action.itemId, + action: action.type, + pickedPackageCount: action.type === 'PICK' ? action.pickedPackageCount : null, + finalPickedPackageCount: action.type === 'PARTIAL' ? action.finalPickedPackageCount : null, + releaseMode: action.type === 'SKIP' ? null : (action.releaseMode || 'AUTO'), + locationIds: action.type === 'SKIP' ? [] : (action.locationIds || []), + version: action.version, + reason: action.type === 'SKIP' ? action.reason : null + })) + return request({ + url: `/first-leg/pda-loading/tasks/${encodeURIComponent(task.taskId)}/batch`, + method: 'POST', + header: { 'Idempotency-Key': stableIdempotencyKey(submitOperationId) }, + data: { warehouseId, containerNo: task.containerNo, expectedVersion, items } + }) } export function listLoadingOrders({ page = 1, size = 50, keyword = '' } = {}) { @@ -150,7 +165,13 @@ export function normalizePdaOrder(row = {}) { ...row, inboundId: row.inboundId ?? row.inbound?.id ?? null, inboundVersion: row.inboundVersion ?? row.inbound?.version ?? null, - pickingVersion: row.pickingVersion ?? row.loading?.pickingVersion ?? null + tenantId: row.tenantId ?? row.accountId ?? row.inbound?.tenantId ?? null, + tenantId: row.tenantId ?? row.accountId ?? row.inbound?.tenantId ?? null, + pickingVersion: row.pickingVersion ?? row.loading?.pickingVersion ?? null, + taskId: row.taskId ?? row.pdaTaskId ?? row.loadingTaskId ?? row.loading?.taskId ?? null, + containerNo: row.containerNo ?? row.container?.containerNo ?? row.loadingPlan?.containerNo ?? row.loading?.containerNo ?? '', + warehouseId: row.warehouseId ?? row.warehouse?.id ?? row.loadingPlan?.warehouseId ?? null, + displayNo: row.orderNo ?? row.inboundCode ?? row.order?.orderNo ?? row.id } } @@ -218,6 +239,7 @@ export function voidEvidence(inboundId, evidenceId, payload = {}, tenantId) { /** Upload a local photo to the file service and return its durable fileId. */ export function uploadEvidenceFile(inboundId, evidenceType, localPath, tenantId, onProgress) { + requireOnline('当前网络不可用,无法上传装柜照片') if (!EVIDENCE_TYPES.includes(evidenceType)) throw new Error('不支持的装柜证据类型') if (!localPath) throw new Error('缺少待上传的照片') const tenant = tenantQuery(tenantId) diff --git a/config/runtime.js b/config/runtime.js new file mode 100644 index 0000000..817b83b --- /dev/null +++ b/config/runtime.js @@ -0,0 +1,6 @@ +const configuredApiRoot = typeof import.meta !== 'undefined' + ? import.meta.env?.VITE_API_BASE_URL + : '' + +export const API_ROOT = String(configuredApiRoot || '/api').replace(/\/$/, '') +export const PLATFORM_API_ROOT = `${API_ROOT}/platform/v1` diff --git a/pages.json b/pages.json index 54445c9..bada3f1 100644 --- a/pages.json +++ b/pages.json @@ -4,6 +4,7 @@ { "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "仓库工作台", "backgroundColor": "#F5F7FA" } }, { "path": "pages/module/module", "style": { "navigationStyle": "custom", "navigationBarTitleText": "业务模块", "backgroundColor": "#F5F7FA" } }, { "path": "pages/operation/operation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "现场作业", "backgroundColor": "#F5F7FA" } }, + { "path": "pages/tasks/tasks", "style": { "navigationStyle": "custom", "navigationBarTitleText": "拣货任务", "backgroundColor": "#F5F7FA" } }, { "path": "pages/history/history", "style": { "navigationStyle": "custom", "navigationBarTitleText": "作业记录", "backgroundColor": "#F5F7FA" } }, { "path": "pages/profile/profile", "style": { "navigationStyle": "custom", "navigationBarTitleText": "个人中心", "backgroundColor": "#F5F7FA" } }, { "path": "pages/scanner-test/scanner-test", "style": { "navigationStyle": "custom", "navigationBarTitleText": "广播扫码测试", "backgroundColor": "#F5F7FA" } }, diff --git a/pages/home/home.vue b/pages/home/home.vue index 05ef28b..cfcc05a 100644 --- a/pages/home/home.vue +++ b/pages/home/home.vue @@ -47,7 +47,7 @@ return true }, methods: { - openOperation(operation) { if (operation.available) uni.navigateTo({ url: '/pages/picking/picking' }) } + openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.name === '集装箱拣货' ? '/pages/tasks/tasks' : '/pages/operation/operation' }) } } } diff --git a/pages/login/login.vue b/pages/login/login.vue index dd5dc0f..7d5b5c6 100644 --- a/pages/login/login.vue +++ b/pages/login/login.vue @@ -7,32 +7,37 @@ - {{ error }} - - + diff --git a/pages/module/module.vue b/pages/module/module.vue index f8df248..f36d69a 100644 --- a/pages/module/module.vue +++ b/pages/module/module.vue @@ -22,7 +22,7 @@ onLoad(options) { this.moduleKey = options.key || 'outbound' }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() }, methods: { - openOperation(operation) { if (operation.available) uni.navigateTo({ url: '/pages/picking/picking' }) } + openOperation(operation) { if (operation.available) uni.navigateTo({ url: operation.name === '集装箱拣货' ? '/pages/tasks/tasks' : '/pages/operation/operation' }) } } } diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index 1b1dce3..4104dae 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -140,7 +140,7 @@ - {{ remoteError }} + {{ remoteError }} 等待统一订单详情提供装柜版本号后可提交完成 @@ -326,6 +326,8 @@ import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' import { claimContainerPickingTask, completeLoading, completePicking, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getPickingTask, isRemoteReady, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, upsertEvidence, voidEvidence } from '@/api/pda-loading.js' import { createEvidenceUploadQueue } from '@/utils/evidence-upload-queue.js' + import { isOnline } from '@/utils/network.js' + import { stableOperationId } from '@/utils/idempotency.js' import ScanTargetInput from '@/components/ScanTargetInput.vue' import PopupHeader from '@/components/PopupHeader.vue' @@ -433,8 +435,7 @@ : !this.completedBoxes[entry.index]) }, remoteSyncState() { - if (!this.currentBox.inboundId) return { label: '演示任务', tone: 'local' } - if (!isRemoteReady(this.currentBox.inboundId)) return { label: '未连接', tone: 'error' } + if (!isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) return { label: '未连接', tone: 'error' } if (this.remoteLoading) return { label: '同步中', tone: 'loading' } if (this.remoteError) return { label: '同步失败', tone: 'error' } return { label: this.remotePick ? '已同步' : '待同步', tone: this.remotePick ? 'ready' : 'local' } @@ -445,11 +446,24 @@ }, created() { this.evidenceQueue = createEvidenceUploadQueue({ - upload: (item, onProgress) => uploadEvidenceFile(item.inboundId, item.evidenceType, item.localPath, null, onProgress), + upload: (item, onProgress) => uploadEvidenceFile(item.inboundId, item.evidenceType, item.localPath, item.tenantId, onProgress), onChange: (items) => { this.evidenceQueueItems = items } }) }, - onLoad() { requireSession() }, + onLoad(options = {}) { + if (!requireSession()) return + if (options.containerNo) { + this.boxes = [{ + boxNo: String(options.containerNo), orderNo: String(options.orderNo || options.inboundCode || ''), + inboundId: options.inboundId ? String(options.inboundId) : null, + inboundVersion: options.inboundVersion == null ? null : Number(options.inboundVersion), + warehouseId: options.warehouseId ? String(options.warehouseId) : null, + tenantId: options.tenantId ? String(options.tenantId) : null, items: [] + }] + this.currentBoxIndex = 0 + this.remoteTaskId = options.taskId ? String(options.taskId) : null + } + }, onShow() { if (!requireSession()) return this.startHardwareScanner() @@ -472,10 +486,9 @@ restoreRemoteDraft() { const restored = restorePickingSession(this.draftIdentity()) if (!restored) return false - if (Array.isArray(restored.boxes) && restored.boxes.length) this.boxes = restored.boxes - this.currentBoxIndex = Math.min(Number(restored.currentBoxIndex || 0), Math.max(0, this.boxes.length - 1)) - this.itemStates = restored.itemStates || this.itemStates - this.partialQuantities = restored.partialQuantities || {} + const itemIds = new Set(this.currentBox.items.map((item) => String(item.id))) + this.itemStates = Object.fromEntries(Object.entries(restored.itemStates || {}).filter(([id]) => itemIds.has(String(id)))) + this.partialQuantities = Object.fromEntries(Object.entries(restored.partialQuantities || {}).filter(([id]) => itemIds.has(String(id)))) this.boxDrafts = restored.boxDrafts || {} this.completedBoxes = restored.completedBoxes || {} this.scanHistory = restored.scanHistory || [] @@ -680,7 +693,7 @@ this.assignmentVersion = this.remotePick.assignmentVersion ?? this.assignmentVersion this.restoreRemoteDraft() if (this.currentBox.inboundId) { - const evidence = await getEvidence(this.currentBox.inboundId) + const evidence = await getEvidence(this.currentBox.inboundId, this.currentBox.tenantId) this.evidenceFiles = (evidence && evidence.evidences ? evidence.evidences : []).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, {}) const seal = this.evidenceEntry('SEAL') if (seal) this.sealNo = seal.sealNo || '' @@ -689,12 +702,19 @@ this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : (error.message || '任务同步失败') } finally { this.remoteLoading = false } }, + async refreshTask() { + if (!this.currentBox.boxNo) return + this.persistSession() + await this.syncRemoteTask() + if (!this.remoteError) this.showToast('任务已刷新,本地拣货标记已保留', 'success') + }, evidenceUploadLabel(type) { const queued = this.evidenceQueueEntry(type) if (queued) return queued.status === 'UPLOADING' ? `上传中 ${queued.progress || 0}%` : '等待上传' - return this.evidenceEntry(type) ? '重新选择' : '拍照/选择' + return this.evidenceEntry(type) ? '继续拍照' : '拍照' }, chooseEvidence(slot) { + if (!isOnline()) { this.showToast('当前网络不可用,请联网后再拍照上传', 'warning'); return } if (typeof uni.chooseImage !== 'function') { this.showToast('当前环境不支持选择照片', 'warning'); return } uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera'], success: (result) => { const path = result.tempFilePaths && result.tempFilePaths[0] @@ -706,7 +726,7 @@ }, async queueEvidenceUpload(type, path) { if (!this.currentBox.inboundId || !isRemoteReady(this.currentBox.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return } - const entry = this.evidenceQueue.add({ inboundId: this.currentBox.inboundId, evidenceType: type, localPath: path }) + const entry = this.evidenceQueue.add({ inboundId: this.currentBox.inboundId, tenantId: this.currentBox.tenantId, evidenceType: type, localPath: path }) await this.evidenceQueue.flush() const latest = this.evidenceQueue.snapshot().find((item) => item.id === entry.id) if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return } @@ -732,7 +752,7 @@ if (!current.fileId) { this.showToast('照片尚未上传完成', 'warning'); return } const payload = { expectedVersion: current.version || 0, evidenceType: type, fileId: current.fileId || null, fileName: current.fileName || null, sealNo: type === 'SEAL' ? this.sealNo : null, remark: null, operationId: operationId || `evidence-${this.remoteTaskId || this.currentBox.inboundId}-${type}-${current.fileId || 'seal'}` } try { - const result = await upsertEvidence(this.currentBox.inboundId, payload) + const result = await upsertEvidence(this.currentBox.inboundId, payload, this.currentBox.tenantId) this.evidenceFiles = (result && result.evidences ? result.evidences : []).reduce((all, item) => { const list = (all[item.evidenceType] || []).filter((entry) => entry.id || entry.fileId !== item.fileId); const index = list.findIndex((entry) => entry.id === item.id); all[item.evidenceType] = index >= 0 ? list.map((entry, i) => i === index ? item : entry) : [...list, item]; return all }, this.evidenceFiles) this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success') } catch (error) { this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败'); this.showToast('证据保存失败', 'error') } @@ -745,7 +765,7 @@ if (!photo?.id || !this.currentBox.inboundId) return const reason = 'PDA现场标记错图' try { - await voidEvidence(this.currentBox.inboundId, photo.id, { expectedVersion: photo.version || 0, reason, operationId: `void-${this.currentBox.inboundId}-${photo.id}-${photo.version || 0}` }) + await voidEvidence(this.currentBox.inboundId, photo.id, { expectedVersion: photo.version || 0, reason, operationId: `void-${this.currentBox.inboundId}-${photo.id}-${photo.version || 0}` }, this.currentBox.tenantId) this.evidenceFiles = { ...this.evidenceFiles, [photo.evidenceType]: (this.evidenceFiles[photo.evidenceType] || []).map((item) => item.id === photo.id ? { ...item, status: 'VOID', voidReason: reason } : item) } this.showToast('照片已作废,可重新拍摄', 'success') } catch (error) { @@ -758,7 +778,7 @@ if (!this.completedBoxes[this.currentBoxIndex]) { this.showToast('请先提交拣货完成', 'warning'); return } if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return } this.submitting = true - try { await completeLoading(this.currentBox.inboundId, { expectedVersion: this.loadingVersion, remark: 'PDA确认装柜完成' }); this.showToast('已确认装柜完成', 'success') } + try { await completeLoading(this.currentBox.inboundId, { expectedVersion: this.loadingVersion, remark: 'PDA确认装柜完成' }, this.currentBox.tenantId); this.showToast('已确认装柜完成', 'success') } catch (error) { this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败'); this.showToast('装柜完成失败', 'error') } finally { this.submitting = false } }, @@ -876,9 +896,9 @@ }, async performSubmit(incompleteReason = '') { const beforeSubmit = this.summary - const remoteRequired = Boolean(this.currentBox.inboundId || (session.token && session.tenantId)) + const remoteRequired = Boolean(session.token) if (remoteRequired && !isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) { - this.remoteError = '缺少服务端会话或 tenantId,未提交拣货完成' + this.remoteError = '缺少服务端任务信息,未提交拣货完成' this.showToast('拣货完成未提交', 'error') return } @@ -887,15 +907,15 @@ try { if (!this.remoteTaskId) throw new Error('尚未进入服务端拣货任务,请联网刷新后重试') if (this.pickingVersion === null) throw new Error('缺少拣货版本号 pickingVersion,无法提交') - const submitId = this.submitId || `submit-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${this.currentBox.boxNo}-${JSON.stringify(this.itemStates)}-${JSON.stringify(this.partialQuantities)}-${incompleteReason}` + const submitId = this.submitId || stableOperationId('submit', `${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${this.currentBox.boxNo}`, { itemStates: this.itemStates, partialQuantities: this.partialQuantities, incompleteReason }) this.submitId = submitId const actions = this.currentBox.items.map((item) => { const status = this.itemState(item) const itemId = item.taskItemId ?? item.remoteItemId ?? item.id - if (status === ITEM_STATUS.PICKED) return { type: 'PICK', itemId, pickedPackageCount: Number(item.qty || 0), locationIds: item.locationIds || [] } - if (status === ITEM_STATUS.PARTIAL) return { type: 'PARTIAL', itemId, finalPickedPackageCount: Number(this.partialQuantities[item.id] || 0), locationIds: item.locationIds || [] } - if (status === ITEM_STATUS.SKIPPED) return { type: 'SKIP', itemId, reason: item.skipReason || incompleteReason || '现场跳拣' } - if (incompleteReason) return { type: 'SKIP', itemId, reason: `${incompleteReason}(提交时确认未拣)` } + if (status === ITEM_STATUS.PICKED) return { type: 'PICK', itemId, pickedPackageCount: Number(item.qty || 0), locationIds: item.locationIds || [], version: Number(item.version || 0) } + if (status === ITEM_STATUS.PARTIAL) return { type: 'PARTIAL', itemId, finalPickedPackageCount: Number(this.partialQuantities[item.id] || 0), locationIds: item.locationIds || [], version: Number(item.version || 0) } + if (status === ITEM_STATUS.SKIPPED) return { type: 'SKIP', itemId, reason: item.skipReason || incompleteReason || '现场跳拣', version: Number(item.version || 0) } + if (incompleteReason) return { type: 'SKIP', itemId, reason: `${incompleteReason}(提交时确认未拣)`, version: Number(item.version || 0) } return null }).filter(Boolean) await submitContainerPickingBatch({ taskId: this.remoteTaskId, containerNo: this.currentBox.boxNo, version: Number(this.pickingVersion) }, actions, { warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse, operationId: submitId }) diff --git a/pages/tasks/tasks.vue b/pages/tasks/tasks.vue new file mode 100644 index 0000000..0729d82 --- /dev/null +++ b/pages/tasks/tasks.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/stores/session.js b/stores/session.js index 8151d0c..bba1957 100644 --- a/stores/session.js +++ b/stores/session.js @@ -1,26 +1,97 @@ import { reactive } from 'vue' import { resetPickingSession } from '@/stores/picking.js' +import { PLATFORM_API_ROOT } from '@/config/runtime.js' -export const session = reactive({ currentUser: null, token: '', tenantId: '', apiBase: '' }) +const SESSION_KEY = 'pda-auth-session' + +export const session = reactive({ currentUser: null, token: '', refreshToken: '', tenantId: '', apiBase: PLATFORM_API_ROOT }) + +function decodeBase64Url(value) { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') + if (typeof atob === 'function') return decodeURIComponent(Array.from(atob(padded), (char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`).join('')) + throw new Error('当前运行环境无法解析登录令牌') +} + +export function decodeAccessToken(token) { + const payload = String(token || '').split('.')[1] + if (!payload) throw new Error('登录响应中的 accessToken 格式无效') + try { + return JSON.parse(decodeBase64Url(payload)) + } catch (error) { + if (error.message === '当前运行环境无法解析登录令牌') throw error + throw new Error('登录响应中的 accessToken 无法解析') + } +} + +function storageAvailable() { + return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function' +} + +function persistSession() { + if (!storageAvailable()) return + uni.setStorageSync(SESSION_KEY, { + currentUser: session.currentUser, + token: session.token, + refreshToken: session.refreshToken, + tenantId: session.tenantId, + apiBase: session.apiBase + }) +} export function signIn(account, warehouse, options = {}) { - session.currentUser = { id: account.id, name: account.name, role: account.role, warehouse } + session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse, warehouseId: account.warehouseId || null } session.token = options.token || account.token || '' + session.refreshToken = options.refreshToken || account.refreshToken || '' session.tenantId = options.tenantId || account.tenantId || '' - session.apiBase = options.apiBase || account.apiBase || '' + session.apiBase = options.apiBase || account.apiBase || PLATFORM_API_ROOT + persistSession() return session.currentUser } +export function signInWithTokens(tokens) { + if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken') + const claims = decodeAccessToken(tokens.accessToken) + if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端') + if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('登录状态已过期') + return signIn({ + id: claims.user_id ?? '', + name: claims.username || '仓库作业员', + role: Array.isArray(claims.roles) ? claims.roles.join('、') : '', + roles: claims.roles || [] + }, '', { + token: tokens.accessToken, + refreshToken: tokens.refreshToken || '', + tenantId: claims.tenant_id == null ? '' : String(claims.tenant_id) + }) +} + +export function restoreSession() { + if (!storageAvailable()) return false + const saved = uni.getStorageSync(SESSION_KEY) + if (!saved?.token || !saved?.currentUser) return false + try { + const claims = decodeAccessToken(saved.token) + if (claims.exp && claims.exp * 1000 <= Date.now()) return false + Object.assign(session, saved, { apiBase: saved.apiBase || PLATFORM_API_ROOT }) + return true + } catch (_) { + return false + } +} + export function signOut() { session.currentUser = null session.token = '' + session.refreshToken = '' session.tenantId = '' - session.apiBase = '' + session.apiBase = PLATFORM_API_ROOT + if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY) resetPickingSession() } export function requireSession() { - if (session.currentUser) return true + if (session.currentUser || restoreSession()) return true uni.reLaunch({ url: '/pages/login/login' }) return false } diff --git a/tests/idempotency.test.mjs b/tests/idempotency.test.mjs new file mode 100644 index 0000000..36890a3 --- /dev/null +++ b/tests/idempotency.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +const { stableOperationId } = await import(new URL('../utils/idempotency.js', import.meta.url).href) + +test('PDA operation id is stable ASCII and within the backend 128 character limit', () => { + const payload = { itemStates: Object.fromEntries(Array.from({ length: 500 }, (_, index) => [`明细-${index}`, 'PICKED'])), reason: '中文异常原因'.repeat(100) } + const first = stableOperationId('submit', 'operator-1-task-2-container-3', payload) + const replay = stableOperationId('submit', 'operator-1-task-2-container-3', payload) + assert.equal(first, replay) + assert.ok(first.length <= 128) + assert.match(first, /^[\x20-\x7E]+$/) +}) + +test('PDA operation id changes when the draft changes', () => { + const first = stableOperationId('submit', 'operator-task-container', { item: 'PICKED' }) + const second = stableOperationId('submit', 'operator-task-container', { item: 'SKIPPED' }) + assert.notEqual(first, second) +}) diff --git a/tests/online-session.test.mjs b/tests/online-session.test.mjs new file mode 100644 index 0000000..b9ab1dd --- /dev/null +++ b/tests/online-session.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') + +test('remote PDA mutations use the shared online gate', async () => { + const [api, network, page] = await Promise.all([ + read('../api/pda-loading.js'), read('../utils/network.js'), read('../pages/picking/picking.vue') + ]) + assert.match(api, /function request\(options\) \{[\s\S]*?requireOnline\(\)/) + assert.match(api, /uploadEvidenceFile[\s\S]*?requireOnline\('当前网络不可用,无法上传装柜照片'\)/) + assert.match(network, /uni\.onNetworkStatusChange/) + assert.match(network, /result\.networkType !== 'none'/) + assert.match(page, /当前网络不可用,请联网后再拍照上传/) +}) + +test('remote refresh keeps draft actions but does not restore stale server item snapshots', async () => { + const page = await read('../pages/picking/picking.vue') + const restore = page.match(/restoreRemoteDraft\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(restore, /itemIds/) + assert.match(restore, /restored\.itemStates/) + assert.doesNotMatch(restore, /this\.boxes = restored\.boxes/) + assert.match(page, /refreshTask\(\)[\s\S]*?persistSession\(\)[\s\S]*?syncRemoteTask\(\)/) +}) diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs index 85d27f0..f74c82f 100644 --- a/tests/page-contract.test.mjs +++ b/tests/page-contract.test.mjs @@ -10,7 +10,7 @@ test('page configuration starts at login and registers independent PDA routes', assert.equal(pages.pages[0].style.navigationStyle, 'custom') assert.deepEqual(pages.pages.map((page) => page.path), [ 'pages/login/login', 'pages/home/home', 'pages/module/module', 'pages/operation/operation', - 'pages/history/history', 'pages/profile/profile', 'pages/scanner-test/scanner-test', 'pages/picking/picking' + 'pages/tasks/tasks', 'pages/history/history', 'pages/profile/profile', 'pages/scanner-test/scanner-test', 'pages/picking/picking' ]) }) @@ -22,13 +22,28 @@ test('application uses the Vue 3 uni-app entry and no unused Android permissions assert.doesNotMatch(manifest, /android\.permission\.(VIBRATE|ACCESS_NETWORK_STATE)/) }) -test('login is isolated and relaunches to the modular workbench', async () => { - const login = await read('../pages/login/login.vue') - for (const text of ['柏悦物流 · PDA作业端', '快捷登录(演示账号)', '工号 / 用户名', '作业仓库']) assert.match(login, new RegExp(text)) - assert.match(login, /signIn\(account, this\.form\.warehouse\)/) +test('login uses the real auth endpoint and relaunches to the modular workbench', async () => { + const [login, auth, session] = await Promise.all([read('../pages/login/login.vue'), read('../api/auth.js'), read('../stores/session.js')]) + for (const text of ['柏悦物流 · PDA作业端', '工号 / 用户名', '登录中']) assert.match(login, new RegExp(text)) + assert.doesNotMatch(login, /DEMO_ACCOUNTS|快捷登录|作业仓库/) + assert.match(auth, /\/auth\/login/) + assert.match(login, /loginRequest\(userId, this\.form\.password\)/) + assert.match(login, /signInWithTokens\(tokens\)/) + assert.match(session, /pda-auth-session/) + assert.match(session, /claims\.app_code !== 'PLATFORM'/) assert.match(login, /uni\.reLaunch\(\{ url: '\/pages\/home\/home' \}\)/) }) +test('picking task list uses remote loading orders and never falls back to demo tasks', async () => { + const [tasks, home, modulePage] = await Promise.all([read('../pages/tasks/tasks.vue'), read('../pages/home/home.vue'), read('../pages/module/module.vue')]) + assert.match(tasks, /listLoadingOrders/) + assert.match(tasks, /当前网络不可用,请联网后刷新/) + assert.match(tasks, /进入并开始拣货/) + assert.doesNotMatch(tasks, /demo-data|DEMO_/) + assert.match(home, /pages\/tasks\/tasks/) + assert.match(modulePage, /pages\/tasks\/tasks/) +}) + test('workbench, module, history and profile are independent sources', async () => { const [home, module, history, profile] = await Promise.all([ read('../pages/home/home.vue'), read('../pages/module/module.vue'), read('../pages/history/history.vue'), read('../pages/profile/profile.vue') @@ -194,7 +209,7 @@ test('reusable scan target input separates field selection from page-level broad assert.match(picking, /handleHardwareScan\(value\)[\s\S]*?activeScanField !== 'sku'[\s\S]*?processScannedValue\(value, 'broadcast'\)/) }) -test('picking session is memory-only, survives navigation and clears on logout', async () => { +test('picking session persists per task identity and clears on logout', async () => { const [store, session, page] = await Promise.all([read('../stores/picking.js'), read('../stores/session.js'), read('../pages/picking/picking.vue')]) assert.match(store, /getPickingSession/) assert.match(store, /savePickingSession/) diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs index 874ef78..214bd05 100644 --- a/tests/pda-loading-api.test.mjs +++ b/tests/pda-loading-api.test.mjs @@ -55,18 +55,21 @@ test('picking page exposes five evidence slots, retry and remote completion stat assert.match(page, /evidenceComplete/) }) -test('PDA cabinet picking adapter exposes online claim and item action contracts', () => { - assert.match(source, /\/first-leg\/pda-loading\/tasks\?/) - assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/claim/) - assert.match(source, /items\/\$\{encodeURIComponent\(itemId\)\}\/pick/) - assert.match(source, /items\/\$\{encodeURIComponent\(itemId\)\}\/partial/) - assert.match(source, /items\/\$\{encodeURIComponent\(itemId\)\}\/skip/) - assert.match(source, /submitContainerPickingBatch/) - assert.match(source, /stableIdempotencyKey/) - assert.match(page, /claimContainerPickingTask\(/) - assert.match(page, /submitContainerPickingBatch\(/) - assert.match(page, /任务版本已变化,请刷新后重试/) - assert.match(source, /locationIds: action\.locationIds \|\| \[\]/) - assert.match(page, /availablePickQuantity/) - assert.match(page, /库存\/库位可用量不足/) +test('PDA cabinet picking adapter claims online and submits one atomic batch', () => { + assert.match(source, /\/first-leg\/pda-loading\/tasks\?/) + assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/claim/) + assert.match(source, /tasks\/\$\{encodeURIComponent\(task\.taskId\)\}\/batch/) + assert.match(source, /submitContainerPickingBatch/) + assert.match(source, /stableIdempotencyKey/) + assert.match(source, /Math\.imul\(hash, 16777619\)/) + assert.match(source, /slice\(0, 128\)/) + assert.match(page, /claimContainerPickingTask\(/) + assert.match(page, /submitContainerPickingBatch\(/) + assert.match(page, /任务版本已变化,请刷新后重试/) + assert.match(source, /data: \{ warehouseId, containerNo: task\.containerNo, expectedVersion, items \}/) + assert.doesNotMatch(source.match(/export async function submitContainerPickingBatch[\s\S]*?\n\}/)?.[0] || '', /for \(/) + assert.match(page, /version: Number\(item\.version \|\| 0\)/) + assert.match(page, /refreshTask/) + assert.match(page, /availablePickQuantity/) + assert.match(page, /库存\/库位可用量不足/) }) diff --git a/utils/idempotency.js b/utils/idempotency.js new file mode 100644 index 0000000..161fada --- /dev/null +++ b/utils/idempotency.js @@ -0,0 +1,19 @@ +function hash32(value, seed) { + let hash = seed >>> 0 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + return hash.toString(16).padStart(8, '0') +} + +export function shortStableHash(value) { + const input = String(value ?? '') + return `${hash32(input, 0x811c9dc5)}${hash32(input, 0x9e3779b9)}` +} + +export function stableOperationId(scope, identity, payload) { + const safeScope = String(scope || 'operation').replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 24) + const safeIdentity = String(identity || 'unknown').replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 48) + return `${safeScope}-${safeIdentity}-${shortStableHash(JSON.stringify(payload ?? null))}`.slice(0, 120) +} diff --git a/utils/network.js b/utils/network.js new file mode 100644 index 0000000..723914a --- /dev/null +++ b/utils/network.js @@ -0,0 +1,46 @@ +let online = true +let listening = false +const listeners = new Set() + +function notify() { + for (const listener of listeners) listener(online) +} + +function ensureListening() { + if (listening || typeof uni === 'undefined') return + listening = true + if (typeof uni.onNetworkStatusChange === 'function') { + uni.onNetworkStatusChange((result) => { + online = Boolean(result.isConnected) + notify() + }) + } + if (typeof uni.getNetworkType === 'function') { + uni.getNetworkType({ + success: (result) => { + online = result.networkType !== 'none' + notify() + } + }) + } +} + +export function isOnline() { + ensureListening() + return online +} + +export function requireOnline(message = '当前网络不可用,请联网后刷新重试') { + if (!isOnline()) { + const error = new Error(message) + error.network = true + throw error + } +} + +export function watchNetwork(listener) { + ensureListening() + listeners.add(listener) + listener(online) + return () => listeners.delete(listener) +}