@@ -12,17 +12,30 @@
< view v-if = "candidateError" class="error-card candidate-error-card"><text class="error-message">{{ candidateError.message || '待卸货集装箱加载失败' }}</text><text v-if="candidateError.code">code={{ candidateError.code }}</text><text v-if="candidateError.requestId">requestId={{ candidateError.requestId }}</text><text v-if="candidateError.traceId">traceId={{ candidateError.traceId }}</text><button class="secondary-button" :disabled="candidateLoading" @tap="loadCandidates" > 重新加载 < / button > < / view >
< view v-if = "candidateLoading && !candidates.length" class="state-card"><uni-load-more status="loading" / > < text > 正在刷新待卸货集装箱 … < / text > < / view >
< view v-else-if = "!candidateError && !candidates.length" class="empty-card"><uni-icons type="info" color="#8090a3" size="34" /><text>暂无待卸货集装箱</text><button class="secondary-button" :disabled="candidateLoading" @tap="loadCandidates" > 重新加载 < / button > < / view >
< view v-for = "item in candidates" :key="item.containerNo" class="container-card container-card-compact" role="button" :aria-label="`${item.containerNo}, ${item.currentAccount ? '继续当前柜 ' : '查看详情 '}`" hover-class="container-card-pressed" @tap="openContainer(item.containerNo )" >
< view class = "card-head" > < text class = "container-no" > { { item . containerNo } } < / text > < view class = "container-card-actions" > < text class = "status-tag" :class = " statusTone(item.status)"> { { statusLabel ( item . status ) } } < / text > < text class = "container-detail-link" > { { item . currentAccount ? '继续' : '详情' } } › < / text > < / view > < / view >
< view class = "candidate-facts" > < view > < strong > { { item . inboundCodeCount } } < / strong > < text > 入仓码 < / text > < / view > < view > < strong > { { item . totalPackages } } < / strong > < text > 箱 < / text > < / view > < view > < strong > { { item . plannedPalletCount } } < / strong > < text > 计划托盘 < / text > < / view > < view > < strong > { { item . destinationCodes ? . length || 0 } } < / strong > < text > 目的仓 < / text > < / view > < / view >
< view v-for = "item in candidates" :key="item.containerNo" class="container-card container-card-compact" :class="{ 'container-card-disabled': !item.operable }" role="button" :aria-label="`${item.containerNo}, ${item.operable ? '查看可操作柜详情 ' : '查看未齐全柜摘要 '}`" hover-class="container-card-pressed" @tap="openContainer(item)" >
< view class = "card-head" > < view > < text class = "container-no" > { { item . containerNo } } < / text > < text class = "candidate-arrival" > 计划到仓 : { { formatPlannedArrival ( item . plannedArrivalAt ) } } < / text > < / view > < view class = "container-card-actions" > < text class = "status-tag" : class = "item.operable ? statusTone(item.status) : 'status-disabled' "> { { item . operable ? statusLabel ( item . status ) : '待齐全' } } < / text > < text class = "container-detail-link" > { { item . operable ? ( item . currentAccount ? '继续' : '详情' ) : '只读' } } › < / text > < / view > < / view >
< view class = "candidate-facts" > < view > < strong > { { item . totalOrderCount } } < / strong > < text > 总订单 < / text > < / view > < view > < strong > { { item . inboundCodeCount } } < / strong > < text > 入仓码 < / text > < / view > < view > < strong > { { item . planCount } } < / strong > < text > 已生成 计划< / text > < / view > < view > < strong > { { item . pendingPlanCount } } < / strong > < text > 未操作 < / text > < / view > < / view >
< text class = "candidate-progress-copy" > 已生成 { { item . planCount } } 个入库计划 , { { item . pendingPlanCount } } 个未操作 < template v-if = "item.missingInboundCodeCount" > , 其中 {{ item.missingInboundCodeCount }} 个缺入仓码 < / template > < / text >
< text v-if = "!item.operable" class="candidate-block-reason" > {{ item.blockReason | | ' 当前柜入库计划尚未齐全 ' }} < / text >
< text v-if = "item.destinationCodes?.length" class="candidate-destinations" > 仓码 : {{ item.destinationCodes.join ( ' / ' ) }} < / text >
< / view >
< / template >
< template v-else-if = "phase === 'detail'" >
< template v-if = "selected?.operable" >
< ReceivingSessionSummary :session = "selected" / >
< view class = "lock-notice" > 选择后账号将锁定当前柜 , 完成前不能切换其他柜号 。 < / view >
< view class = "sticky-action" > < button class = "ghost-button" :disabled = "busy" @tap ="cancelDetail" > 返回列表 < / button > < button class = "primary-button" :disabled = "busy " @tap ="prepareContainer" > 选择此柜并开始拍照 < / button > < / view >
< view class = "sticky-action" > < button class = "ghost-button" :disabled = "busy" @tap ="cancelDetail" > 返回列表 < / button > < button class = "primary-button" : disabled = "busy || !selected.allPlansReady || !selected.operable " @tap ="prepareContainer" > 选择此柜并开始拍照 < / button > < / view >
< / template >
< template v-else >
< view class = "readonly-candidate-card" >
< view class = "card-head" > < view > < text class = "container-no" > { { selected ? . containerNo } } < / text > < text class = "candidate-arrival" > 计划到仓 : { { formatPlannedArrival ( selected ? . plannedArrivalAt ) } } < / text > < / view > < text class = "status-tag status-disabled" > 暂不可操作 < / text > < / view >
< view class = "candidate-facts" > < view > < strong > { { selected ? . totalOrderCount } } < / strong > < text > 总订单 < / text > < / view > < view > < strong > { { selected ? . inboundCodeCount } } < / strong > < text > 入仓码 < / text > < / view > < view > < strong > { { selected ? . planCount } } < / strong > < text > 已生成计划 < / text > < / view > < view > < strong > { { selected ? . pendingPlanCount } } < / strong > < text > 未操作 < / text > < / view > < / view >
< text class = "candidate-progress-copy" > 已生成 { { selected ? . planCount } } 个入库计划 , { { selected ? . pendingPlanCount } } 个未操作 < template v-if = "selected?.missingInboundCodeCount" > , 其中 {{ selected.missingInboundCodeCount }} 个缺入仓码 < / template > < / text >
< text class = "candidate-block-reason" > { { selected ? . blockReason || '当前柜入库计划尚未齐全' } } < / text >
< / view >
< view class = "sticky-action one" > < button class = "ghost-button" :disabled = "busy" @tap ="cancelDetail" > 返回列表 < / button > < / view >
< / template >
< / template >
< template v-else-if = "phase === 'sign'" >
@@ -128,6 +141,7 @@
methods : {
statusLabel ( status ) { return ( { WAIT _SIGN : '待签收' , SIGNED : '已签收' , RECEIVING : '收货中' , COMPLETED : '已完成' } ) [ status ] || status } ,
statusTone ( status ) { return status === 'RECEIVING' ? 'status-current' : status === 'COMPLETED' ? 'status-good' : 'status-wait' } ,
formatPlannedArrival ( value ) { const text = String ( value || '' ) . trim ( ) ; return text ? text . replace ( 'T' , ' ' ) . slice ( 0 , 16 ) : '待确认' } ,
destinationLabel ( item ) { return item ? . destinationType === 'FBA' ? ( item . fbaWarehouseCode || 'FBA' ) : ( item ? . destinationWarehouseName || item ? . destinationWarehouseCode || '海外仓' ) } ,
photoStateLabel ( photo ) { if ( photo ? . status === 'uploading' ) return '正在上传…' ; if ( photo ? . status === 'error' ) return photo . errorMessage || ( photo . fileId ? '重拍失败,已保留原图' : '上传失败,请重试' ) ; return photo ? . fileId ? '点击左侧照片可查看大图' : '请拍摄现场照片' } ,
startScanner ( ) { this . stopScanner ( ) ; this . _stopScanner = startScanBroadcast ( { onScan : ( value ) => this . handleHardwareScan ( value ) , onError : ( error ) => { this . error = error ; this . showToast ( '硬件扫码接收器异常,请检查扫描服务' , 'warning' ) } } ) } , stopScanner ( ) { if ( typeof this . _stopScanner === 'function' ) this . _stopScanner ( ) ; this . _stopScanner = null } ,
@@ -137,11 +151,11 @@
async restorePhotoPreviews ( ) { await Promise . all ( receiving . photoSlots . filter ( ( photo ) => photo ? . downloadPath && ! photo . localPath ) . map ( async ( photo ) => { photo . status = 'restoring' ; try { photo . localPath = await downloadCurrentUnloadPhoto ( photo . downloadPath ) ; photo . status = 'success' } catch ( error ) { photo . status = 'error' ; photo . errorMessage = error . message || '照片加载失败' ; this . error = error } } ) ) } ,
async applyCurrentReceiving ( value ) { setCurrentReceiving ( value ) ; await this . restorePhotoPreviews ( ) ; this . setScanTargetForCurrent ( value ) } ,
async bootstrap ( ) { if ( this . loading ) return ; this . loading = true ; this . error = null ; this . detailOpen = false ; try { const current = await getCurrentReceiving ( ) ; await this . applyCurrentReceiving ( current ) ; this . candidateError = null ; this . bootstrapped = true } catch ( error ) { if ( isNoCurrentReceiving ( error ) ) { resetReceivingDraft ( ) ; await this . loadCandidates ( false ) ; this . bootstrapped = true } else this . candidateError = error } finally { this . loading = false } } ,
async recoverCurrentAfterConflict ( error ) { if ( ! isReceivingConflict ( error ) ) return false ; try { const current = await getCurrentReceiving ( ) ; await this . applyCurrentReceiving ( current ) ; this . detailOpen = false ; this . error = null ; this . showToast ( ` 账号已有未完成柜 ${ current . containerNo } ,已返回当前柜 ` , 'warning' ) ; return true } catch ( recoveryError ) { if ( isNoCurrentReceiving ( recoveryError ) ) { this . detailOpen = false ; receiving . selected = null ; await this . loadCandidates ( false ) ; this . error = null ; this . showToast ( '该柜已被其他账号选择 ,列表已刷新' , 'warning' ) ; return true } this . error = error ; return false } } ,
async recoverCurrentAfterConflict ( error , preserveEvidence = false ) { if ( ! isReceivingConflict ( error ) ) return false ; try { const current = await getCurrentReceiving ( ) ; await this . applyCurrentReceiving ( current ) ; this . detailOpen = false ; this . error = preserveEvidence ? error : null ; this . showToast ( ` 账号已有未完成柜 ${ current . containerNo } ,已返回当前柜 ` , 'warning' ) ; return true } catch ( recoveryError ) { if ( isNoCurrentReceiving ( recoveryError ) ) { this . detailOpen = false ; receiving . selected = null ; await this . loadCandidates ( false ) ; this . candidateError = preserveEvidence ? error : null ; this . error = preserveEvidence ? error : null ; this . showToast ( '柜状态已变化 ,列表已刷新' , 'warning' ) ; return true } this . error = error ; return false } } ,
async loadCandidates ( feedback = true ) { if ( this . candidateLoading ) return ; this . candidateLoading = true ; this . candidateError = null ; this . error = null ; try { receiving . candidates = await listReceivingCandidates ( ) ; if ( feedback ) this . showToast ( '刷新待卸柜成功' , 'success' ) } catch ( error ) { this . candidateError = error ; if ( feedback ) { playErrorSound ( ) ; this . showToast ( error . message || '刷新待卸柜失败' , 'error' ) } } finally { this . candidateLoading = false } } ,
async openContainer ( raw ) { const containerNo = String ( raw ? . detail ? . value ? ? raw ? ? '' ) . trim ( ) ; if ( ! containerNo || this . busy ) return ; try { receiving . selected = await this . run ( '查询柜号' , ( ) => getReceivingContainer ( containerNo ) ) ; this . detailOpen = true ; playSuccessSound ( ) } catch ( error ) { await this . recoverCurrentAfterConflict ( error ) } } ,
async openContainer ( candidate ) { const containerNo = String ( candidate ? . containerNo || '' ) . trim ( ) ; if ( ! containerNo || this . busy ) return ; this . error = null ; if ( ! candidate . operable ) { receiving . selected = candidate ; this . detailOpen = true ; return } try { const detail = await this . run ( '查询柜号' , ( ) => getReceivingContainer ( containerNo ) ) ; receiving . selected = { ... candidate , ... detail , allPlansReady : candidate . allPlansReady , operable : candidate . operable , blockReason : candidate . blockReason } ; this . detailOpen = true ; playSuccessSound ( ) } catch ( error ) { await this . recoverCurrentAfterConflict ( error ) } } ,
cancelDetail ( ) { this . detailOpen = false ; receiving . selected = null } ,
async prepareContainer ( ) { if ( ! this . selected ? . containerNo || this . busy ) return ; const key = idempotencyKey ( 'prepare' , { containerNo : this . selected . containerNo } ) ; try { const value = await this . run ( '选择当前柜' , ( ) => prepareReceivingContainer ( this . selected . containerNo , { idempotencyKey : key . value } ) ) ; releaseIdempotencyKey ( key . signature ) ; setCurrentReceiving ( value ) ; this . setScanTargetForCurrent ( value ) ; this . detailOpen = false } catch ( error ) { await this . recoverCurrentAfterConflict ( error ) } } ,
async prepareContainer ( ) { if ( ! this . selected ? . containerNo || ! this . selected ? . allPlansReady || ! this . selected ? . operable || this . busy ) return ; const key = idempotencyKey ( 'prepare' , { containerNo : this . selected . containerNo } ) ; try { const value = await this . run ( '选择当前柜' , ( ) => prepareReceivingContainer ( this . selected . containerNo , { idempotencyKey : key . value } ) ) ; releaseIdempotencyKey ( key . signature ) ; setCurrentReceiving ( value ) ; this . setScanTargetForCurrent ( value ) ; this . detailOpen = false } catch ( error ) { await this . recoverCurrentAfterConflict ( error , true ) } } ,
choosePhoto ( index ) { if ( this . busy ) return ; uni . chooseImage ( { count : 1 , sourceType : [ 'camera' ] , success : ( { tempFilePaths , tempFiles } ) => { const path = tempFilePaths ? . [ 0 ] ; const size = Number ( tempFiles ? . [ 0 ] ? . size || 0 ) ; if ( size > MAX _UNLOAD _PHOTO _BYTES ) { const message = '照片不能超过10MB' ; this . error = Object . assign ( new Error ( message ) , { code : 'A0400' } ) ; this . showToast ( message , 'error' ) ; playErrorSound ( ) ; return } if ( path ) this . replacePhoto ( index , path ) } } ) } ,
async replacePhoto ( index , path ) { const previous = receiving . photoSlots [ index ] ; receiving . photoSlots . splice ( index , 1 , { ... ( previous || { } ) , role : this . photoSteps [ index ] . role , status : 'uploading' , errorMessage : '' } ) ; this . busy = true ; this . error = null ; try { const file = await uploadCurrentUnloadPhoto ( path ) ; receiving . photoSlots . splice ( index , 1 , { ... file , fileId : String ( file . fileId ) , role : this . photoSteps [ index ] . role , localPath : path , uploaded : true , status : 'success' } ) ; this . showToast ( ` ${ this . photoSteps [ index ] . label } 上传成功 ` , 'success' ) } catch ( error ) { receiving . photoSlots . splice ( index , 1 , previous ? . fileId ? { ... previous , status : 'error' , errorMessage : error . message || '重拍失败,已保留原图' } : { role : this . photoSteps [ index ] . role , localPath : path , uploaded : false , status : 'error' , errorMessage : error . message || '上传失败,请重试' } ) ; this . error = error ; this . showToast ( error . message || '照片上传失败' , 'error' ) ; playErrorSound ( ) } finally { this . busy = false } } ,
previewPhoto ( index ) { const path = this . photoSlots [ index ] ? . localPath ; if ( ! path ) return ; uni . previewImage ( { urls : [ path ] , current : path } ) } ,
@@ -187,4 +201,5 @@
. primary - button , . secondary - button , . ghost - button { display : flex ; box - sizing : border - box ; align - items : center ; justify - content : center ; padding - top : 0 ; padding - bottom : 0 ; line - height : normal ; text - align : center }
. sticky - action button { display : flex ; align - items : center ; justify - content : center ; line - height : normal ; text - align : center }
. list - toolbar { height : 28 px ; margin : 0 2 px 7 px ; align - items : center } . list - toolbar button { box - sizing : border - box ; width : auto ; min - width : 48 px ; height : 28 px ; min - height : 28 px ; margin : 0 ; padding : 0 8 px ; border : 1 px solid # bdd1eb ; border - radius : 7 px ; color : # 1668 dc ; font - size : 11 px ; line - height : 26 px ; background : # f7faff } . container - card - compact { padding : 11 px 13 px ; cursor : pointer ; transition : background - color .12 s ease , border - color .12 s ease } . container - card - pressed { border - color : # 93 baf0 ; background : # f4f8ff } . container - card - actions { display : flex ; flex - shrink : 0 ; align - items : flex - end ; flex - direction : column ; gap : 5 px } . container - detail - link { box - sizing : border - box ; min - height : 24 px ; padding : 0 4 px ; color : # 1668 dc ; font - size : 11 px ; font - weight : 700 ; line - height : 24 px } . candidate - facts { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ; margin - top : 9 px ; padding : 8 px 0 ; border - top : 1 px solid # edf1f5 ; border - bottom : 1 px solid # edf1f5 } . candidate - facts view { display : flex ; align - items : center ; flex - direction : column ; border - right : 1 px solid # edf1f5 } . candidate - facts view : last - child { border - right : 0 } . candidate - facts strong { color : # 17324 d ; font - size : 17 px ; line - height : 21 px } . candidate - facts text { margin - top : 2 px ; color : # 6 f8194 ; font - size : 10 px } . candidate - destinations { display : block ; margin - top : 7 px ; overflow : hidden ; color : # 6 f8194 ; font - size : 11 px ; line - height : 17 px ; text - overflow : ellipsis ; white - space : nowrap }
. candidate - arrival { display : block ; margin - top : 3 px ; color : # 718095 ; font - size : 10 px ; line - height : 15 px } . candidate - progress - copy { display : block ; margin - top : 7 px ; color : # 435 a72 ; font - size : 10 px ; line - height : 16 px } . candidate - block - reason { display : block ; margin - top : 6 px ; padding : 6 px 8 px ; border - radius : 7 px ; color : # 73551 b ; font - size : 10 px ; line - height : 16 px ; background : # f0e9dc } . container - card - disabled { border - color : # d9dee5 ; background : # f1f3f5 ; box - shadow : none } . container - card - disabled . container - no , . container - card - disabled . candidate - facts strong { color : # 778391 } . container - card - disabled . container - detail - link { color : # 788797 } . container - card - disabled . candidate - progress - copy , . container - card - disabled . candidate - arrival , . container - card - disabled . candidate - destinations { color : # 87919 d } . status - disabled { color : # 788491 ; background : # e1e5e9 } . readonly - candidate - card { padding : 13 px ; border : 1 px solid # d9dee5 ; border - radius : 13 px ; background : # f1f3f5 } . readonly - candidate - card . candidate - block - reason { margin - top : 9 px }
< / style >