From 493fa7c2e9ce26a50badfb4ff8c09bce4e92655b Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Tue, 27 Oct 2026 12:09:25 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/pages/dispatch/dispatch.vue | 33 +++++++++++++++++++++++-----
tests/dispatch-workflow.test.mjs | 37 ++++++++++++++++++++++++++++++++
2 files changed, 65 insertions(+), 5 deletions(-)
diff --git a/src/pages/dispatch/dispatch.vue b/src/pages/dispatch/dispatch.vue
index a15651c..a913850 100644
--- a/src/pages/dispatch/dispatch.vue
+++ b/src/pages/dispatch/dispatch.vue
@@ -81,6 +81,15 @@
{{ successMessage }}
+
+
+ 托盘扫描成功
+ {{ scanNotice.palletNo }}
+ 本次可拣箱数
+ {{ scanNotice.packages }} 箱
+ 3 秒后自动关闭
+
+
确认装车
@@ -103,7 +112,7 @@ import { toDispatchError } from '@/utils/dispatch-error.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
export default {
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus, DispatchLoadingDocuments },
- data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', loading: false, requestBusy: false, photoBusy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
+ data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', scanNotice: null, loading: false, requestBusy: false, photoBusy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
computed: {
busy() { return this.requestBusy || this.photoBusy },
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, packages() { return fulfillment.dispatch.packages },
@@ -121,9 +130,15 @@ export default {
},
onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo); else this.loadTasks() } else if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo); else this.loadTasks() },
- onHide() { this.stopScanner(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.generation++ },
+ onHide() { this.stopScanner(); this.clearScanNotice(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.clearScanNotice(); this.generation++ },
methods: {
- selectTab(tab) { if (this.busy || this.loading || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.activeTab = tab },
+ clearScanNotice() { clearTimeout(this._scanNoticeTimer); this._scanNoticeTimer = null; this.scanNotice = null },
+ showScanNotice(item) {
+ this.clearScanNotice()
+ this.scanNotice = { palletNo: item.palletNo, packages: this.selectedRemaining }
+ this._scanNoticeTimer = setTimeout(() => this.clearScanNotice(), 3000)
+ },
+ selectTab(tab) { if (this.busy || this.loading || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.clearScanNotice(); this.activeTab = tab },
gate(action) { return this.current?.actions?.[action]?.allowed === true },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => this.handleScan(value), onError: error => this.setError(error, '扫码广播注册失败') }) }, stopScanner() { this._stopScanner?.(); this._stopScanner = null },
updatePackages(event) { const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) },
@@ -133,6 +148,7 @@ export default {
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim()
if (!code || this.busy || this.loading || this.activeTab !== 'picking') return
+ this.clearScanNotice()
this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = ''
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 })
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
@@ -151,6 +167,7 @@ export default {
this.selectPallet(item)
if (fulfillment.dispatch.dispatchItemId !== item.dispatchItemId) return
if (cartonCode) updateFulfillmentDraft('dispatch', { cartonCode, packages: 1 })
+ else this.showScanNotice(item)
this.scanCode = ''; playSuccessSound()
},
pickablePackages(item) {
@@ -206,8 +223,8 @@ export default {
})
},
async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
- async run(work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } },
- setError(cause, fallback) { this.error = toDispatchError(cause, fallback); playErrorSound() },
+ async run(work, loading = false) { if (this.busy || this.loading) return; this.clearScanNotice(); this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } },
+ setError(cause, fallback) { this.clearScanNotice(); this.error = toDispatchError(cause, fallback); playErrorSound() },
stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', AFTER_LOADING: '装车后', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
reviewTime(value) { if (!value) return '未填写'; const date = new Date(new Date(value).getTime() + 8 * 60 * 60 * 1000); return Number.isNaN(date.getTime()) ? '时间无效' : date.toISOString().slice(0, 16).replace('T', ' ') },
reviewLabel(review) { return ({ NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' })[review?.status] || '待核对' },
@@ -273,5 +290,11 @@ export default {
.loading-cancel { color: #52657d; background: #f0f3f8; }
.loading-confirm { color: #fff; background: #1764ec; }
.loading-dialog-actions button[disabled] { opacity: .5; }
+.scan-notice-overlay { pointer-events: none; }
+.scan-notice-dialog { text-align: center; }
+.scan-notice-pallet { display: block; margin-top: 20rpx; color: #243c58; font-size: 30rpx; overflow-wrap: anywhere; }
+.scan-notice-label { display: block; margin-top: 28rpx; color: #536982; font-size: 28rpx; }
+.scan-notice-count { display: block; margin-top: 12rpx; color: #1764ec; font-size: 72rpx; font-weight: 700; line-height: 1.3; }
+.scan-notice-hint { display: block; margin-top: 24rpx; color: #72839b; font-size: 24rpx; }
diff --git a/tests/dispatch-workflow.test.mjs b/tests/dispatch-workflow.test.mjs
index acb6b73..76482e8 100644
--- a/tests/dispatch-workflow.test.mjs
+++ b/tests/dispatch-workflow.test.mjs
@@ -43,6 +43,43 @@ function page(dependencies = {}) {
}
const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' : 'LOADING', result: { confirmed: true } })
+test('pallet scan notice reports the current pickable quantity and closes after three seconds without submitting', t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ storage(); let writes = 0, sounds = 0
+ const state = page({ pickDispatchItem: async () => { writes++ }, playSuccessSound: () => { sounds++ } })
+ state.current = task({ items: [item({ scheduledPackages: 12, pickedPackages: 4, positions: [{ positionId: 'position-1', availablePackages: 6, allocatedPackages: 0, pickedPackages: 0 }] })] })
+ state.handleScan('PALLET-1')
+ assert.deepEqual(state.scanNotice, { palletNo: 'PALLET-1', packages: 6 })
+ assert.equal(sounds, 1)
+ t.mock.timers.tick(2999); assert.ok(state.scanNotice)
+ t.mock.timers.tick(1); assert.equal(state.scanNotice, null)
+ assert.equal(writes, 0); assert.equal(state.packages, 6)
+})
+
+test('consecutive pallet scans replace the notice and restart its full display time', t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ storage(); const state = page()
+ state.current = task({ items: [item(), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', palletNo: 'PALLET-2', scheduledPackages: 1 })] })
+ state.handleScan('PALLET-1'); t.mock.timers.tick(2000)
+ state.handleScan('PALLET-2')
+ assert.deepEqual(state.scanNotice, { palletNo: 'PALLET-2', packages: 1 })
+ t.mock.timers.tick(1000); assert.ok(state.scanNotice)
+ t.mock.timers.tick(2000); assert.equal(state.scanNotice, null)
+})
+
+test('scan errors, carton scans and switching tabs cannot retain a stale pallet notice', t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ storage(); const state = page()
+ state.handleScan('PALLET-1'); state.handleScan('UNKNOWN')
+ assert.equal(state.scanNotice, null); assert.ok(state.error)
+ state.handleScan('PALLET-1'); state.selectTab('photos')
+ assert.equal(state.scanNotice, null)
+ state.selectTab('picking'); state.handleScan('PALLET-1')
+ state.current = task({ items: [item({ returnCartons: [{ returnCartonId: 'rc-1', cartonMark: 'RC-1', status: 'OUTBOUND_PENDING', productQuantity: 5 }] })] })
+ state.handleScan('RC-1'); assert.equal(state.scanNotice, null)
+ t.mock.timers.tick(3000); assert.equal(state.scanNotice, null)
+})
+
test('real picking requires the native gate, physical source and current stage; empty or merely counted tasks cannot complete loading', async () => {
storage(); const state = page(); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
for (const allowed of [false, 'true', undefined]) { state.current.actions.PICK.allowed = allowed; assert.equal(state.canPick, false) }