Files
pda-uniapp/pages/picking/picking.vue
T
2026-07-30 18:04:37 +08:00

708 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>