Files
pda-overseas-uniapp/src/pages/pallet/pallet.vue
T

316 lines
19 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="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>