708 lines
30 KiB
Vue
708 lines
30 KiB
Vue
<template>
|
||
<view v-if="currentUser" class="page-shell">
|
||
<view class="work-page" :aria-hidden="activeOverlay ? 'true' : 'false'">
|
||
<view class="app-header">
|
||
<view class="header-top">
|
||
<button class="header-back" aria-label="返回上一页" @tap="leavePicking">‹</button>
|
||
<text class="header-title">集装箱拣货</text>
|
||
<view class="header-actions">
|
||
<text class="header-badge">{{ currentUser.warehouse }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="work-content">
|
||
<view class="box-select-wrap">
|
||
<button class="box-select-btn" @tap="toggleBoxDropdown">
|
||
<text>{{ currentBox.boxNo }} · 订单 {{ currentBox.orderNo }}</text>
|
||
<text class="box-chevron" :class="{ open: boxDropdownOpen }">▼</text>
|
||
</button>
|
||
<view v-if="boxDropdownOpen" class="box-dropdown">
|
||
<view
|
||
v-for="(box, index) in boxes"
|
||
:key="box.boxNo"
|
||
class="box-option"
|
||
:class="{ active: index === currentBoxIndex, completed: completedBoxes[index] }"
|
||
@tap="requestBoxSwitch(index)"
|
||
>
|
||
<view><text class="box-option-number">{{ box.boxNo }}</text><text class="box-option-order">{{ box.orderNo }}</text></view>
|
||
<text v-if="completedBoxes[index]" class="box-option-state">已完成</text>
|
||
<text v-else-if="hasBoxDraft(index)" class="box-option-state draft">有草稿</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="scan-section">
|
||
<view class="scan-row">
|
||
<ScanTargetInput v-model="scanValue" name="sku" label="SKU 扫码框" placeholder="扫描或输入 M001" :active="activeScanField === 'sku'" @activate="setScanTarget" @submit="handleManualSubmit" />
|
||
</view>
|
||
<text class="scan-hardware-tip">支持硬件广播扫码,也可点击输入后按“完成”确认</text>
|
||
<view v-if="lastScanResult" class="last-scan" :class="lastScanResult.type" role="status">
|
||
<view class="last-scan-icon">{{ lastScanResult.type === 'success' ? '✓' : '!' }}</view>
|
||
<view class="last-scan-content">
|
||
<text class="last-scan-label">最近扫码 · {{ lastScanResult.title }}</text>
|
||
<text class="last-scan-sku">{{ lastScanResult.sku }}</text>
|
||
<text class="last-scan-detail">{{ lastScanResult.detail }}</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="scanHistory.length > 1" class="scan-history">
|
||
<text v-for="entry in scanHistory.slice(1, 3)" :key="entry.id" class="scan-history-item">{{ entry.sku }} · {{ entry.title }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="progress-section">
|
||
<view class="progress-header">
|
||
<text class="progress-title">拣货进度</text>
|
||
<view class="progress-actions">
|
||
<view class="progress-stats">
|
||
<template v-if="summary.partial > 0">
|
||
<text>已拣 </text><text class="progress-number">{{ summary.picked }}</text>
|
||
<text> · 部分拣 </text><text class="progress-number">{{ summary.partial }}</text>
|
||
<text> / {{ summary.total }} 件</text>
|
||
</template>
|
||
<template v-else>
|
||
<text>已拣 </text><text class="progress-number">{{ summary.picked }}</text>
|
||
<text> / {{ summary.total }} 件</text>
|
||
</template>
|
||
</view>
|
||
<button class="box-tasks-btn" @tap="openBoxList('pending')">箱任务 {{ completedCount }}/{{ boxes.length }}</button>
|
||
</view>
|
||
</view>
|
||
<view class="progress-bar-bg">
|
||
<view class="progress-bar-fill" :style="{ transform: 'scaleX(' + summary.progress / 100 + ')' }"></view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="pick-list">
|
||
<view v-if="orderedItems.length === 0" class="empty-state">
|
||
<text class="empty-state-icon">□</text>
|
||
<text class="empty-state-text">该箱号暂无拣货任务</text>
|
||
</view>
|
||
<view
|
||
v-for="item in orderedItems"
|
||
:key="item.id"
|
||
class="pick-item"
|
||
:class="itemState(item)"
|
||
>
|
||
<view class="pick-item-row1">
|
||
<text class="pick-item-sku">{{ item.sku }}</text>
|
||
<view class="pick-item-meta">
|
||
<text v-if="itemState(item) === ITEM_STATUS.PICKED" class="pick-status-tag tag-picked">已拣 ✓</text>
|
||
<text v-else-if="itemState(item) === ITEM_STATUS.PARTIAL" class="pick-status-tag tag-partial">
|
||
部分拣 {{ partialQuantities[item.id] || 0 }}/{{ item.qty }}
|
||
</text>
|
||
<text v-else-if="itemState(item) === ITEM_STATUS.SKIPPED" class="pick-status-tag tag-skipped">已跳拣</text>
|
||
<view class="pick-item-qty">
|
||
<text>需拣 </text><text class="qty-number">{{ item.qty }}</text><text> {{ item.unit }}</text>
|
||
<template v-if="itemState(item) === ITEM_STATUS.PARTIAL">
|
||
<text> · 实拣 </text><text class="actual-number">{{ partialQuantities[item.id] || 0 }}</text>
|
||
</template>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="pick-item-row2">
|
||
<text class="pick-item-desc">{{ itemDescription(item) }}</text>
|
||
<text class="pick-item-location">{{ item.location }}</text>
|
||
</view>
|
||
|
||
<view v-if="itemState(item) === ITEM_STATUS.PENDING" class="pick-item-actions">
|
||
<button class="pick-btn pick-btn-confirm" @tap="pickItem(item.id)">确认拣出</button>
|
||
<button class="pick-btn pick-btn-partial" @tap="openPartial(item.id)">部分拣货</button>
|
||
<button class="pick-btn pick-btn-skip" @tap="openSkip(item.id)">跳拣</button>
|
||
</view>
|
||
<view v-else-if="!completedBoxes[currentBoxIndex]" class="pick-item-actions processed-actions">
|
||
<button class="pick-btn pick-btn-undo" @tap="undoItem(item.id)">撤销</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="submit-section">
|
||
<view class="submit-info">
|
||
<view class="submit-pending"><text class="submit-pending-number">{{ summary.pending }}</text><text class="submit-pending-label"> 件待拣</text></view>
|
||
<text class="submit-status-hint">{{ submitStatusHint }}</text>
|
||
</view>
|
||
<button class="submit-btn" aria-label="提交拣货完成" :disabled="completedBoxes[currentBoxIndex] || !summary.canSubmit" @tap="submitPicking">
|
||
{{ submitButtonText }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
<uni-popup ref="boxListPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('boxList', $event)">
|
||
<view class="modal-sheet box-list-modal" role="dialog" aria-modal="true" :aria-label="boxListType === 'done' ? '已拣集装箱' : '待拣集装箱'" @tap.stop>
|
||
<PopupHeader :title="boxListType === 'done' ? '已拣集装箱' : '待拣集装箱'" @close="closeOverlay" />
|
||
<view v-if="visibleBoxList.length === 0" class="modal-empty">
|
||
{{ boxListType === 'done' ? '暂无已拣完成的集装箱' : '所有集装箱已拣完成' }}
|
||
</view>
|
||
<view
|
||
v-for="entry in visibleBoxList"
|
||
:key="entry.box.boxNo"
|
||
class="box-list-item"
|
||
:class="boxListType === 'done' ? 'box-list-item-done' : 'box-list-item-pending'"
|
||
@tap="selectBoxFromList(entry)"
|
||
>
|
||
<view class="box-list-info">
|
||
<text class="box-list-number">{{ entry.box.boxNo }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }}</text>
|
||
<text class="box-list-detail">{{ boxListDetail(entry) }}</text>
|
||
</view>
|
||
<text class="box-list-tag" :class="boxListType === 'done' ? 'box-list-tag-done' : 'box-list-tag-pending'">
|
||
{{ boxListType === 'done' ? '已完成 ✓' : (entry.index === currentBoxIndex ? '拣货中' : '待拣') }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</uni-popup>
|
||
|
||
<uni-popup ref="skipPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('skip', $event)">
|
||
<view class="modal-sheet skip-modal" role="dialog" aria-modal="true" aria-label="选择跳拣原因" @tap.stop>
|
||
<PopupHeader title="选择跳拣原因" @close="closeOverlay" />
|
||
<view class="skip-reasons">
|
||
<button
|
||
v-for="reason in skipReasons"
|
||
:key="reason"
|
||
class="skip-reason-btn"
|
||
:class="{ selected: selectedSkipReason === reason }"
|
||
@tap="selectedSkipReason = reason"
|
||
>
|
||
{{ reason }}
|
||
</button>
|
||
</view>
|
||
<view class="modal-actions">
|
||
<button class="modal-cancel-btn" @tap="closeOverlay">取消</button>
|
||
<button class="modal-confirm-btn skip-confirm-btn" @tap="confirmSkip">确认跳拣</button>
|
||
</view>
|
||
</view>
|
||
</uni-popup>
|
||
|
||
<uni-popup ref="partialPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('partial', $event)">
|
||
<view class="modal-sheet partial-modal" role="dialog" aria-modal="true" aria-label="部分拣货" @tap.stop>
|
||
<PopupHeader title="部分拣货" @close="closeOverlay" />
|
||
<view v-if="partialTargetItem" class="partial-info">
|
||
<text class="partial-info-sku">{{ partialTargetItem.sku }}</text>
|
||
<text class="partial-info-desc">{{ partialTargetItem.desc }}</text>
|
||
<view class="partial-info-qty">需拣总量:<text>{{ partialTargetItem.qty }}</text> {{ partialTargetItem.unit }}</view>
|
||
</view>
|
||
<view class="partial-input-row">
|
||
<text class="partial-input-label">实际拣出</text>
|
||
<input
|
||
v-model="partialInput"
|
||
class="partial-input"
|
||
aria-label="实际拣出数量"
|
||
type="number"
|
||
:focus="partialFocused"
|
||
confirm-type="done"
|
||
@confirm="confirmPartial"
|
||
/>
|
||
<text class="partial-input-unit">{{ partialTargetItem ? partialTargetItem.unit : '' }}</text>
|
||
</view>
|
||
<view class="partial-quick-row">
|
||
<button
|
||
v-for="option in partialQuickOptions"
|
||
:key="option.label"
|
||
class="partial-quick-btn"
|
||
@tap="setPartialInput(option.value)"
|
||
>
|
||
{{ option.label }} ({{ option.value }})
|
||
</button>
|
||
</view>
|
||
<text v-if="partialValidationMessage" class="partial-validation" role="alert">{{ partialValidationMessage }}</text>
|
||
<view class="modal-actions">
|
||
<button class="modal-cancel-btn" @tap="closeOverlay">取消</button>
|
||
<button class="modal-confirm-btn" :disabled="!partialInputValid" @tap="confirmPartial">确认部分拣货</button>
|
||
</view>
|
||
</view>
|
||
</uni-popup>
|
||
|
||
<uni-popup ref="switchBoxConfirmPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('switchBoxConfirm', $event)">
|
||
<view class="modal-sheet decision-modal" role="dialog" aria-modal="true" aria-label="保存当前拣货进度" @tap.stop>
|
||
<PopupHeader title="切换前保存当前进度" @close="closeOverlay" />
|
||
<text class="decision-desc">当前箱已处理 {{ summary.processed }} 件,还有 {{ summary.pending }} 件待拣。进度会保存为本机演示草稿。</text>
|
||
<view class="decision-summary">
|
||
<view><text class="decision-number">{{ summary.processed }}</text><text>已处理</text></view>
|
||
<view><text class="decision-number warning">{{ summary.pending }}</text><text>待拣</text></view>
|
||
</view>
|
||
<view class="modal-actions">
|
||
<button class="modal-cancel-btn" @tap="closeOverlay">继续当前箱</button>
|
||
<button class="modal-confirm-btn" @tap="confirmBoxSwitch">保存并切换</button>
|
||
</view>
|
||
</view>
|
||
</uni-popup>
|
||
|
||
<uni-popup ref="submitConfirmPopup" type="bottom" background-color="#ffffff" border-radius="16px 16px 0 0" :is-mask-click="true" @change="handleOverlayChange('submitConfirm', $event)">
|
||
<view class="modal-sheet decision-modal submit-decision-modal" role="dialog" aria-modal="true" aria-label="确认未完成提交" @tap.stop>
|
||
<PopupHeader :title="`仍有 ${summary.pending} 件未完成`" tone="danger" @close="closeOverlay" />
|
||
<text class="decision-desc submit-decision-desc">确认提交后,以下待拣商品会被明确标记为“未拣出”,本次演示提交不可撤销。</text>
|
||
<view class="pending-sku-list">
|
||
<text v-for="item in pendingItems.slice(0, 3)" :key="item.id">{{ item.sku }} · {{ item.location }}</text>
|
||
<text v-if="pendingItems.length > 3">另有 {{ pendingItems.length - 3 }} 件</text>
|
||
</view>
|
||
<text class="decision-label">选择未完成原因</text>
|
||
<view class="incomplete-reasons">
|
||
<button v-for="reason in incompleteReasons" :key="reason" class="incomplete-reason" :class="{ selected: selectedIncompleteReason === reason }" @tap="selectedIncompleteReason = reason">{{ reason }}</button>
|
||
</view>
|
||
<view class="modal-actions submit-decision-actions">
|
||
<button class="modal-cancel-btn" @tap="closeOverlay">继续拣货</button>
|
||
<button class="modal-confirm-btn danger-confirm" :disabled="!selectedIncompleteReason" @tap="confirmIncompleteSubmit">确认未拣并提交</button>
|
||
</view>
|
||
</view>
|
||
</uni-popup>
|
||
|
||
<uni-popup ref="completePopup" type="center" :is-mask-click="true" @change="handleOverlayChange('complete', $event)">
|
||
<view class="complete-modal" role="dialog" aria-modal="true" aria-label="拣货完成" @tap.stop>
|
||
<PopupHeader title="拣货完成" @close="closeOverlay" />
|
||
<view class="complete-icon">✓</view>
|
||
<text class="complete-desc">箱号 {{ currentBox.boxNo }} · 订单 {{ currentBox.orderNo }}</text>
|
||
<view class="complete-stats">
|
||
<view class="complete-stat">
|
||
<text class="complete-stat-num green">{{ completionSummary.picked }}</text>
|
||
<text class="complete-stat-label">已拣出</text>
|
||
</view>
|
||
<view class="complete-stat">
|
||
<text class="complete-stat-num blue">{{ completionSummary.partial }}</text>
|
||
<text class="complete-stat-label">部分拣货</text>
|
||
</view>
|
||
<view class="complete-stat">
|
||
<text class="complete-stat-num gray">{{ completionSummary.skipped }}</text>
|
||
<text class="complete-stat-label">跳拣/未拣</text>
|
||
</view>
|
||
<view v-if="completionSummary.autoSkipped > 0" class="complete-stat">
|
||
<text class="complete-stat-num red">{{ completionSummary.autoSkipped }}</text>
|
||
<text class="complete-stat-label">自动未拣</text>
|
||
</view>
|
||
</view>
|
||
<view class="complete-btns">
|
||
<button class="complete-btn-back" @tap="closeOverlay">返回查看</button>
|
||
<button class="complete-btn-next" @tap="nextBox">{{ nextBoxButtonText }}</button>
|
||
</view>
|
||
</view>
|
||
</uni-popup>
|
||
|
||
<uni-popup ref="allCompletePopup" type="center" :is-mask-click="true" @change="handleOverlayChange('allComplete', $event)">
|
||
<view class="complete-modal" role="dialog" aria-modal="true" aria-label="全部箱号已完成" @tap.stop>
|
||
<PopupHeader title="全部箱号已完成" @close="closeOverlay" />
|
||
<view class="complete-icon">✓</view>
|
||
<text class="complete-desc">本班次演示任务 {{ boxes.length }} 箱已全部处理</text>
|
||
<button class="all-complete-btn" @tap="leavePicking">返回工作台</button>
|
||
</view>
|
||
</uni-popup>
|
||
|
||
<view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { ITEM_STATUS, classifyScan, getPickSummary, submitStates } from '@/utils/picking-state.js'
|
||
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
|
||
import { session, requireSession } from '@/stores/session.js'
|
||
import { getPickingSession, savePickingSession } from '@/stores/picking.js'
|
||
import { navigateBackOr } from '@/utils/navigation.js'
|
||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||
import PopupHeader from '@/components/PopupHeader.vue'
|
||
|
||
export default {
|
||
components: { PopupHeader, ScanTargetInput },
|
||
data() {
|
||
return {
|
||
ITEM_STATUS,
|
||
currentUser: session.currentUser,
|
||
skipReasons: SKIP_REASONS,
|
||
incompleteReasons: INCOMPLETE_REASONS,
|
||
...getPickingSession(),
|
||
activeScanField: 'sku',
|
||
partialFocused: false,
|
||
boxDropdownOpen: false,
|
||
activeOverlay: '',
|
||
boxListType: 'pending',
|
||
skipTargetId: null,
|
||
selectedSkipReason: '',
|
||
partialTargetId: null,
|
||
partialInput: '',
|
||
switchTargetIndex: null,
|
||
selectedIncompleteReason: '',
|
||
completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 },
|
||
toast: { visible: false, message: '', type: '' }
|
||
}
|
||
},
|
||
computed: {
|
||
currentBox() {
|
||
return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', items: [] }
|
||
},
|
||
summary() {
|
||
return getPickSummary(this.currentBox.items, this.itemStates)
|
||
},
|
||
orderedItems() {
|
||
const pending = []
|
||
const processed = []
|
||
this.currentBox.items.forEach((item) => {
|
||
if (this.itemState(item) === ITEM_STATUS.PENDING) pending.push(item)
|
||
else processed.push(item)
|
||
})
|
||
return pending.concat(processed)
|
||
},
|
||
pendingItems() {
|
||
return this.currentBox.items.filter((item) => this.itemState(item) === ITEM_STATUS.PENDING)
|
||
},
|
||
lastScanResult() {
|
||
return this.scanHistory[0] || null
|
||
},
|
||
completedCount() {
|
||
return Object.keys(this.completedBoxes).filter((key) => this.completedBoxes[key]).length
|
||
},
|
||
pendingBoxCount() {
|
||
return this.boxes.length - this.completedCount
|
||
},
|
||
partialTargetItem() {
|
||
return this.currentBox.items.find((item) => item.id === this.partialTargetId) || null
|
||
},
|
||
partialQuickOptions() {
|
||
if (!this.partialTargetItem) return []
|
||
const quantity = this.partialTargetItem.qty
|
||
return [
|
||
{ label: '1/4', value: Math.floor(quantity * 0.25) },
|
||
{ label: '1/2', value: Math.floor(quantity * 0.5) },
|
||
{ label: '3/4', value: Math.floor(quantity * 0.75) },
|
||
{ label: '差1', value: Math.max(1, quantity - 1) }
|
||
]
|
||
},
|
||
partialInputValid() {
|
||
if (!this.partialTargetItem) return false
|
||
const value = parseInt(this.partialInput, 10)
|
||
return Number.isInteger(value) && value > 0 && value < this.partialTargetItem.qty
|
||
},
|
||
partialValidationMessage() {
|
||
if (!this.partialInput || !this.partialTargetItem) return ''
|
||
const value = parseInt(this.partialInput, 10)
|
||
if (!Number.isInteger(value) || value <= 0) return '请输入大于 0 的整数'
|
||
if (value >= this.partialTargetItem.qty) return `部分拣货必须少于 ${this.partialTargetItem.qty},整单请返回后点击“确认拣出”`
|
||
return ''
|
||
},
|
||
submitButtonText() {
|
||
return this.completedBoxes[this.currentBoxIndex] ? '该箱已完成' : this.summary.buttonText
|
||
},
|
||
submitStatusHint() {
|
||
if (this.completedBoxes[this.currentBoxIndex]) return '本箱任务已完成'
|
||
if (!this.summary.canSubmit) return '请先处理至少一件'
|
||
if (this.summary.pending > 0) return '提交时需选择原因'
|
||
return `已处理 ${this.summary.processed}/${this.summary.total}`
|
||
},
|
||
nextBoxButtonText() {
|
||
return this.completedCount === this.boxes.length ? '查看全部完成' : '下一箱'
|
||
},
|
||
visibleBoxList() {
|
||
return this.boxes
|
||
.map((box, index) => ({ box, index }))
|
||
.filter((entry) => this.boxListType === 'done'
|
||
? Boolean(this.completedBoxes[entry.index])
|
||
: !this.completedBoxes[entry.index])
|
||
}
|
||
},
|
||
onLoad() { requireSession() },
|
||
onShow() {
|
||
if (!requireSession()) return
|
||
this.startHardwareScanner()
|
||
},
|
||
onHide() { this.stopHardwareScanner() },
|
||
onUnload() { this.stopHardwareScanner(); this.persistSession(); this.clearTimers() },
|
||
beforeUnmount() { this.stopHardwareScanner(); this.clearTimers() },
|
||
onBackPress() {
|
||
if (this.activeOverlay) { this.closeOverlay(); return true }
|
||
if (this.boxDropdownOpen) { this.boxDropdownOpen = false; return true }
|
||
this.persistSession()
|
||
return false
|
||
},
|
||
methods: {
|
||
handleOverlayChange(name, event) {
|
||
if (!event.show && this.activeOverlay === name) this.resetOverlayState()
|
||
},
|
||
startHardwareScanner() {
|
||
this.stopHardwareScanner()
|
||
this._stopScanBroadcast = startScanBroadcast({
|
||
onScan: (value) => this.handleHardwareScan(value),
|
||
onError: () => this.showToast('硬件扫码接收器启动失败,请检查扫描服务', 'warning')
|
||
})
|
||
},
|
||
stopHardwareScanner() {
|
||
if (typeof this._stopScanBroadcast !== 'function') return
|
||
this._stopScanBroadcast()
|
||
this._stopScanBroadcast = null
|
||
},
|
||
handleHardwareScan(value) {
|
||
if (this.activeOverlay || this.boxDropdownOpen) return
|
||
if (this.activeScanField !== 'sku') return
|
||
this.scanValue = value
|
||
this.processScannedValue(value, 'broadcast')
|
||
},
|
||
handleManualSubmit(value) {
|
||
if (this.activeOverlay || this.boxDropdownOpen) return
|
||
const normalizedValue = String(value || '').trim().toUpperCase()
|
||
this.scanValue = normalizedValue
|
||
this.processScannedValue(normalizedValue, 'manual')
|
||
},
|
||
setScanTarget(fieldName) { this.activeScanField = fieldName },
|
||
openOverlay(name) {
|
||
this.activeOverlay = name
|
||
this.$nextTick(() => {
|
||
const popup = this.$refs[`${name}Popup`]
|
||
if (popup) popup.open()
|
||
})
|
||
},
|
||
persistSession() {
|
||
this.saveCurrentBoxDraft()
|
||
savePickingSession({ boxes: this.boxes, currentBoxIndex: this.currentBoxIndex, itemStates: this.itemStates, partialQuantities: this.partialQuantities, boxDrafts: this.boxDrafts, completedBoxes: this.completedBoxes, scanValue: this.scanValue, scanHistory: this.scanHistory })
|
||
},
|
||
leavePicking() { this.persistSession(); this.activeOverlay = ''; navigateBackOr('/pages/home/home') },
|
||
clearTimers() { clearTimeout(this._toastTimer) },
|
||
scrollToTop() { this.$nextTick(() => { if (typeof uni !== 'undefined' && typeof uni.pageScrollTo === 'function') uni.pageScrollTo({ scrollTop: 0, duration: 0 }) }) },
|
||
saveCurrentBoxDraft() {
|
||
if (!this.currentBox.boxNo) return
|
||
this.boxDrafts = {
|
||
...this.boxDrafts,
|
||
[this.currentBoxIndex]: {
|
||
itemStates: { ...this.itemStates },
|
||
partialQuantities: { ...this.partialQuantities },
|
||
scanHistory: this.scanHistory.map((entry) => ({ ...entry }))
|
||
}
|
||
}
|
||
},
|
||
loadBoxState(index) {
|
||
const draft = this.boxDrafts[index]
|
||
if (draft) {
|
||
this.itemStates = { ...draft.itemStates }
|
||
this.partialQuantities = { ...draft.partialQuantities }
|
||
this.scanHistory = draft.scanHistory.map((entry) => ({ ...entry }))
|
||
} else {
|
||
this.itemStates = createItemStates(this.currentBox.items)
|
||
this.partialQuantities = {}
|
||
this.scanHistory = []
|
||
}
|
||
this.scanValue = ''
|
||
},
|
||
toggleBoxDropdown() {
|
||
this.boxDropdownOpen = !this.boxDropdownOpen
|
||
},
|
||
requestBoxSwitch(index) {
|
||
if (index === this.currentBoxIndex) {
|
||
this.boxDropdownOpen = false
|
||
return
|
||
}
|
||
this.boxDropdownOpen = false
|
||
if (!this.completedBoxes[this.currentBoxIndex] && this.summary.processed > 0 && this.summary.pending > 0) {
|
||
this.switchTargetIndex = index
|
||
this.openOverlay('switchBoxConfirm')
|
||
return
|
||
}
|
||
this.selectBox(index)
|
||
},
|
||
confirmBoxSwitch() {
|
||
if (this.switchTargetIndex === null) return
|
||
const targetIndex = this.switchTargetIndex
|
||
this.closeOverlay(false)
|
||
this.selectBox(targetIndex)
|
||
},
|
||
selectBox(index) {
|
||
this.saveCurrentBoxDraft()
|
||
this.currentBoxIndex = index
|
||
this.boxDropdownOpen = false
|
||
this.loadBoxState(index)
|
||
this.scrollToTop()
|
||
this.showToast(`切换至 ${this.currentBox.boxNo}`, 'success')
|
||
},
|
||
itemState(item) {
|
||
return this.itemStates[item.id] || ITEM_STATUS.PENDING
|
||
},
|
||
hasBoxDraft(index) {
|
||
const draft = this.boxDrafts[index]
|
||
return Boolean(draft && Object.values(draft.itemStates).some((status) => status !== ITEM_STATUS.PENDING))
|
||
},
|
||
itemDescription(item) {
|
||
const reason = this.itemState(item) === ITEM_STATUS.SKIPPED && item.skipReason
|
||
? ` · ${item.skipReason}`
|
||
: ''
|
||
return `${item.desc}${reason}`
|
||
},
|
||
processScannedValue(scanValue, source = 'broadcast') {
|
||
const value = scanValue.trim()
|
||
if (!value) {
|
||
const manual = source === 'manual'
|
||
playErrorSound()
|
||
this.recordScanResult('error', manual ? '未输入商品编码' : '未读取到条码', manual ? '输入内容为空' : '广播值为空', manual ? '请输入 SKU 后按“完成”' : '请重新扫描并检查设备广播配置')
|
||
this.showToast(manual ? '请输入商品编码' : '广播扫码结果为空', 'warning')
|
||
return
|
||
}
|
||
const result = classifyScan(this.currentBox.items, this.itemStates, value)
|
||
if (result.type === 'processed') {
|
||
playErrorSound()
|
||
const labels = {
|
||
[ITEM_STATUS.PICKED]: '已拣出',
|
||
[ITEM_STATUS.PARTIAL]: '已部分拣货',
|
||
[ITEM_STATUS.SKIPPED]: '已跳拣'
|
||
}
|
||
this.recordScanResult('warning', '重复扫描', value, `该 SKU ${labels[result.status]},无需重复处理`)
|
||
this.showToast(`该SKU${labels[result.status]}:${value}`, 'warning')
|
||
return
|
||
}
|
||
if (result.type === 'unknown') {
|
||
playErrorSound()
|
||
this.recordScanResult('error', '当前箱无此商品', value, '请核对箱号、SKU 或转入异常处理')
|
||
this.showToast(`未找到匹配的待拣SKU:${value}`, 'error')
|
||
return
|
||
}
|
||
playSuccessSound()
|
||
this.pickItem(result.item.id, 'scan')
|
||
},
|
||
pickItem(id, source = 'manual') {
|
||
const item = this.currentBox.items.find((entry) => entry.id === id)
|
||
this.itemStates = { ...this.itemStates, [id]: ITEM_STATUS.PICKED }
|
||
if (source === 'scan' && item) {
|
||
this.recordScanResult('success', '扫描成功', item.sku, `${item.location} · 已拣 ${item.qty} ${item.unit}`)
|
||
}
|
||
this.showToast(item ? `已拣出 ${item.sku}` : '已确认拣出', 'success')
|
||
},
|
||
recordScanResult(type, title, sku, detail) {
|
||
const entry = { id: `${Date.now()}-${this.scanHistory.length}-${sku}`, type, title, sku, detail }
|
||
this.scanHistory = [entry, ...this.scanHistory].slice(0, 3)
|
||
},
|
||
undoItem(id) {
|
||
const item = this.currentBox.items.find((entry) => entry.id === id)
|
||
if (item) item.skipReason = ''
|
||
this.itemStates = { ...this.itemStates, [id]: ITEM_STATUS.PENDING }
|
||
const nextPartialQuantities = { ...this.partialQuantities }
|
||
delete nextPartialQuantities[id]
|
||
this.partialQuantities = nextPartialQuantities
|
||
this.showToast('已撤销操作', 'success')
|
||
},
|
||
openSkip(id) {
|
||
this.skipTargetId = id
|
||
this.selectedSkipReason = ''
|
||
this.openOverlay('skip')
|
||
},
|
||
confirmSkip() {
|
||
if (!this.selectedSkipReason) {
|
||
this.showToast('请先选择跳拣原因', 'warning')
|
||
return
|
||
}
|
||
const item = this.currentBox.items.find((entry) => entry.id === this.skipTargetId)
|
||
if (item) item.skipReason = this.selectedSkipReason
|
||
this.itemStates = { ...this.itemStates, [this.skipTargetId]: ITEM_STATUS.SKIPPED }
|
||
const reason = this.selectedSkipReason
|
||
this.closeOverlay(false)
|
||
this.showToast(`已标记跳拣:${reason}`, 'warning')
|
||
},
|
||
openPartial(id) {
|
||
this.partialTargetId = id
|
||
this.partialInput = ''
|
||
this.openOverlay('partial')
|
||
this.$nextTick(() => {
|
||
this.partialFocused = true
|
||
})
|
||
},
|
||
setPartialInput(value) {
|
||
this.partialInput = String(value)
|
||
this.partialFocused = false
|
||
this.$nextTick(() => {
|
||
this.partialFocused = true
|
||
})
|
||
},
|
||
confirmPartial() {
|
||
const item = this.partialTargetItem
|
||
if (!item) return
|
||
if (!this.partialInputValid) return
|
||
const value = parseInt(this.partialInput, 10)
|
||
this.itemStates = { ...this.itemStates, [item.id]: ITEM_STATUS.PARTIAL }
|
||
this.partialQuantities = { ...this.partialQuantities, [item.id]: value }
|
||
this.closeOverlay(false)
|
||
this.showToast(`已部分拣货:${value}/${item.qty} ${item.unit}`, 'success')
|
||
},
|
||
openBoxList(type) {
|
||
this.boxListType = type
|
||
this.boxDropdownOpen = false
|
||
this.openOverlay('boxList')
|
||
},
|
||
boxListDetail(entry) {
|
||
if (this.boxListType !== 'done') {
|
||
const draft = this.boxDrafts[entry.index]
|
||
const processed = draft
|
||
? Object.values(draft.itemStates).filter((status) => status !== ITEM_STATUS.PENDING).length
|
||
: 0
|
||
return `${entry.box.orderNo} · 已处理${processed}件 · 待拣${entry.box.items.length - processed}件`
|
||
}
|
||
const picked = entry.box.items.filter((item) => item.finalState === ITEM_STATUS.PICKED).length
|
||
const partial = entry.box.items.filter((item) => item.finalState === ITEM_STATUS.PARTIAL).length
|
||
return `${entry.box.orderNo} · 拣出${picked}件${partial ? ` · 部分拣${partial}件` : ''}`
|
||
},
|
||
selectBoxFromList(entry) {
|
||
if (this.boxListType === 'done' || entry.index === this.currentBoxIndex) return
|
||
this.closeOverlay(false)
|
||
this.requestBoxSwitch(entry.index)
|
||
},
|
||
submitPicking() {
|
||
if (!this.summary.canSubmit || this.completedBoxes[this.currentBoxIndex]) return
|
||
if (this.summary.pending > 0) {
|
||
this.selectedIncompleteReason = ''
|
||
this.openOverlay('submitConfirm')
|
||
return
|
||
}
|
||
this.performSubmit()
|
||
},
|
||
confirmIncompleteSubmit() {
|
||
if (!this.selectedIncompleteReason) return
|
||
this.performSubmit(this.selectedIncompleteReason)
|
||
},
|
||
performSubmit(incompleteReason = '') {
|
||
const beforeSubmit = this.summary
|
||
const result = submitStates(this.currentBox.items, this.itemStates)
|
||
this.itemStates = result.states
|
||
this.currentBox.items.forEach((item) => {
|
||
if (result.autoSkippedIds.includes(item.id)) {
|
||
item.skipReason = `${incompleteReason}(提交时确认未拣)`
|
||
}
|
||
item.finalState = this.itemState(item)
|
||
item.finalPartialQuantity = this.partialQuantities[item.id] || 0
|
||
})
|
||
this.completedBoxes = { ...this.completedBoxes, [this.currentBoxIndex]: true }
|
||
this.saveCurrentBoxDraft()
|
||
const afterSubmit = getPickSummary(this.currentBox.items, this.itemStates)
|
||
this.completionSummary = {
|
||
picked: afterSubmit.picked,
|
||
partial: afterSubmit.partial,
|
||
skipped: afterSubmit.skipped,
|
||
autoSkipped: beforeSubmit.pending
|
||
}
|
||
this.openOverlay('complete')
|
||
},
|
||
nextBox() {
|
||
this.closeOverlay(false)
|
||
const nextIndex = this.boxes.findIndex((box, index) => !this.completedBoxes[index])
|
||
if (nextIndex < 0) {
|
||
this.openOverlay('allComplete')
|
||
return
|
||
}
|
||
this.selectBox(nextIndex)
|
||
this.showToast('切换至下一箱', 'success')
|
||
},
|
||
resetOverlayState() {
|
||
this.activeOverlay = ''
|
||
this.skipTargetId = null
|
||
this.selectedSkipReason = ''
|
||
this.partialTargetId = null
|
||
this.partialInput = ''
|
||
this.switchTargetIndex = null
|
||
this.selectedIncompleteReason = ''
|
||
this.partialFocused = false
|
||
},
|
||
closeOverlay() {
|
||
const popup = this.$refs[`${this.activeOverlay}Popup`]
|
||
if (popup) popup.close()
|
||
this.resetOverlayState()
|
||
},
|
||
showToast(message, type = '') {
|
||
clearTimeout(this._toastTimer)
|
||
this.toast = { visible: true, message, type }
|
||
this._toastTimer = setTimeout(() => {
|
||
this.toast.visible = false
|
||
}, 2200)
|
||
}
|
||
}
|
||
}
|
||
</script>
|