feat(pda): connect online picking workflow

This commit is contained in:
2026-08-11 11:31:05 +08:00
parent fc1944fb7e
commit be776c8efa
16 changed files with 440 additions and 82 deletions
+36
View File
@@ -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 })
}
+40 -18
View File
@@ -1,4 +1,5 @@
import { session } from '@/stores/session.js' import { session } from '@/stores/session.js'
import { requireOnline } from '@/utils/network.js'
const EVIDENCE_TYPES = Object.freeze([ const EVIDENCE_TYPES = Object.freeze([
'PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL' '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. // server success when any required identifier or version is absent.
function request(options) { function request(options) {
requireOnline()
const baseUrl = session.apiBase || '/api/platform/v1' const baseUrl = session.apiBase || '/api/platform/v1'
const headers = { ...(options.header || {}) } const headers = { ...(options.header || {}) }
if (session.token) headers.Authorization = `Bearer ${session.token}` if (session.token) headers.Authorization = `Bearer ${session.token}`
@@ -57,7 +59,14 @@ function idempotencyKey(operation, inboundId) {
} }
function stableIdempotencyKey(operationId, suffix = '') { 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) { function tenantQuery(tenantId) {
@@ -67,14 +76,14 @@ function tenantQuery(tenantId) {
} }
export function isRemoteReady(inboundId) { export function isRemoteReady(inboundId) {
return Boolean(session.token && session.tenantId && inboundId) return Boolean(session.token && (session.tenantId || inboundId))
} }
export function getContainerPickingTask(containerNo, warehouseId) { export function getContainerPickingTask(containerNo, warehouseId) {
if (!containerNo) throw new Error('缺少柜次号,无法请求现场任务') if (!containerNo) throw new Error('缺少柜次号,无法请求现场任务')
const query = [`containerNo=${encodeURIComponent(containerNo)}`] const query = [`containerNo=${encodeURIComponent(containerNo)}`]
if (warehouseId) query.push(`warehouseId=${encodeURIComponent(warehouseId)}`) 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) => { return request({ url: `/first-leg/pda-loading/tasks?${query.join('&')}`, method: 'GET' }).then((data) => {
if (Array.isArray(data)) return data[0] || null if (Array.isArray(data)) return data[0] || null
if (data?.records) return data.records[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 } = {}) { export async function submitContainerPickingBatch(task, actions, { warehouseId, operationId } = {}) {
if (!task?.taskId) throw new Error('缺少拣货任务标识,无法提交') if (!task?.taskId) throw new Error('缺少拣货任务标识,无法提交')
const submitOperationId = operationId || `submit-${task.taskId}-${task.containerNo || ''}-${task.version ?? task.pickingVersion ?? ''}` const submitOperationId = operationId || `submit-${task.taskId}-${task.containerNo || ''}-${task.version ?? task.pickingVersion ?? ''}`
const results = [] const expectedVersion = task.version ?? task.pickingVersion
let version = task.version ?? task.pickingVersion if (expectedVersion == null) throw new Error('缺少拣货任务版本,无法整批提交')
for (const action of actions || []) { if (!warehouseId) throw new Error('缺少作业仓库标识,无法整批提交')
const common = { warehouseId, locationIds: action.locationIds || [], version: action.version ?? version, operationId: `${submitOperationId}-${action.itemId}` } if (!task.containerNo) throw new Error('缺少柜号,无法整批提交')
let result const items = (actions || []).map((action) => ({
if (action.type === 'PICK') result = await pickContainerItem(task.taskId, action.itemId, { ...common, pickedPackageCount: action.pickedPackageCount }) itemId: action.itemId,
else if (action.type === 'PARTIAL') result = await partialContainerItem(task.taskId, action.itemId, { ...common, finalPickedPackageCount: action.finalPickedPackageCount }) action: action.type,
else if (action.type === 'SKIP') result = await skipContainerItem(task.taskId, action.itemId, { ...common, reason: action.reason }) pickedPackageCount: action.type === 'PICK' ? action.pickedPackageCount : null,
if (result !== undefined) { finalPickedPackageCount: action.type === 'PARTIAL' ? action.finalPickedPackageCount : null,
results.push(result) releaseMode: action.type === 'SKIP' ? null : (action.releaseMode || 'AUTO'),
version = result?.version ?? result?.taskVersion ?? result?.pickingVersion ?? version locationIds: action.type === 'SKIP' ? [] : (action.locationIds || []),
} version: action.version,
} reason: action.type === 'SKIP' ? action.reason : null
return results }))
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 = '' } = {}) { export function listLoadingOrders({ page = 1, size = 50, keyword = '' } = {}) {
@@ -150,7 +165,13 @@ export function normalizePdaOrder(row = {}) {
...row, ...row,
inboundId: row.inboundId ?? row.inbound?.id ?? null, inboundId: row.inboundId ?? row.inbound?.id ?? null,
inboundVersion: row.inboundVersion ?? row.inbound?.version ?? 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. */ /** Upload a local photo to the file service and return its durable fileId. */
export function uploadEvidenceFile(inboundId, evidenceType, localPath, tenantId, onProgress) { export function uploadEvidenceFile(inboundId, evidenceType, localPath, tenantId, onProgress) {
requireOnline('当前网络不可用,无法上传装柜照片')
if (!EVIDENCE_TYPES.includes(evidenceType)) throw new Error('不支持的装柜证据类型') if (!EVIDENCE_TYPES.includes(evidenceType)) throw new Error('不支持的装柜证据类型')
if (!localPath) throw new Error('缺少待上传的照片') if (!localPath) throw new Error('缺少待上传的照片')
const tenant = tenantQuery(tenantId) const tenant = tenantQuery(tenantId)
+6
View File
@@ -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`
+1
View File
@@ -4,6 +4,7 @@
{ "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "仓库工作台", "backgroundColor": "#F5F7FA" } }, { "path": "pages/home/home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "仓库工作台", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/module/module", "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/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/history/history", "style": { "navigationStyle": "custom", "navigationBarTitleText": "作业记录", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/profile/profile", "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" } }, { "path": "pages/scanner-test/scanner-test", "style": { "navigationStyle": "custom", "navigationBarTitleText": "广播扫码测试", "backgroundColor": "#F5F7FA" } },
+1 -1
View File
@@ -47,7 +47,7 @@
return true return true
}, },
methods: { 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' }) }
} }
} }
</script> </script>
+20 -15
View File
@@ -7,32 +7,37 @@
<view class="login-card"> <view class="login-card">
<view class="login-field"><text class="login-field-label">工号 / 用户名</text><input v-model="form.userId" class="login-field-input" aria-label="工号 / 用户名" placeholder="请输入工号" confirm-type="next" @focus="error = ''" /></view> <view class="login-field"><text class="login-field-label">工号 / 用户名</text><input v-model="form.userId" class="login-field-input" aria-label="工号 / 用户名" placeholder="请输入工号" confirm-type="next" @focus="error = ''" /></view>
<view class="login-field"><text class="login-field-label">密码</text><input v-model="form.password" class="login-field-input" aria-label="密码" type="password" placeholder="请输入密码" confirm-type="done" @focus="error = ''" @confirm="login" /></view> <view class="login-field"><text class="login-field-label">密码</text><input v-model="form.password" class="login-field-input" aria-label="密码" type="password" placeholder="请输入密码" confirm-type="done" @focus="error = ''" @confirm="login" /></view>
<view class="login-field"><text class="login-field-label">作业仓库</text><picker aria-label="作业仓库" :range="warehouses" :value="warehouseIndex" @change="changeWarehouse"><view class="login-field-select"><text>{{ form.warehouse }}</text><text class="select-chevron">⌄</text></view></picker></view>
<text v-if="error" class="login-error" role="alert">{{ error }}</text> <text v-if="error" class="login-error" role="alert">{{ error }}</text>
<button class="login-btn" @tap="login">登录</button> <button class="login-btn" :disabled="submitting" @tap="login">{{ submitting ? '登录中...' : '登录' }}</button>
<view class="login-accounts"><text class="login-accounts-title">快捷登录(演示账号)</text><view class="login-quick-btns"><button v-for="account in accounts" :key="account.id" class="login-quick-btn" @tap="enter(account)"><text class="login-quick-btn-name">{{ account.id }} {{ account.name }}</text><text class="login-quick-btn-role"> · {{ account.role }}</text></button></view></view>
</view> </view>
</view> </view>
</template> </template>
<script> <script>
import { DEMO_ACCOUNTS, WAREHOUSES } from '@/data/demo-data.js' import { login as loginRequest } from '@/api/auth.js'
import { session, signIn } from '@/stores/session.js' import { session, restoreSession, signInWithTokens } from '@/stores/session.js'
import { requireOnline } from '@/utils/network.js'
export default { export default {
data() { return { accounts: DEMO_ACCOUNTS, warehouses: WAREHOUSES, form: { userId: '', password: '', warehouse: WAREHOUSES[0] }, error: '' } }, data() { return { form: { userId: '', password: '' }, error: '', submitting: false } },
computed: { warehouseIndex() { return Math.max(0, this.warehouses.indexOf(this.form.warehouse)) } }, onLoad() { if (session.currentUser || restoreSession()) uni.reLaunch({ url: '/pages/home/home' }) },
onLoad() { if (session.currentUser) uni.reLaunch({ url: '/pages/home/home' }) },
methods: { methods: {
changeWarehouse(event) { this.form.warehouse = this.warehouses[Number(event.detail.value)] || this.warehouses[0] }, async login() {
login() {
const userId = this.form.userId.trim() const userId = this.form.userId.trim()
if (!userId) return void (this.error = '请输入工号') if (!userId) return void (this.error = '请输入工号')
if (!this.form.password) return void (this.error = '请输入密码') if (!this.form.password) return void (this.error = '请输入密码')
const account = this.accounts.find((entry) => entry.id === userId) this.submitting = true
if (!account || account.password !== this.form.password) return void (this.error = '工号或密码错误') this.error = ''
this.enter(account) try {
}, requireOnline('当前网络不可用,无法登录')
enter(account) { signIn(account, this.form.warehouse); uni.reLaunch({ url: '/pages/home/home' }) } const tokens = await loginRequest(userId, this.form.password)
signInWithTokens(tokens)
uni.reLaunch({ url: '/pages/home/home' })
} catch (error) {
this.error = error.network ? '当前网络不可用,请联网后重试' : (error.message || '登录失败')
} finally {
this.submitting = false
}
}
} }
} }
</script> </script>
+1 -1
View File
@@ -22,7 +22,7 @@
onLoad(options) { this.moduleKey = options.key || 'outbound' }, onLoad(options) { this.moduleKey = options.key || 'outbound' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() },
methods: { 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' }) }
} }
} }
</script> </script>
+42 -22
View File
@@ -140,7 +140,7 @@
<input v-model="sealNo" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" /> <input v-model="sealNo" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
<button class="evidence-btn seal-btn" @tap="saveSealNo">保存封签</button> <button class="evidence-btn seal-btn" @tap="saveSealNo">保存封签</button>
</view> </view>
<text v-if="remoteError" class="remote-error" role="alert">{{ remoteError }}</text> <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 || loadingVersion === null || !completedBoxes[currentBoxIndex] || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button> <button class="loading-complete-btn" :disabled="submitting || loadingVersion === null || !completedBoxes[currentBoxIndex] || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button>
<text v-if="loadingVersion === null" class="version-hint">等待统一订单详情提供装柜版本号后可提交完成</text> <text v-if="loadingVersion === null" class="version-hint">等待统一订单详情提供装柜版本号后可提交完成</text>
</view> </view>
@@ -326,6 +326,8 @@
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' 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 { 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 { 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 ScanTargetInput from '@/components/ScanTargetInput.vue'
import PopupHeader from '@/components/PopupHeader.vue' import PopupHeader from '@/components/PopupHeader.vue'
@@ -433,8 +435,7 @@
: !this.completedBoxes[entry.index]) : !this.completedBoxes[entry.index])
}, },
remoteSyncState() { remoteSyncState() {
if (!this.currentBox.inboundId) return { label: '演示任务', tone: 'local' } if (!isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) return { label: '未连接', tone: 'error' }
if (!isRemoteReady(this.currentBox.inboundId)) return { label: '未连接', tone: 'error' }
if (this.remoteLoading) return { label: '同步中', tone: 'loading' } if (this.remoteLoading) return { label: '同步中', tone: 'loading' }
if (this.remoteError) return { label: '同步失败', tone: 'error' } if (this.remoteError) return { label: '同步失败', tone: 'error' }
return { label: this.remotePick ? '已同步' : '待同步', tone: this.remotePick ? 'ready' : 'local' } return { label: this.remotePick ? '已同步' : '待同步', tone: this.remotePick ? 'ready' : 'local' }
@@ -445,11 +446,24 @@
}, },
created() { created() {
this.evidenceQueue = createEvidenceUploadQueue({ 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 } 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() { onShow() {
if (!requireSession()) return if (!requireSession()) return
this.startHardwareScanner() this.startHardwareScanner()
@@ -472,10 +486,9 @@
restoreRemoteDraft() { restoreRemoteDraft() {
const restored = restorePickingSession(this.draftIdentity()) const restored = restorePickingSession(this.draftIdentity())
if (!restored) return false if (!restored) return false
if (Array.isArray(restored.boxes) && restored.boxes.length) this.boxes = restored.boxes const itemIds = new Set(this.currentBox.items.map((item) => String(item.id)))
this.currentBoxIndex = Math.min(Number(restored.currentBoxIndex || 0), Math.max(0, this.boxes.length - 1)) this.itemStates = Object.fromEntries(Object.entries(restored.itemStates || {}).filter(([id]) => itemIds.has(String(id))))
this.itemStates = restored.itemStates || this.itemStates this.partialQuantities = Object.fromEntries(Object.entries(restored.partialQuantities || {}).filter(([id]) => itemIds.has(String(id))))
this.partialQuantities = restored.partialQuantities || {}
this.boxDrafts = restored.boxDrafts || {} this.boxDrafts = restored.boxDrafts || {}
this.completedBoxes = restored.completedBoxes || {} this.completedBoxes = restored.completedBoxes || {}
this.scanHistory = restored.scanHistory || [] this.scanHistory = restored.scanHistory || []
@@ -680,7 +693,7 @@
this.assignmentVersion = this.remotePick.assignmentVersion ?? this.assignmentVersion this.assignmentVersion = this.remotePick.assignmentVersion ?? this.assignmentVersion
this.restoreRemoteDraft() this.restoreRemoteDraft()
if (this.currentBox.inboundId) { 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 }, {}) this.evidenceFiles = (evidence && evidence.evidences ? evidence.evidences : []).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, {})
const seal = this.evidenceEntry('SEAL') const seal = this.evidenceEntry('SEAL')
if (seal) this.sealNo = seal.sealNo || '' if (seal) this.sealNo = seal.sealNo || ''
@@ -689,12 +702,19 @@
this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : (error.message || '任务同步失败') this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : (error.message || '任务同步失败')
} finally { this.remoteLoading = false } } finally { this.remoteLoading = false }
}, },
async refreshTask() {
if (!this.currentBox.boxNo) return
this.persistSession()
await this.syncRemoteTask()
if (!this.remoteError) this.showToast('任务已刷新,本地拣货标记已保留', 'success')
},
evidenceUploadLabel(type) { evidenceUploadLabel(type) {
const queued = this.evidenceQueueEntry(type) const queued = this.evidenceQueueEntry(type)
if (queued) return queued.status === 'UPLOADING' ? `上传中 ${queued.progress || 0}%` : '等待上传' if (queued) return queued.status === 'UPLOADING' ? `上传中 ${queued.progress || 0}%` : '等待上传'
return this.evidenceEntry(type) ? '重新选择' : '拍照/选择' return this.evidenceEntry(type) ? '继续拍照' : '拍照'
}, },
chooseEvidence(slot) { chooseEvidence(slot) {
if (!isOnline()) { this.showToast('当前网络不可用,请联网后再拍照上传', 'warning'); return }
if (typeof uni.chooseImage !== 'function') { this.showToast('当前环境不支持选择照片', 'warning'); return } if (typeof uni.chooseImage !== 'function') { this.showToast('当前环境不支持选择照片', 'warning'); return }
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera'], success: (result) => { uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera'], success: (result) => {
const path = result.tempFilePaths && result.tempFilePaths[0] const path = result.tempFilePaths && result.tempFilePaths[0]
@@ -706,7 +726,7 @@
}, },
async queueEvidenceUpload(type, path) { async queueEvidenceUpload(type, path) {
if (!this.currentBox.inboundId || !isRemoteReady(this.currentBox.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return } 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() await this.evidenceQueue.flush()
const latest = this.evidenceQueue.snapshot().find((item) => item.id === entry.id) const latest = this.evidenceQueue.snapshot().find((item) => item.id === entry.id)
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return } if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
@@ -732,7 +752,7 @@
if (!current.fileId) { this.showToast('照片尚未上传完成', 'warning'); return } 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'}` } 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 { 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.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') this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success')
} catch (error) { this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败'); this.showToast('证据保存失败', 'error') } } catch (error) { this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败'); this.showToast('证据保存失败', 'error') }
@@ -745,7 +765,7 @@
if (!photo?.id || !this.currentBox.inboundId) return if (!photo?.id || !this.currentBox.inboundId) return
const reason = 'PDA现场标记错图' const reason = 'PDA现场标记错图'
try { 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.evidenceFiles = { ...this.evidenceFiles, [photo.evidenceType]: (this.evidenceFiles[photo.evidenceType] || []).map((item) => item.id === photo.id ? { ...item, status: 'VOID', voidReason: reason } : item) }
this.showToast('照片已作废,可重新拍摄', 'success') this.showToast('照片已作废,可重新拍摄', 'success')
} catch (error) { } catch (error) {
@@ -758,7 +778,7 @@
if (!this.completedBoxes[this.currentBoxIndex]) { this.showToast('请先提交拣货完成', 'warning'); return } if (!this.completedBoxes[this.currentBoxIndex]) { this.showToast('请先提交拣货完成', 'warning'); return }
if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return } if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return }
this.submitting = true 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') } catch (error) { this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败'); this.showToast('装柜完成失败', 'error') }
finally { this.submitting = false } finally { this.submitting = false }
}, },
@@ -876,9 +896,9 @@
}, },
async performSubmit(incompleteReason = '') { async performSubmit(incompleteReason = '') {
const beforeSubmit = this.summary 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)) { if (remoteRequired && !isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) {
this.remoteError = '缺少服务端会话或 tenantId,未提交拣货完成' this.remoteError = '缺少服务端任务信息,未提交拣货完成'
this.showToast('拣货完成未提交', 'error') this.showToast('拣货完成未提交', 'error')
return return
} }
@@ -887,15 +907,15 @@
try { try {
if (!this.remoteTaskId) throw new Error('尚未进入服务端拣货任务,请联网刷新后重试') if (!this.remoteTaskId) throw new Error('尚未进入服务端拣货任务,请联网刷新后重试')
if (this.pickingVersion === null) throw new Error('缺少拣货版本号 pickingVersion,无法提交') 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 this.submitId = submitId
const actions = this.currentBox.items.map((item) => { const actions = this.currentBox.items.map((item) => {
const status = this.itemState(item) const status = this.itemState(item)
const itemId = item.taskItemId ?? item.remoteItemId ?? item.id 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.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 || [] } 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 || '现场跳拣' } 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}(提交时确认未拣)` } if (incompleteReason) return { type: 'SKIP', itemId, reason: `${incompleteReason}(提交时确认未拣)`, version: Number(item.version || 0) }
return null return null
}).filter(Boolean) }).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 }) 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 })
+70
View File
@@ -0,0 +1,70 @@
<template>
<view class="page-shell task-page">
<PortalHeader title="拣货任务" :user="user" :fallback-url="'/pages/home/home'" />
<view class="task-content">
<view class="task-toolbar">
<text class="task-title">待处理任务</text>
<button class="task-refresh" :disabled="loading" @tap="loadTasks">刷新</button>
</view>
<view v-if="error" class="task-error" role="alert">{{ error }}</view>
<view v-if="loading" class="task-state">正在加载真实任务...</view>
<view v-else-if="!tasks.length && !error" class="task-state">暂无待处理拣货任务</view>
<view v-for="task in tasks" :key="task.id || task.inboundId || task.displayNo" class="task-card">
<view class="task-card-head"><text class="task-code">{{ task.containerNo || '待分配柜次' }}</text><text class="task-status">{{ task.mainStatus || '待拣货' }}</text></view>
<text class="task-order">{{ task.displayNo || '未命名订单' }}</text>
<text class="task-meta">{{ task.warehouseName || (task.warehouseId ? `仓库 ${task.warehouseId}` : '缺少仓库信息') }}</text>
<button class="task-enter" :disabled="!task.containerNo || !task.warehouseId" @tap="enterTask(task)">{{ task.containerNo && task.warehouseId ? '进入并开始拣货' : '缺少任务信息' }}</button>
</view>
</view>
<view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
</view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import { listLoadingOrders } from '@/api/pda-loading.js'
import { session, requireSession } from '@/stores/session.js'
import { isOnline, watchNetwork } from '@/utils/network.js'
export default {
components: { PortalHeader },
data() { return { user: session.currentUser, tasks: [], loading: false, error: '', online: true, toast: { visible: false, message: '', type: '' } } },
onLoad() { if (!requireSession()) return; this._stopNetwork = watchNetwork((value) => { this.online = value }) },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() },
onUnload() { if (this._stopNetwork) this._stopNetwork(); clearTimeout(this._toastTimer) },
methods: {
async loadTasks() {
if (!isOnline()) { this.error = '当前网络不可用,请联网后刷新'; return }
this.loading = true; this.error = ''
try {
const result = await listLoadingOrders({ page: 1, size: 50 })
this.tasks = (result.records || []).filter((task) => task.containerNo || task.taskId || task.inboundId)
} catch (error) {
this.error = error.network ? '当前网络不可用,请联网后刷新' : (error.message || '任务加载失败')
} finally { this.loading = false }
},
enterTask(task) {
if (!task.containerNo || !task.warehouseId) { this.showToast('任务缺少柜号或仓库标识,无法开始', 'error'); return }
uni.navigateTo({ url: `/pages/picking/picking?containerNo=${encodeURIComponent(task.containerNo)}&warehouseId=${encodeURIComponent(task.warehouseId)}&inboundId=${encodeURIComponent(task.inboundId || '')}&inboundVersion=${encodeURIComponent(task.inboundVersion ?? '')}&tenantId=${encodeURIComponent(task.tenantId || '')}&taskId=${encodeURIComponent(task.taskId || '')}&orderNo=${encodeURIComponent(task.displayNo || '')}` })
},
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2200) }
}
}
</script>
<style scoped>
.task-content { padding: 24px 16px 40px; }
.task-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.task-title { color: #12233f; font-size: 22px; font-weight: 700; }
.task-refresh { margin: 0; min-width: 92px; height: 44px; border: 0; border-radius: 10px; background: #1a73e8; color: #fff; font-size: 15px; }
.task-refresh[disabled] { opacity: .6; }
.task-error, .task-state { padding: 24px 16px; border-radius: 12px; background: #fff; color: #667085; text-align: center; }
.task-error { color: #b42318; background: #fff1f0; }
.task-card { padding: 16px; margin-bottom: 12px; border: 1px solid #e3eaf3; border-radius: 12px; background: #fff; box-shadow: 0 4px 16px rgba(23, 55, 93, .06); }
.task-card-head { display: flex; align-items: center; justify-content: space-between; }
.task-code { color: #12233f; font-weight: 700; }
.task-status { color: #16794a; font-size: 13px; }
.task-order, .task-meta { display: block; margin-top: 8px; color: #667085; font-size: 14px; }
.task-enter { width: 100%; margin: 16px 0 0; height: 48px; border: 0; border-radius: 10px; background: #1a73e8; color: #fff; font-size: 16px; }
.task-enter[disabled] { background: #d0d5dd; color: #667085; }
</style>
+76 -5
View File
@@ -1,26 +1,97 @@
import { reactive } from 'vue' import { reactive } from 'vue'
import { resetPickingSession } from '@/stores/picking.js' 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 = {}) { 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.token = options.token || account.token || ''
session.refreshToken = options.refreshToken || account.refreshToken || ''
session.tenantId = options.tenantId || account.tenantId || '' 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 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() { export function signOut() {
session.currentUser = null session.currentUser = null
session.token = '' session.token = ''
session.refreshToken = ''
session.tenantId = '' session.tenantId = ''
session.apiBase = '' session.apiBase = PLATFORM_API_ROOT
if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY)
resetPickingSession() resetPickingSession()
} }
export function requireSession() { export function requireSession() {
if (session.currentUser) return true if (session.currentUser || restoreSession()) return true
uni.reLaunch({ url: '/pages/login/login' }) uni.reLaunch({ url: '/pages/login/login' })
return false return false
} }
+19
View File
@@ -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)
})
+25
View File
@@ -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\(\)/)
})
+21 -6
View File
@@ -10,7 +10,7 @@ test('page configuration starts at login and registers independent PDA routes',
assert.equal(pages.pages[0].style.navigationStyle, 'custom') assert.equal(pages.pages[0].style.navigationStyle, 'custom')
assert.deepEqual(pages.pages.map((page) => page.path), [ assert.deepEqual(pages.pages.map((page) => page.path), [
'pages/login/login', 'pages/home/home', 'pages/module/module', 'pages/operation/operation', '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)/) assert.doesNotMatch(manifest, /android\.permission\.(VIBRATE|ACCESS_NETWORK_STATE)/)
}) })
test('login is isolated and relaunches to the modular workbench', async () => { test('login uses the real auth endpoint and relaunches to the modular workbench', async () => {
const login = await read('../pages/login/login.vue') 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)) for (const text of ['柏悦物流 · PDA作业端', '工号 / 用户名', '登录中']) assert.match(login, new RegExp(text))
assert.match(login, /signIn\(account, this\.form\.warehouse\)/) 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' \}\)/) 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 () => { test('workbench, module, history and profile are independent sources', async () => {
const [home, module, history, profile] = await Promise.all([ 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') 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'\)/) 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')]) 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, /getPickingSession/)
assert.match(store, /savePickingSession/) assert.match(store, /savePickingSession/)
+17 -14
View File
@@ -55,18 +55,21 @@ test('picking page exposes five evidence slots, retry and remote completion stat
assert.match(page, /evidenceComplete/) assert.match(page, /evidenceComplete/)
}) })
test('PDA cabinet picking adapter exposes online claim and item action contracts', () => { test('PDA cabinet picking adapter claims online and submits one atomic batch', () => {
assert.match(source, /\/first-leg\/pda-loading\/tasks\?/) assert.match(source, /\/first-leg\/pda-loading\/tasks\?/)
assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/claim/) assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/claim/)
assert.match(source, /items\/\$\{encodeURIComponent\(itemId\)\}\/pick/) assert.match(source, /tasks\/\$\{encodeURIComponent\(task\.taskId\)\}\/batch/)
assert.match(source, /items\/\$\{encodeURIComponent\(itemId\)\}\/partial/) assert.match(source, /submitContainerPickingBatch/)
assert.match(source, /items\/\$\{encodeURIComponent\(itemId\)\}\/skip/) assert.match(source, /stableIdempotencyKey/)
assert.match(source, /submitContainerPickingBatch/) assert.match(source, /Math\.imul\(hash, 16777619\)/)
assert.match(source, /stableIdempotencyKey/) assert.match(source, /slice\(0, 128\)/)
assert.match(page, /claimContainerPickingTask\(/) assert.match(page, /claimContainerPickingTask\(/)
assert.match(page, /submitContainerPickingBatch\(/) assert.match(page, /submitContainerPickingBatch\(/)
assert.match(page, /任务版本已变化,请刷新后重试/) assert.match(page, /任务版本已变化,请刷新后重试/)
assert.match(source, /locationIds: action\.locationIds \|\| \[\]/) assert.match(source, /data: \{ warehouseId, containerNo: task\.containerNo, expectedVersion, items \}/)
assert.match(page, /availablePickQuantity/) assert.doesNotMatch(source.match(/export async function submitContainerPickingBatch[\s\S]*?\n\}/)?.[0] || '', /for \(/)
assert.match(page, /库存\/库位可用量不足/) assert.match(page, /version: Number\(item\.version \|\| 0\)/)
assert.match(page, /refreshTask/)
assert.match(page, /availablePickQuantity/)
assert.match(page, /库存\/库位可用量不足/)
}) })
+19
View File
@@ -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)
}
+46
View File
@@ -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)
}