316 lines
19 KiB
Vue
316 lines
19 KiB
Vue
<template>
|
||
<view v-if="user" class="page-shell portal-page pallet-page">
|
||
<PortalHeader title="托盘管理" :user="user" />
|
||
<scroll-view class="pallet-scroll" scroll-y>
|
||
<view class="portal-content pallet-content">
|
||
<view class="pallet-heading">
|
||
<text class="pallet-title">托盘管理</text>
|
||
|
||
</view>
|
||
|
||
<scroll-view class="pallet-mode-scroll" scroll-x :show-scrollbar="false">
|
||
<view class="pallet-modes">
|
||
<button v-for="item in modes" :key="item.key" class="pallet-mode" :class="{ active: mode === item.key }" @tap="selectMode(item.key)">{{ item.name }}</button>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<view class="pallet-card">
|
||
<view class="pallet-card-head"><text>{{ sourceTitle }}</text><text class="pallet-card-hint">{{ sourceHint }}</text></view>
|
||
<ScanTargetInput name="source" :model-value="sourceCode" label="来源托盘码" placeholder="扫描或人工输入托盘码" :active="activeTarget === 'source'" @activate="activateTarget" @update:modelValue="handleSourceInput" @submit="resolveSource" />
|
||
<button class="pallet-secondary" :disabled="!sourceCode || resolvingSource || submitting" @tap="resolveSource(sourceCode)">{{ resolvingSource ? '查询中…' : sourceButtonText }}</button>
|
||
</view>
|
||
|
||
<view v-if="sources.length" class="pallet-stack">
|
||
<view v-for="source in sources" :key="source.palletCode" class="pallet-card pallet-result-card">
|
||
<view class="pallet-summary-head">
|
||
<view><text class="pallet-code">{{ source.palletCode }}</text><text class="pallet-meta">{{ source.locationCode || '未绑定库位' }} · {{ statusLabel(source.status) }}</text></view>
|
||
<view class="pallet-summary-side"><text class="pallet-packages">{{ source.totalPackages }} 箱</text><button v-if="mode === 'assemble'" class="pallet-remove" @tap="removeSource(source.palletCode)">移除</button></view>
|
||
</view>
|
||
<text v-if="source.destinationWarehouseCode" class="pallet-destination">目的仓 {{ source.destinationWarehouseCode }}</text>
|
||
<view v-if="source.inventory?.length" class="inventory-list">
|
||
<view v-for="line in source.inventory" :key="String(line.positionId)" class="inventory-line">
|
||
<view class="inventory-copy"><text class="inventory-name">{{ line.productName || line.inboundCode || '库存明细' }}</text><text class="inventory-meta">{{ line.inboundCode || '无入仓码' }} · 可移动 {{ line.availablePackages }} 箱</text><text v-if="lockedPackages(line)" class="inventory-warning">另有 {{ lockedPackages(line) }} 箱处于冻结或处理中</text></view>
|
||
<view v-if="editableLines" class="inventory-count"><input type="number" :value="line.movePackages" :placeholder="`最多 ${line.availablePackages} 箱`" @input="updateMovePackages(source.palletCode, line.positionId, $event.detail.value)" /><text>箱</text></view>
|
||
</view>
|
||
</view>
|
||
<view v-else class="pallet-empty-inline">当前托盘为空</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="needsTarget" class="pallet-card">
|
||
<view class="pallet-card-head"><text>目标托盘</text><text class="pallet-card-hint">{{ targetHint }}</text></view>
|
||
<ScanTargetInput name="target" :model-value="targetCode" label="目标托盘码" placeholder="扫描或人工输入目标托盘码" :active="activeTarget === 'target'" @activate="activateTarget" @update:modelValue="handleTargetInput" @submit="resolveTarget" />
|
||
<button class="pallet-secondary" :disabled="!targetCode || resolvingTarget || submitting" @tap="resolveTarget(targetCode)">{{ resolvingTarget ? '校验中…' : '校验目标托盘' }}</button>
|
||
<view v-if="target" class="target-summary"><text class="pallet-code">{{ target.palletCode }}</text><text>{{ target.locationCode || '未绑定库位' }} · {{ statusLabel(target.status) }} · {{ target.totalPackages }} 箱</text></view>
|
||
</view>
|
||
|
||
<view v-if="mode !== 'query' && sources.length" class="pallet-card">
|
||
<text class="pallet-card-title">作业备注</text>
|
||
<textarea v-model="reason" class="pallet-reason" maxlength="500" aria-label="作业备注(选填)" />
|
||
<text class="pallet-confirm-tip">{{ confirmationTip }}</text>
|
||
<button class="pallet-primary" :disabled="!canSubmit || submitting" @tap="submitOperation">{{ submitting ? '提交中…' : actionText }}</button>
|
||
</view>
|
||
|
||
<view v-if="error" class="pallet-error" role="alert">
|
||
<text class="pallet-error-title">{{ error.message }}</text>
|
||
<text v-if="error.code">错误码:{{ error.code }}</text>
|
||
<text v-if="error.requestId">requestId:{{ error.requestId }}</text>
|
||
<text v-if="error.traceId">traceId:{{ error.traceId }}</text>
|
||
</view>
|
||
|
||
<view v-if="result" class="pallet-success" role="status">
|
||
<text class="pallet-success-title">托盘作业已完成</text>
|
||
<text>{{ operationLabel(result.operationType) }} · {{ result.replayed ? '幂等恢复结果' : '本次提交成功' }}</text>
|
||
<text v-for="item in result.pallets || []" :key="item.palletCode">{{ item.palletCode }} · {{ statusLabel(item.status) }} · {{ item.totalPackages }} 箱</text>
|
||
<button class="pallet-secondary" @tap="resetWork">继续作业</button>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import PortalHeader from '@/components/PortalHeader.vue'
|
||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||
import { assemblePallet, disassemblePallet, resolvePallet, swapPallet, unbindPallet } from '@/api/pallet.js'
|
||
import { session, requireSession } from '@/stores/session.js'
|
||
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
|
||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||
|
||
const MODES = [
|
||
{ key: 'query', name: '查询' },
|
||
{ key: 'assemble', name: '组托' },
|
||
{ key: 'disassemble', name: '拆托' },
|
||
{ key: 'swap', name: '换托' },
|
||
{ key: 'unbind', name: '解绑' }
|
||
]
|
||
|
||
function normalizedPallet(value) {
|
||
return {
|
||
...value,
|
||
palletCode: String(value?.palletCode || ''),
|
||
palletVersion: Number(value?.palletVersion || 0),
|
||
totalPackages: Number(value?.totalPackages || 0),
|
||
inventory: (value?.inventory || []).map((line) => ({
|
||
...line,
|
||
positionId: String(line.positionId),
|
||
positionVersion: String(line.positionVersion || 0),
|
||
availablePackages: Number(line.availablePackages || 0),
|
||
holdPackages: Number(line.holdPackages || 0),
|
||
allocatedPackages: Number(line.allocatedPackages || 0),
|
||
pickedPackages: Number(line.pickedPackages || 0),
|
||
receivingPackages: Number(line.receivingPackages || 0),
|
||
movePackages: Number(line.availablePackages || 0)
|
||
}))
|
||
}
|
||
}
|
||
|
||
function expectation(pallet) {
|
||
return { palletCode: pallet.palletCode, expectedVersion: pallet.palletVersion }
|
||
}
|
||
|
||
function idempotencyKey(mode) {
|
||
return `pda-pallet-${mode}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||
}
|
||
|
||
function isConflict(error) {
|
||
return String(error?.code || '') === 'A0409' || Number(error?.code) === 409
|
||
}
|
||
|
||
export default {
|
||
components: { PortalHeader, ScanTargetInput },
|
||
data() {
|
||
return {
|
||
user: session.currentUser,
|
||
modes: MODES,
|
||
mode: 'query',
|
||
activeTarget: 'source',
|
||
sourceCode: '', targetCode: '', sources: [], target: null, reason: '',
|
||
resolvingSource: false, resolvingTarget: false, submitting: false,
|
||
error: null, result: null, pendingKey: ''
|
||
}
|
||
},
|
||
computed: {
|
||
sourceTitle() { return this.mode === 'query' ? '扫描托盘查询' : this.mode === 'assemble' ? '来源托盘(可连续扫描)' : '来源托盘' },
|
||
sourceHint() { return this.mode === 'assemble' ? `已添加 ${this.sources.length} 个` : '先扫描并读取当前状态' },
|
||
sourceButtonText() { return this.mode === 'assemble' ? '添加来源托盘' : '查询托盘' },
|
||
needsTarget() { return ['assemble', 'disassemble', 'swap'].includes(this.mode) },
|
||
editableLines() { return ['assemble', 'disassemble'].includes(this.mode) },
|
||
targetHint() { return this.mode === 'swap' ? '必须为空托盘' : '需与来源托盘属于同一海外仓' },
|
||
actionText() { return ({ assemble: '确认组托', disassemble: '确认拆托', swap: '确认换托', unbind: '确认解绑' })[this.mode] || '' },
|
||
confirmationTip() {
|
||
if (this.mode === 'assemble') return '把所选箱数移动到目标托盘;不同目的仓不能合托。'
|
||
if (this.mode === 'disassemble') return '把所选箱数从来源托盘拆到目标托盘。'
|
||
if (this.mode === 'swap') return '将来源托盘全部可用库存更换到空目标托盘。'
|
||
return '解除全部库存与当前实物托盘的绑定。'
|
||
},
|
||
moveLines() {
|
||
return this.sources.flatMap((source) => source.inventory.map((line) => ({
|
||
positionId: String(line.positionId),
|
||
packages: Number(line.movePackages || 0),
|
||
expectedPositionVersion: String(line.positionVersion || 0)
|
||
}))).filter((line) => line.packages > 0)
|
||
},
|
||
canSubmit() {
|
||
if (!this.sources.length || this.submitting) return false
|
||
if (this.needsTarget && !this.target) return false
|
||
if (this.editableLines && !this.moveLines.length) return false
|
||
return true
|
||
}
|
||
},
|
||
onShow() {
|
||
if (!requireSession()) return
|
||
this.user = session.currentUser
|
||
this.startScanner()
|
||
},
|
||
onHide() { this.stopScanner() },
|
||
beforeUnmount() { this.stopScanner() },
|
||
methods: {
|
||
startScanner() {
|
||
this.stopScanner()
|
||
this._stopScanner = startScanBroadcast({
|
||
onScan: (value) => this.handleHardwareScan(value),
|
||
onError: (error) => this.setError(error, '扫码广播注册失败')
|
||
})
|
||
},
|
||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||
activateTarget(name) { this.activeTarget = name },
|
||
handleSourceInput(value) {
|
||
this.sourceCode = value
|
||
if (this.mode !== 'assemble' && this.sources[0]?.palletCode !== String(value || '').trim()) this.sources = []
|
||
this.pendingKey = ''; this.result = null
|
||
},
|
||
handleTargetInput(value) {
|
||
this.targetCode = value
|
||
if (this.target?.palletCode !== String(value || '').trim()) this.target = null
|
||
this.pendingKey = ''; this.result = null
|
||
},
|
||
handleHardwareScan(value) {
|
||
console.info('[Overseas PDA pallet] scan', { target: this.activeTarget, value })
|
||
if (this.activeTarget === 'target' && this.needsTarget) { this.targetCode = value; this.resolveTarget(value); return }
|
||
this.sourceCode = value
|
||
this.resolveSource(value)
|
||
},
|
||
selectMode(mode) { if (this.mode === mode || this.submitting) return; this.mode = mode; this.resetWork() },
|
||
resetWork() {
|
||
this.sourceCode = ''; this.targetCode = ''; this.sources = []; this.target = null; this.reason = ''
|
||
this.activeTarget = 'source'; this.error = null; this.result = null; this.pendingKey = ''
|
||
},
|
||
async resolveSource(rawValue) {
|
||
const code = String(rawValue?.detail?.value ?? rawValue ?? this.sourceCode).trim()
|
||
if (!code || this.resolvingSource || this.submitting) return
|
||
this.sourceCode = code; this.resolvingSource = true; this.error = null; this.result = null
|
||
try {
|
||
const pallet = normalizedPallet(await resolvePallet(code))
|
||
if (this.mode === 'assemble') {
|
||
if (this.sources.some((item) => item.palletCode === pallet.palletCode)) throw new Error('该来源托盘已添加')
|
||
if (this.target?.palletCode === pallet.palletCode) throw new Error('来源托盘不能与目标托盘相同')
|
||
this.sources.push(pallet); this.sourceCode = ''
|
||
} else {
|
||
if (this.target?.palletCode === pallet.palletCode) this.target = null
|
||
this.sources = [pallet]
|
||
}
|
||
this.pendingKey = ''; playSuccessSound()
|
||
} catch (error) { this.setError(error, '托盘查询失败') } finally { this.resolvingSource = false }
|
||
},
|
||
async resolveTarget(rawValue) {
|
||
const code = String(rawValue?.detail?.value ?? rawValue ?? this.targetCode).trim()
|
||
if (!code || this.resolvingTarget || this.submitting) return
|
||
this.targetCode = code; this.resolvingTarget = true; this.error = null; this.result = null
|
||
try {
|
||
const pallet = normalizedPallet(await resolvePallet(code))
|
||
if (this.sources.some((item) => item.palletCode === pallet.palletCode)) throw new Error('目标托盘不能与来源托盘相同')
|
||
this.target = pallet; this.pendingKey = ''; playSuccessSound()
|
||
} catch (error) { this.setError(error, '目标托盘校验失败') } finally { this.resolvingTarget = false }
|
||
},
|
||
removeSource(code) { this.sources = this.sources.filter((item) => item.palletCode !== code); this.pendingKey = '' },
|
||
updateMovePackages(palletCode, positionId, rawValue) {
|
||
const source = this.sources.find((item) => item.palletCode === palletCode)
|
||
const line = source?.inventory.find((item) => String(item.positionId) === String(positionId))
|
||
if (!line) return
|
||
const count = Math.max(0, Math.min(Number(line.availablePackages || 0), Math.trunc(Number(rawValue || 0))))
|
||
line.movePackages = Number.isFinite(count) ? count : 0; this.pendingKey = ''
|
||
},
|
||
lockedPackages(line) { return Number(line.holdPackages || 0) + Number(line.allocatedPackages || 0) + Number(line.pickedPackages || 0) + Number(line.receivingPackages || 0) },
|
||
async submitOperation() {
|
||
if (!this.canSubmit) return
|
||
this.submitting = true; this.error = null; this.result = null
|
||
this.pendingKey = idempotencyKey(this.mode)
|
||
try {
|
||
const source = expectation(this.sources[0])
|
||
const target = this.target ? expectation(this.target) : null
|
||
const common = { idempotencyKey: this.pendingKey, reason: this.reason.trim() || undefined }
|
||
let response
|
||
if (this.mode === 'assemble') response = await assemblePallet({ ...common, target, sources: this.sources.map(expectation), lines: this.moveLines })
|
||
else if (this.mode === 'disassemble') response = await disassemblePallet({ ...common, source, targets: [{ target, lines: this.moveLines }] })
|
||
else if (this.mode === 'swap') response = await swapPallet({ ...common, source, target })
|
||
else response = await unbindPallet({ ...common, source })
|
||
this.result = response; playSuccessSound(); this.sourceCode = ''; this.targetCode = ''; this.sources = []; this.target = null; this.reason = ''; this.pendingKey = ''
|
||
} catch (error) {
|
||
this.setError(error, '托盘作业失败')
|
||
if (isConflict(error)) {
|
||
this.error.message = `${this.error.message},请重新扫描相关托盘后再操作`
|
||
this.sources = []; this.target = null; this.pendingKey = ''; this.activeTarget = 'source'
|
||
}
|
||
} finally { this.submitting = false }
|
||
},
|
||
setError(error, fallback) {
|
||
this.error = { message: error?.message || fallback, code: error?.code, requestId: error?.requestId, traceId: error?.traceId }
|
||
console.error('[Overseas PDA pallet] failed', this.error); playErrorSound()
|
||
},
|
||
statusLabel(status) { return ({ IDLE: '空闲', OCCUPIED: '已占用', LOCKED: '已锁定', SCRAPPED: '已报废' })[status] || status || '状态未知' },
|
||
operationLabel(type) { return ({ PALLET_ASSEMBLE: '组托', PALLET_DISASSEMBLE: '拆托', PALLET_SWAP: '换托', PALLET_UNBIND: '解绑' })[type] || type || '托盘作业' }
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.pallet-page { height: 100vh; overflow: hidden; }
|
||
.pallet-scroll { height: calc(100vh - 48px - var(--status-bar-height, 0px)); }
|
||
.pallet-content { padding-bottom: 40px; }
|
||
.pallet-heading { margin: 4px 2px 12px; }
|
||
.pallet-title, .pallet-subtitle, .pallet-code, .pallet-meta, .pallet-destination, .inventory-name, .inventory-meta, .inventory-warning, .pallet-confirm-tip, .pallet-error text, .pallet-success text { display: block; }
|
||
.pallet-title { color: #14263d; font-size: 21px; font-weight: 800; }
|
||
.pallet-subtitle { margin-top: 4px; color: #708197; font-size: 11px; line-height: 17px; }
|
||
.pallet-mode-scroll { width: 100%; margin-bottom: 10px; white-space: nowrap; }
|
||
.pallet-modes { display: inline-flex; gap: 6px; min-width: 100%; }
|
||
.pallet-mode { box-sizing: border-box; min-width: 68px; height: 36px; margin: 0; padding: 0 14px; border-radius: 18px; color: #5f7085; font-size: 12px; line-height: 36px; background: #e8edf4; }
|
||
.pallet-mode.active { color: #fff; font-weight: 700; background: #135ee8; }
|
||
.pallet-stack { display: flex; flex-direction: column; gap: 10px; }
|
||
.pallet-card { box-sizing: border-box; margin-bottom: 10px; padding: 14px; border: 1px solid #dce5f0; border-radius: 13px; background: #fff; box-shadow: 0 4px 15px rgba(32,67,111,.06); }
|
||
.pallet-card-head, .pallet-summary-head, .inventory-line, .target-summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||
.pallet-card-head { margin-bottom: 10px; color: #17314f; font-size: 13px; font-weight: 800; }
|
||
.pallet-card-hint { color: #7a899d; font-size: 10px; font-weight: 500; }
|
||
.pallet-card-title { display: block; margin-bottom: 9px; color: #17314f; font-size: 13px; font-weight: 800; }
|
||
.pallet-secondary, .pallet-primary { box-sizing: border-box; width: 100%; height: 42px; margin: 9px 0 0; border-radius: 8px; font-size: 13px; line-height: 42px; }
|
||
.pallet-secondary { border: 1px solid #9dc0f4; color: #135ee8; background: #f8fbff; }
|
||
.pallet-primary { color: #fff; font-weight: 800; background: #135ee8; }
|
||
.pallet-secondary[disabled], .pallet-primary[disabled] { color: #9aa7b5; background: #e8edf3; opacity: 1; }
|
||
.pallet-confirm-tip { margin-top: 8px; color: #718095; font-size: 10px; line-height: 16px; }
|
||
.pallet-result-card { margin-bottom: 0; }
|
||
.pallet-summary-head { align-items: flex-start; }
|
||
.pallet-code { color: #142c48; font-size: 16px; font-weight: 800; }
|
||
.pallet-meta { margin-top: 3px; color: #718095; font-size: 10px; }
|
||
.pallet-summary-side { display: flex; align-items: center; gap: 8px; }
|
||
.pallet-packages { color: #135ee8; font-size: 13px; font-weight: 800; }
|
||
.pallet-remove { height: 28px; margin: 0; padding: 0 9px; border-radius: 6px; color: #c52b2b; font-size: 10px; line-height: 28px; background: #fff0f0; }
|
||
.pallet-destination { margin-top: 8px; padding: 6px 8px; border-radius: 6px; color: #7a4b00; font-size: 10px; background: #fff4dc; }
|
||
.inventory-list { margin-top: 10px; border-top: 1px solid #e5ebf2; }
|
||
.inventory-line { min-height: 58px; padding: 8px 0; border-bottom: 1px solid #edf1f5; }
|
||
.inventory-line:last-child { border-bottom: 0; }
|
||
.inventory-copy { min-width: 0; flex: 1; }
|
||
.inventory-name { overflow: hidden; color: #243951; font-size: 12px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
||
.inventory-meta { margin-top: 3px; color: #718095; font-size: 10px; }
|
||
.inventory-warning { margin-top: 3px; color: #ad6700; font-size: 9px; }
|
||
.inventory-count { display: flex; align-items: center; gap: 4px; color: #5f7085; font-size: 10px; }
|
||
.inventory-count input { box-sizing: border-box; width: 62px; height: 36px; padding: 0 8px; border: 1px solid #9dc0f4; border-radius: 7px; color: #17314f; font-size: 14px; text-align: center; background: #f8fbff; }
|
||
.pallet-empty-inline { margin-top: 10px; padding: 12px; border-radius: 7px; color: #7a899d; font-size: 11px; text-align: center; background: #f5f7fa; }
|
||
.target-summary { margin-top: 10px; padding: 9px 10px; border-radius: 8px; color: #65768b; font-size: 10px; background: #edf5ff; }
|
||
.pallet-reason { box-sizing: border-box; width: 100%; height: 70px; padding: 9px 10px; border: 1px solid #d3dde9; border-radius: 8px; color: #27384d; font-size: 12px; background: #fbfcfe; }
|
||
.pallet-error, .pallet-success { margin-bottom: 10px; padding: 12px; border-radius: 10px; font-size: 10px; line-height: 17px; }
|
||
.pallet-error { color: #9d2323; background: #fff0f0; }
|
||
.pallet-error-title, .pallet-success-title { font-size: 12px; font-weight: 800; }
|
||
.pallet-success { color: #176b42; background: #eaf8f1; }
|
||
.pallet-success .pallet-secondary { background: #fff; }
|
||
</style>
|