@@ -0,0 +1,316 @@
< 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 >
< text class = "pallet-subtitle" > 扫描实物托盘 , 按 Token 绑定的当前海外仓执行作业 < / 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 >
< text class = "pallet-scan-tip" > PDA 广播扫码与 H5 人工输入均可使用 < / text >
< / 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" @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" placeholder="选填,异常或调整原因" / >
< 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
if ( ! this . pendingKey ) 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 : 100 vh ; overflow : hidden ; }
. pallet - scroll { height : calc ( 100 vh - 48 px - var ( -- status - bar - height , 0 px ) ) ; }
. pallet - content { padding - bottom : 40 px ; }
. pallet - heading { margin : 4 px 2 px 12 px ; }
. pallet - title , . pallet - subtitle , . pallet - code , . pallet - meta , . pallet - destination , . inventory - name , . inventory - meta , . inventory - warning , . pallet - scan - tip , . pallet - confirm - tip , . pallet - error text , . pallet - success text { display : block ; }
. pallet - title { color : # 14263 d ; font - size : 21 px ; font - weight : 800 ; }
. pallet - subtitle { margin - top : 4 px ; color : # 708197 ; font - size : 11 px ; line - height : 17 px ; }
. pallet - mode - scroll { width : 100 % ; margin - bottom : 10 px ; white - space : nowrap ; }
. pallet - modes { display : inline - flex ; gap : 6 px ; min - width : 100 % ; }
. pallet - mode { box - sizing : border - box ; min - width : 68 px ; height : 36 px ; margin : 0 ; padding : 0 14 px ; border - radius : 18 px ; color : # 5 f7085 ; font - size : 12 px ; line - height : 36 px ; background : # e8edf4 ; }
. pallet - mode . active { color : # fff ; font - weight : 700 ; background : # 135 ee8 ; }
. pallet - stack { display : flex ; flex - direction : column ; gap : 10 px ; }
. pallet - card { box - sizing : border - box ; margin - bottom : 10 px ; padding : 14 px ; border : 1 px solid # dce5f0 ; border - radius : 13 px ; background : # fff ; box - shadow : 0 4 px 15 px 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 : 10 px ; }
. pallet - card - head { margin - bottom : 10 px ; color : # 17314 f ; font - size : 13 px ; font - weight : 800 ; }
. pallet - card - hint { color : # 7 a899d ; font - size : 10 px ; font - weight : 500 ; }
. pallet - card - title { display : block ; margin - bottom : 9 px ; color : # 17314 f ; font - size : 13 px ; font - weight : 800 ; }
. pallet - secondary , . pallet - primary { box - sizing : border - box ; width : 100 % ; height : 42 px ; margin : 9 px 0 0 ; border - radius : 8 px ; font - size : 13 px ; line - height : 42 px ; }
. pallet - secondary { border : 1 px solid # 9 dc0f4 ; color : # 135 ee8 ; background : # f8fbff ; }
. pallet - primary { color : # fff ; font - weight : 800 ; background : # 135 ee8 ; }
. pallet - secondary [ disabled ] , . pallet - primary [ disabled ] { color : # 9 aa7b5 ; background : # e8edf3 ; opacity : 1 ; }
. pallet - scan - tip , . pallet - confirm - tip { margin - top : 8 px ; color : # 718095 ; font - size : 10 px ; line - height : 16 px ; }
. pallet - result - card { margin - bottom : 0 ; }
. pallet - summary - head { align - items : flex - start ; }
. pallet - code { color : # 142 c48 ; font - size : 16 px ; font - weight : 800 ; }
. pallet - meta { margin - top : 3 px ; color : # 718095 ; font - size : 10 px ; }
. pallet - summary - side { display : flex ; align - items : center ; gap : 8 px ; }
. pallet - packages { color : # 135 ee8 ; font - size : 13 px ; font - weight : 800 ; }
. pallet - remove { height : 28 px ; margin : 0 ; padding : 0 9 px ; border - radius : 6 px ; color : # c52b2b ; font - size : 10 px ; line - height : 28 px ; background : # fff0f0 ; }
. pallet - destination { margin - top : 8 px ; padding : 6 px 8 px ; border - radius : 6 px ; color : # 7 a4b00 ; font - size : 10 px ; background : # fff4dc ; }
. inventory - list { margin - top : 10 px ; border - top : 1 px solid # e5ebf2 ; }
. inventory - line { min - height : 58 px ; padding : 8 px 0 ; border - bottom : 1 px solid # edf1f5 ; }
. inventory - line : last - child { border - bottom : 0 ; }
. inventory - copy { min - width : 0 ; flex : 1 ; }
. inventory - name { overflow : hidden ; color : # 243951 ; font - size : 12 px ; font - weight : 700 ; text - overflow : ellipsis ; white - space : nowrap ; }
. inventory - meta { margin - top : 3 px ; color : # 718095 ; font - size : 10 px ; }
. inventory - warning { margin - top : 3 px ; color : # ad6700 ; font - size : 9 px ; }
. inventory - count { display : flex ; align - items : center ; gap : 4 px ; color : # 5 f7085 ; font - size : 10 px ; }
. inventory - count input { box - sizing : border - box ; width : 62 px ; height : 36 px ; padding : 0 8 px ; border : 1 px solid # 9 dc0f4 ; border - radius : 7 px ; color : # 17314 f ; font - size : 14 px ; text - align : center ; background : # f8fbff ; }
. pallet - empty - inline { margin - top : 10 px ; padding : 12 px ; border - radius : 7 px ; color : # 7 a899d ; font - size : 11 px ; text - align : center ; background : # f5f7fa ; }
. target - summary { margin - top : 10 px ; padding : 9 px 10 px ; border - radius : 8 px ; color : # 65768 b ; font - size : 10 px ; background : # edf5ff ; }
. pallet - reason { box - sizing : border - box ; width : 100 % ; height : 70 px ; padding : 9 px 10 px ; border : 1 px solid # d3dde9 ; border - radius : 8 px ; color : # 27384 d ; font - size : 12 px ; background : # fbfcfe ; }
. pallet - error , . pallet - success { margin - bottom : 10 px ; padding : 12 px ; border - radius : 10 px ; font - size : 10 px ; line - height : 17 px ; }
. pallet - error { color : # 9 d2323 ; background : # fff0f0 ; }
. pallet - error - title , . pallet - success - title { font - size : 12 px ; font - weight : 800 ; }
. pallet - success { color : # 176 b42 ; background : # eaf8f1 ; }
. pallet - success . pallet - secondary { background : # fff ; }
< / style >