2026-09-17 13:36:08 +08:00
< template >
< view v-if = "user" class="page-shell portal-page transfer-page" >
2026-09-24 08:55:38 +08:00
< PortalHeader : title = "entryMode === 'list' ? '仓储转运测量列表' : '转运测量作业'" :user = "user" fallback -url ="/ pages / module / module ? key = inbound " />
2026-09-17 13:36:08 +08:00
< scroll-view scroll -y class = "transfer-scroll" >< view class = "portal-content transfer-content" >
< view class = "transfer-heading" >
2026-09-24 08:55:38 +08:00
< view >< text class = "transfer-title" >{{ entryMode === 'list' ? '仓储转运测量列表' : '转运测量作业' }}</ text >< text class = "transfer-subtitle" >{{ entryMode === 'list' ? '选择待测转运单后进入作业详情' : '按实际托盘分别记录箱规、重量和箱数' }}</ text ></ view >
2026-09-17 13:36:08 +08:00
< button class = "refresh-button" :disabled = "loading" aria -label =" 刷新待测转运单 " @tap ="loadCandidates" >< uni-icons type = "refresh" color = "#135ee8" size = "18" />< text >{{ loading ? '加载中' : '刷新' }}</ text ></ button >
</ view >
2026-09-24 08:55:38 +08:00
< template v-if = "!selected && entryMode === 'list'" >
2026-09-17 13:36:08 +08:00
< view class = "section-heading" >< text > 待测转运单 </ text >< text >{{ candidates . length }} 单 </ text ></ view >
< view v-if = "!candidates.length && !loading" class="transfer-empty"><text class="empty-title" > 暂无待测量任务 </ text >< text > 新任务进入后可点击右上角刷新 </ text ></ view >
< view class = "candidate-list" >
< button v-for = "item in candidates" :key="item.transferNo" class="transfer-card" :class="{ selected: selected?.transferNo === item.transferNo }" @tap="select(item)" >
2026-09-24 08:55:38 +08:00
< view class = "transfer-card-head" >< text class = "transfer-no" >{{ item . transferNo }}</ text >< text class = "status-pill" >{{ statusLabel ( item . status , item . transferNo ) }}</ text ></ view >
2026-09-22 17:27:56 +08:00
< view class = "task-metrics" >< view >< text > 计划箱数 </ text >< strong >{{ item . packageCount || 0 }}</ strong ></ view >< view >< text >{{ item . destinationType === 'PRIVATE' ? '目的仓' : 'FBA 目的仓' }}</ text >< strong >{{ destinationLabel ( item ) }}</ strong ></ view >< view >< text > 换标 </ text >< strong >{{ item . needRelabel ? '需要' : '不需要' }}</ strong ></ view ></ view >
2026-09-17 13:36:08 +08:00
< text class = "transfer-meta" > 入仓码 {{ item . inboundCode || '—' }}</ text >
</ button >
</ view >
2026-09-24 08:55:38 +08:00
</ template >
< view v-if = "!selected && entryMode === 'work' && !detailLoading" class="transfer-empty"><text class="empty-title" > 暂无进行中的转运测量作业 </ text >< text > 请先从 “ 仓储转运测量列表 ” 选择待测转运单 。 </ text ></ view >
2026-09-17 13:36:08 +08:00
< view v-if = "detailLoading" class="transfer-empty"><text class="empty-title" > 正在读取转运明细 </ text >< text > 请稍候 , 不要重复选择 </ text ></ view >
< template v-if = "selected && !detailLoading" >
2026-09-24 08:55:38 +08:00
< view class = "operation-detail-heading" >< button class = "back-to-list" @tap ="backToCandidates" > ‹ 返回待测列表 </ button >< view >< text class = "operation-detail-title" > 作业详情 </ text >< text class = "operation-detail-subtitle" > 进入后完成当前转运单的测量 、 打托和拍照 </ text ></ view ></ view >
2026-09-23 09:06:31 +08:00
< view v-if = "isInventoryTransfer" class="transfer-success inventory-transfer-panel"><text class="feedback-title">库存转运 · 无需测量</text><text>该单来源于仓库现有库存,不需要重复测量或上传现场照片。请进入托盘作业,按来源托盘执行整托转运、拆托重打,或与相同目的仓合托。</text><button class="transfer-primary" @tap="openPalletOperations" > 进入托盘作业 </ button ></ view >
< template v-if = "!isInventoryTransfer" >
2026-09-17 13:36:08 +08:00
< view class = "order-summary" >
< view >< text class = "summary-eyebrow" > 当前测量任务 </ text >< text class = "summary-no" >{{ selected . transferNo }}</ text ></ view >
< view class = "summary-count" >< strong >{{ totalCartons }}</ strong >< text > 实收箱 </ text ></ view >
</ view >
< view class = "receipt-comparison" >< view >< strong >{{ selected . packageCount || 0 }}</ strong >< text > 客户预报 </ text ></ view >< view >< strong >{{ totalCartons }}</ strong >< text > 当前实收 </ text ></ view >< view : class = "{ difference: cartonDifference !== 0 }" >< strong >{{ cartonDifference > 0 ? `+ ${ cartonDifference } ` : cartonDifference }}</ strong >< text >{{ cartonDifference === 0 ? '无差异' : cartonDifference > 0 ? '超收差异' : '短收差异' }}</ text ></ view ></ view >
< view class = "summary-metrics" >< view >< strong >{{ pallets . length }}</ strong >< text > 托盘 </ text ></ view >< view >< strong >{{ totalVolume }}</ strong >< text > 总体积 m³ </ text ></ view >< view >< strong >{{ totalWeight }}</ strong >< text > 总重量 kg </ text ></ view ></ view >
< view v-for = "(pallet, index) in pallets" :key="pallet.key" class="pallet-card" >
< view class = "pallet-head" >
< view class = "pallet-index" >< text >{{ String ( index + 1 ). padStart ( 2 , '0' ) }}</ text ></ view >
< view class = "pallet-heading-copy" >< text > 托盘 {{ index + 1 }} · {{ pallet . bound ? '已完成' : pallet . generated ? '待绑定' : '待测量' }}</ text >< text >{{ pallet . collapsed && pallet . palletNo ? pallet . palletNo : '本卡数据仅属于当前托盘' }}</ text ></ view >
< button v-if = "pallets.length > 1 && !pallet.generated" class="remove-button" @tap.stop="removePallet(index)" > 移除 </ button >
< button v-if = "pallet.generated" class="collapse-button" :aria-label="pallet.collapsed ? '展开托盘明细' : '折叠托盘明细'" @tap.stop="pallet.collapsed = !pallet.collapsed" >{{ pallet.collapsed ? '⌄' : '⌃' }}</ button >
</ view >
< view v-show = "!pallet.collapsed" >
< view class = "measure-section-head" >< view >< text class = "measure-title" > 本托实测 </ text >< text class = "measure-hint" > 尺寸单位 cm , 重量单位 kg </ text ></ view >< view class = "volume-chip" >< text > 自动体积 </ text >< strong >{{ palletVolume ( pallet ) }} m³ </ strong ></ view ></ view >
< view class = "dimension-grid" >
< label >< text class = "field-label" > 长 </ text >< view class = "input-with-unit" >< input type = "digit" v -model .number =" pallet.lengthCm " :disabled = "pallet.generated" placeholder = "长度" />< text > cm </ text ></ view ></ label >
< label >< text class = "field-label" > 宽 </ text >< view class = "input-with-unit" >< input type = "digit" v -model .number =" pallet.widthCm " :disabled = "pallet.generated" placeholder = "宽度" />< text > cm </ text ></ view ></ label >
< label >< text class = "field-label" > 高 </ text >< view class = "input-with-unit" >< input type = "digit" v -model .number =" pallet.heightCm " :disabled = "pallet.generated" placeholder = "高度" />< text > cm </ text ></ view ></ label >
</ view >
< view class = "measure-grid" >
< label >< text class = "field-label" > 当前托重量 </ text >< view class = "input-with-unit important" >< input type = "digit" v -model .number =" pallet.grossWeightKg " :disabled = "pallet.generated" placeholder = "毛重" />< text > kg </ text ></ view ></ label >
< label >< text class = "field-label" > 当前托箱数 </ text >< view class = "input-with-unit important" >< input type = "number" v -model .number =" pallet.cartonCount " :disabled = "pallet.generated" placeholder = "箱数" />< text > 箱 </ text ></ view ></ label >
</ view >
< button v-if = "!pallet.generated" class="generate-button" :disabled="!palletReady(pallet) || pallet.busy" @tap="generatePallet(pallet)" >{{ pallet.busy ? ' 生成中 …' : ' 生成托盘号 ' }}</ button >
2026-09-22 17:27:56 +08:00
< view v-else class = "generated-card" >< view >< text class = "print-code" >{{ pallet . palletNo }}</ text >< text >{{ destinationLabel ( pallet , selected ) }}</ text ></ view >< button :disabled = "pallet.busy" @tap ="printDraftPallet(pallet)" > 打印标签 </ button ></ view >
2026-09-17 13:36:08 +08:00
< view v-if = "pallet.generated && !pallet.bound" class="bind-panel"><ScanTargetInput :name="`transfer-pallet-${index}`" v-model="pallet.bindCode" label="确认实物托盘码" placeholder="扫描或手输新托盘标签号" :active="activePalletKey === pallet.key" @activate="activePalletKey = pallet.key" @submit="bindPallet(pallet)" /><button :disabled="pallet.busy || !String(pallet.bindCode || '').trim()" @tap="bindPallet(pallet)" >{{ pallet.busy ? ' 确认中 …' : ' 确认绑定 ' }}</ button ></ view >
< view v-if = "pallet.bound" class="bound-state" > 已绑定实物托盘 {{ pallet.palletNo }}</ view >
</ view >
</ view >
< button class = "add-pallet-button" @tap ="addPallet" >< uni-icons type = "plus" color = "#135ee8" size = "18" />< text > 新增一个托盘 </ text ></ button >
< view class = "photo-card" >
< view class = "photo-head" >< view >< text class = "measure-title" > 测量现场照片 </ text >< text class = "measure-hint" > 完成测量并打托前至少拍摄 1 张 </ text ></ view >< text class = "photo-count" >{{ photoFileIds . length }} / 20 张 </ text ></ view >
< view v-if = "photoFileIds.length" class="photo-grid"><view v-for="(fileId, index) in photoFileIds" :key="fileId" class="photo-tile" >
< image class = "photo-image" :src = "photoUrls[fileId]" mode = "aspectFill" @tap ="previewPhoto(fileId)" />
< button class = "photo-remove" : disabled = "uploadingPhoto || submitting" @tap ="removePhoto(index)" > 移除 </ button >
</ view ></ view >
< button v-if = "photoFileIds.length < 20" class="take-photo-button" :disabled="uploadingPhoto || submitting" @tap="takePhoto"><uni-icons type="camera-filled" color="#135ee8" size="20" />< text >{{ uploadingPhoto ? '照片上传中…' : '拍摄测量现场照片' }}</ text ></ button >
</ view >
< view v-if = "validationMessage" class="transfer-validation" role="alert"><text class="validation-title" > 还不能提交 </ text >< text >{{ validationMessage }}</ text ></ view >
< view class = "submit-panel" >< view >< text > 整单测量汇总 </ text >< strong >{{ pallets . length }} 托 · {{ totalCartons }} / {{ selected . packageCount || 0 }} 箱 </ strong ></ view >< button class = "transfer-primary" : disabled = "submitting || !canSubmit" @tap ="submit" >{{ submitting ? ' 提交中 …' : ' 完成测量并打托 ' }}</ button ></ view >
2026-09-23 09:06:31 +08:00
</ template >
2026-09-17 13:36:08 +08:00
</ template >
< view v-if = "error" class="transfer-error" role="alert"><text class="feedback-title">操作失败</text><text>{{ error.message || error }}</text><text v-if="error.requestId" > requestId : {{ error.requestId }}</ text ></ view >
< view v-if = "success" class="transfer-success" role="status"><text class="feedback-title" > 提交成功 </ text >< text > 测量与托盘打托已完成 </ text ></ view >
< view v-if = "completedPallets.length" class="print-card" >
2026-09-22 17:27:56 +08:00
< view class = "photo-head" >< view >< text class = "measure-title" > 新托盘标签 </ text >< text class = "measure-hint" >{{ completedDestinationLabel }} · 逐托打开 PDF 后打印并粘贴 </ text ></ view ></ view >
< view v-for = "pallet in completedPallets" :key="pallet.palletNo" class="print-row"><view><text class="print-code">{{ pallet.palletNo }}</text><text>{{ pallet.cartonCount }} 箱 · {{ destinationLabel(pallet, { destinationType: completedDestinationType }) }}</text></view><button @tap="printPallet(pallet)" > 打印标签 </ button ></ view >
2026-09-17 13:36:08 +08:00
</ view >
</ view > </ scroll -view >
</ view >
</ template >
< script >
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { session , requireSession } from '@/stores/session.js'
2026-09-24 08:55:38 +08:00
import { restoreWarehouseWork , resetWarehouseDomain , updateWarehouseWork , warehouseWork } from '@/stores/warehouse-work.js'
2026-09-17 13:36:08 +08:00
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { bindWarehouseTransferPallet , deleteWarehouseTransferPallet , downloadWarehouseTransferMeasurementPhoto , downloadWarehouseTransferPalletLabel , generateWarehouseTransferPallet , getWarehouseTransferDetail , listWarehouseTransferCandidates , listWarehouseTransferMeasurementPhotos , palletizeWarehouseTransfer , saveWarehouseTransferPalletDraft , uploadWarehouseTransferMeasurementPhoto } from '@/api/warehouse-transfers.js'
const newPallet = () => ({ key : ` ${ Date . now () } - ${ Math . random () } ` , cartonCount : 0 , lengthCm : null , widthCm : null , heightCm : null , grossWeightKg : null , generated : false , bound : false , collapsed : false , palletNo : '' , fbaWarehouseCode : '' , labelDownloadPath : '' , bindCode : '' , busy : false })
const restoredPallet = ( value , index ) => ({
key : `server- ${ value . draftId || value . id || index } ` ,
cartonCount : Number ( value . cartonCount || 0 ), lengthCm : value . lengthCm , widthCm : value . widthCm , heightCm : value . heightCm , grossWeightKg : value . grossWeightKg ,
generated : Boolean ( value . palletNo ), bound : Boolean ( value . boundAt || value . status === 'BOUND' ), collapsed : Boolean ( value . boundAt || value . status === 'BOUND' ),
palletNo : String ( value . palletNo || '' ), fbaWarehouseCode : String ( value . fbaWarehouseCode || '' ), labelDownloadPath : String ( value . labelDownloadPath || '' ), bindCode : '' , busy : false ,
draftId : value . draftId || value . id , version : value . version
})
export default {
components : { PortalHeader , ScanTargetInput },
2026-09-24 08:55:38 +08:00
data () { return { user : session . currentUser , entryMode : 'work' , pendingTaskNo : '' , candidates : [], selected : null , pallets : [ newPallet ()], activePalletKey : '' , photoFileIds : [], photoNames : [], photoUrls : {}, completedPallets : [], completedTransferNo : '' , completedDestination : '' , completedDestinationType : '' , loading : false , detailLoading : false , submitting : false , uploadingPhoto : false , openingLabel : false , error : null , success : false , pendingKey : '' } },
2026-09-17 13:36:08 +08:00
computed : {
2026-09-22 17:27:56 +08:00
completedDestinationLabel () { return this . destinationLabel ({ fbaWarehouseCode : this . completedDestination , destinationType : this . completedDestinationType }) },
2026-09-17 13:36:08 +08:00
totalCartons () { return this . pallets . reduce (( sum , pallet ) => sum + Math . max ( 0 , Number ( pallet . cartonCount ) || 0 ), 0 ) },
cartonDifference () { return this . totalCartons - Number ( this . selected ? . packageCount || 0 ) },
2026-09-23 09:06:31 +08:00
isInventoryTransfer () { return Boolean ( this . selected && this . selected . inboundCode && this . selected . inboundCode !== this . selected . transferNo ) },
2026-09-17 13:36:08 +08:00
totalVolume () { return this . pallets . reduce (( sum , pallet ) => sum + this . rawPalletVolume ( pallet ), 0 ). toFixed ( 3 ) },
totalWeight () { return this . pallets . reduce (( sum , pallet ) => sum + Math . max ( 0 , Number ( pallet . grossWeightKg ) || 0 ), 0 ). toFixed ( 2 ) },
validationMessage () {
2026-09-23 09:06:31 +08:00
if ( this . isInventoryTransfer ) return ''
2026-09-17 13:36:08 +08:00
if ( ! this . selected || ! this . pallets . length ) return '转运单缺少可测量的托盘'
for ( let index = 0 ; index < this . pallets . length ; index += 1 ) {
const pallet = this . pallets [ index ]
if ( ! [ pallet . cartonCount , pallet . lengthCm , pallet . widthCm , pallet . heightCm , pallet . grossWeightKg ]. every (( value ) => Number ( value ) > 0 )) return `托盘 ${ index + 1 } 的箱数、箱规和重量必须大于 0`
if ( ! Number . isInteger ( Number ( pallet . cartonCount ))) return `托盘 ${ index + 1 } 的箱数必须是整数`
}
if ( this . pallets . some (( pallet ) => ! pallet . bound )) return '请先生成并扫描确认绑定全部托盘'
if ( ! this . photoFileIds . length ) return '请至少拍摄 1 张测量现场照片'
return ''
},
canSubmit () { return ! this . validationMessage }
},
watch : {
pallets : { deep : true , handler () { if ( ! this . submitting ) this . pendingKey = '' } }
},
2026-09-24 08:55:38 +08:00
onShow () { if ( requireSession ()) { this . user = session . currentUser ; restoreWarehouseWork ( ` ${ this . user . id } : ${ this . user . warehouseId } ` ); this . startScanner (); this . loadCandidates (). then (() => { const activeNo = String ( warehouseWork . warehouseTransfer . currentNo || '' ); const taskNo = this . pendingTaskNo || activeNo ; if ( this . entryMode === 'work' && taskNo ) { const active = this . candidates . find (( item ) => String ( item . transferNo ) === taskNo ); if ( active ) { this . pendingTaskNo = '' ; void this . select ( active ) } } }) } },
onLoad ( options ) { this . entryMode = options ? . mode === 'list' ? 'list' : 'work' ; this . pendingTaskNo = String ( options ? . task || '' ) },
2026-09-17 13:36:08 +08:00
onHide () { this . stopScanner () },
beforeUnmount () { this . stopScanner () },
2026-09-23 09:06:31 +08:00
methods : {
openPalletOperations () { uni . navigateTo ({ url : '/pages/pallet/pallet' }) },
2026-09-22 17:27:56 +08:00
destinationLabel ( value , fallback = {}) { const code = String ( value ? . fbaWarehouseCode || fallback ? . fbaWarehouseCode || '' ). trim (); if ( code ) return code ; const type = value ? . destinationType || fallback ? . destinationType ; return type === 'PRIVATE' ? '私人仓' : '目的仓待定' },
2026-09-17 13:36:08 +08:00
startScanner () { this . stopScanner (); this . _stopScanner = startScanBroadcast ({ onScan : ( value ) => this . handlePalletScan ( value ), onError : ( error ) => { this . error = error } }) },
stopScanner () { if ( this . _stopScanner ) this . _stopScanner (); this . _stopScanner = null },
handlePalletScan ( value ) { const pallet = this . pallets . find (( item ) => item . key === this . activePalletKey && item . generated && ! item . bound ); if ( ! pallet ) return ; pallet . bindCode = String ( value || '' ). trim (); this . bindPallet ( pallet ) },
2026-09-24 08:55:38 +08:00
statusLabel ( status , transferNo = '' ) { if ( String ( status ) === 'WAIT_PDA_RECEIVING' && String ( warehouseWork . warehouseTransfer . currentNo ) === String ( transferNo )) return '测量中' ; return ({ WAIT_PDA_RECEIVING : '待测量' , APPROVED : '待测量' , PARTIALLY_RECEIVED : '测量中' })[ status ] || '待测量' },
2026-09-17 13:36:08 +08:00
rawPalletVolume ( pallet ) { const values = [ pallet . lengthCm , pallet . widthCm , pallet . heightCm ]. map ( Number ); return values . every (( value ) => value > 0 ) ? values [ 0 ] * values [ 1 ] * values [ 2 ] / 1000000 : 0 },
palletVolume ( pallet ) { return this . rawPalletVolume ( pallet ). toFixed ( 3 ) },
palletReady ( pallet ) { return Number . isInteger ( Number ( pallet . cartonCount )) && [ pallet . cartonCount , pallet . lengthCm , pallet . widthCm , pallet . heightCm , pallet . grossWeightKg ]. every (( value ) => Number ( value ) > 0 ) },
applyPallet ( target , value ) { Object . assign ( target , restoredPallet ( value , this . pallets . indexOf ( target )), { key : target . key , busy : false , collapsed : false }) },
async generatePallet ( pallet ) {
if ( ! this . selected || pallet . busy || pallet . generated || ! this . palletReady ( pallet )) return
pallet . busy = true ; this . error = null
try {
const measured = { palletId : pallet . draftId || null , idempotencyKey : `pda-transfer-draft- ${ this . selected . transferNo } - ${ pallet . key } ` , cartonCount : Number ( pallet . cartonCount ), lengthCm : Number ( pallet . lengthCm ), widthCm : Number ( pallet . widthCm ), heightCm : Number ( pallet . heightCm ), volumeCbm : Number ( this . rawPalletVolume ( pallet ). toFixed ( 6 )), grossWeightKg : Number ( pallet . grossWeightKg ) }
const draftDetail = await saveWarehouseTransferPalletDraft ( this . selected . transferNo , measured )
const draft = pallet . draftId ? ( draftDetail . pallets || []). find (( item ) => String ( item . id ) === String ( pallet . draftId )) : [...( draftDetail . pallets || [])]. sort (( a , b ) => Number ( b . sequenceNo || 0 ) - Number ( a . sequenceNo || 0 ))[ 0 ]
if ( ! draft ? . id ) throw new Error ( '服务端未返回已保存的托盘草稿' )
const generatedDetail = await generateWarehouseTransferPallet ( this . selected . transferNo , draft . id , { idempotencyKey : `pda-transfer-generate- ${ this . selected . transferNo } - ${ draft . id } ` })
const generated = ( generatedDetail . pallets || []). find (( item ) => String ( item . id ) === String ( draft . id ))
if ( ! generated ? . palletNo ) throw new Error ( '服务端未返回新托盘号' )
this . applyPallet ( pallet , generated ); this . pendingKey = ''
} catch ( error ) { this . error = error ; pallet . busy = false }
},
async bindPallet ( pallet ) {
const code = String ( pallet . bindCode || '' ). trim ()
if ( ! this . selected || pallet . busy || ! pallet . generated || pallet . bound || ! code ) return
pallet . busy = true ; this . error = null
try { const detail = await bindWarehouseTransferPallet ( this . selected . transferNo , pallet . draftId , { palletCode : code , idempotencyKey : `pda-transfer-bind- ${ this . selected . transferNo } - ${ pallet . draftId } - ${ code } ` }); const value = ( detail . pallets || []). find (( item ) => String ( item . id ) === String ( pallet . draftId )); if ( ! value ? . boundAt && value ? . status !== 'BOUND' ) throw new Error ( '服务端未确认托盘绑定' ); this . applyPallet ( pallet , value ); pallet . collapsed = true ; this . pendingKey = '' }
catch ( error ) { this . error = error ; pallet . busy = false }
},
async printDraftPallet ( pallet ) { if ( ! pallet . labelDownloadPath ) { this . error = new Error ( '服务端未返回托盘标签下载地址' ); return }; await this . openLabelPdf (() => downloadWarehouseTransferPalletLabel ( pallet . labelDownloadPath )) },
2026-09-24 08:55:38 +08:00
async loadCandidates () { this . loading = true ; this . error = null ; try { const activeNo = String ( warehouseWork . warehouseTransfer . currentNo || '' ); this . candidates = ( await listWarehouseTransferCandidates ()). map (( item ) => String ( item . transferNo ) === activeNo && item . status === 'WAIT_PDA_RECEIVING' ? { ... item , status : 'PARTIALLY_RECEIVED' } : item ) } catch ( e ) { this . error = e } finally { this . loading = false } },
2026-09-17 13:36:08 +08:00
async select ( item ) {
if ( this . detailLoading || this . submitting ) return
2026-09-24 08:55:38 +08:00
const requestedNo = String ( item ? . transferNo || '' )
const activeNo = String ( warehouseWork . warehouseTransfer . currentNo || '' )
if ( ! activeNo ) {
const result = await new Promise (( resolve ) => uni . showModal ({ title : '开始测量' , content : `确认开始测量转运单 ${ requestedNo } 吗?开始后本设备需先完成该单,才能选择下一单。` , confirmText : '确认开始' , cancelText : '取消' , success : resolve , fail : () => resolve ({ confirm : false }) }))
if ( ! result . confirm ) return
}
if ( activeNo && activeNo !== requestedNo ) {
const result = await new Promise (( resolve ) => uni . showModal ({ title : '存在未完结的测量单' , content : `转运单 ${ activeNo } 尚未测量完成,是否继续进入?` , confirmText : '去继续测量' , cancelText : '取消' , success : resolve , fail : () => resolve ({ confirm : false }) }))
if ( ! result . confirm ) return
item = this . candidates . find (( candidate ) => String ( candidate . transferNo ) === activeNo ) || item
}
updateWarehouseWork ( 'warehouseTransfer' , { currentNo : String ( item . transferNo ) })
this . candidates = this . candidates . map (( candidate ) => String ( candidate . transferNo ) === String ( item . transferNo ) ? { ... candidate , status : 'PARTIALLY_RECEIVED' } : candidate )
if ( this . entryMode === 'list' ) { uni . navigateTo ({ url : `/pages/warehouse-transfer/warehouse-transfer?mode=work&task= ${ encodeURIComponent ( String ( item . transferNo )) } ` }); return }
2026-09-22 17:27:56 +08:00
this . detailLoading = true ; this . error = null ; this . success = false ; this . completedPallets = []; this . completedTransferNo = '' ; this . completedDestination = '' ; this . completedDestinationType = ''
2026-09-17 13:36:08 +08:00
try { this . selected = await getWarehouseTransferDetail ( item . transferNo ); this . pendingKey = '' ; this . pallets = this . selected . pallets ? . length ? this . selected . pallets . map ( restoredPallet ) : [ newPallet ()]; await this . restorePhotos ( item . transferNo , this . selected . measurementPhotos ) }
catch ( e ) { this . selected = null ; this . error = e }
finally { this . detailLoading = false }
},
2026-09-24 08:55:38 +08:00
backToCandidates () { if ( this . submitting || this . detailLoading ) return ; this . entryMode = 'list' ; this . selected = null ; this . error = null ; this . pallets = [ newPallet ()]; this . photoFileIds = []; this . photoUrls = {}; this . photoNames = [] },
2026-09-17 13:36:08 +08:00
addPallet () { this . pendingKey = '' ; this . pallets . push ( newPallet ()) },
async removePallet ( index ) { const pallet = this . pallets [ index ]; if ( ! pallet || pallet . generated || pallet . busy ) return ; pallet . busy = true ; try { if ( pallet . draftId ) await deleteWarehouseTransferPallet ( this . selected . transferNo , pallet . draftId ); this . pallets . splice ( index , 1 ); this . pendingKey = '' } catch ( error ) { pallet . busy = false ; this . error = error } },
async restorePhotos ( transferNo , knownPhotos ) {
this . photoFileIds = []; this . photoUrls = {}; this . photoNames = []
try {
const photos = Array . isArray ( knownPhotos ) ? knownPhotos : await listWarehouseTransferMeasurementPhotos ( transferNo )
this . photoFileIds = photos . map (( photo ) => String ( photo . fileId )); this . photoNames = photos . map (( photo ) => photo . fileName || '测量现场照片' )
await Promise . all ( photos . map ( async ( photo ) => { try { this . photoUrls [ String ( photo . fileId )] = await downloadWarehouseTransferMeasurementPhoto ( transferNo , photo . fileId ) } catch ( _ ) { /* 单张恢复失败不阻断补拍 */ } }))
} catch ( error ) { this . error = error }
},
takePhoto () {
if ( ! this . selected || this . uploadingPhoto || this . submitting || this . photoFileIds . length >= 20 ) return
const transferNo = this . selected . transferNo
uni . chooseImage ({ count : Math . min ( 9 , 20 - this . photoFileIds . length ), sourceType : [ 'camera' , 'album' ], success : async ({ tempFilePaths , tempFiles }) => {
const paths = ( tempFilePaths || []). length ? tempFilePaths : ( tempFiles || []). map (( file ) => file . path || file . tempFilePath ). filter ( Boolean )
if ( this . selected ? . transferNo !== transferNo ) { this . error = new Error ( '所选转运单已切换,请重新拍摄' ); return }
if ( ! paths . length ) { this . error = new Error ( '未获取到可上传的照片路径,请重新拍摄' ); return }
this . uploadingPhoto = true ; this . error = null
try {
for ( const [ index , path ] of paths . entries ()) {
if ( Number ( tempFiles ? .[ index ] ? . size || 0 ) > 10 * 1024 * 1024 ) throw new Error ( '照片不能超过 10MB' )
const file = await uploadWarehouseTransferMeasurementPhoto ( transferNo , path )
if ( this . selected ? . transferNo !== transferNo ) throw new Error ( '所选转运单已切换,请重新拍摄' )
const fileId = String ( file . fileId ); this . photoUrls [ fileId ] = path ; this . photoFileIds . push ( fileId ); this . photoNames . push ( file . fileName || '测量现场照片' )
}
this . pendingKey = ''
} catch ( error ) { this . error = error } finally { this . uploadingPhoto = false }
}, fail : ( cause ) => { if ( ! /cancel/i . test ( String ( cause ? . errMsg || '' ))) this . error = new Error ( cause ? . errMsg || '选择照片失败,请重试' ) } })
},
previewPhoto ( fileId ) { const urls = this . photoFileIds . map (( id ) => this . photoUrls [ id ]). filter ( Boolean ); if ( this . photoUrls [ fileId ]) uni . previewImage ({ current : this . photoUrls [ fileId ], urls }) },
removePhoto ( index ) { if ( this . uploadingPhoto || this . submitting ) return ; const [ fileId ] = this . photoFileIds . splice ( index , 1 ); this . photoNames . splice ( index , 1 ); if ( fileId ) delete this . photoUrls [ fileId ]; this . pendingKey = '' },
async submit () {
if ( ! this . selected || this . submitting ) return
if ( this . validationMessage ) { this . error = new Error ( this . validationMessage ); return }
this . submitting = true ; this . error = null
try {
if ( ! this . pendingKey ) this . pendingKey = `pda-transfer- ${ this . selected . transferNo } - ${ Date . now () } - ${ Math . random (). toString ( 36 ). slice ( 2 , 10 ) } `
const payload = { idempotencyKey : this . pendingKey , photoFileIds : [... this . photoFileIds ] }
2026-09-24 08:55:38 +08:00
const transferNo = this . selected . transferNo ; const result = await palletizeWarehouseTransfer ( transferNo , payload ); this . completedTransferNo = transferNo ; this . completedDestination = result ? . fbaWarehouseCode || this . selected . fbaWarehouseCode || '' ; this . completedDestinationType = result ? . destinationType || this . selected . destinationType || '' ; this . completedPallets = ( result ? . pallets || []). filter (( pallet ) => pallet ? . palletNo ); this . success = true ; resetWarehouseDomain ( 'warehouseTransfer' ); this . selected = null ; this . pendingKey = '' ; this . pallets = [ newPallet ()]; this . photoFileIds = []; this . photoUrls = {}; this . photoNames = []; await this . loadCandidates ()
2026-09-17 13:36:08 +08:00
} catch ( e ) { this . error = e } finally { this . submitting = false }
},
async printPallet ( pallet ) { if ( ! pallet ? . labelDownloadPath ) { this . error = new Error ( '服务端未返回托盘标签下载地址,请刷新后重试' ); return }; await this . openLabelPdf (() => downloadWarehouseTransferPalletLabel ( pallet . labelDownloadPath )) },
async openLabelPdf ( download ) {
if ( this . openingLabel ) return
this . openingLabel = true ; this . error = null
let h5View = null
// #ifdef H5
h5View = window . open ( '' , '_blank' ); if ( ! h5View ) { this . error = new Error ( '请允许打开托盘标签 PDF 窗口' ); this . openingLabel = false ; return }
// #endif
try {
const filePath = await download ()
// #ifdef H5
h5View . location . href = filePath
// #endif
// #ifndef H5
await new Promise (( resolve , reject ) => uni . openDocument ({ filePath , fileType : 'pdf' , showMenu : true , success : resolve , fail : ( cause ) => reject ( new Error ( cause ? . errMsg || '托盘标签 PDF 打开失败' )) }))
// #endif
} catch ( error ) { if ( h5View ) h5View . close (); this . error = error } finally { this . openingLabel = false }
}
}
}
</ script >
< style scoped >
. transfer - page { height : 100 vh ; overflow : hidden ; background : # f2f5f9 }. transfer - scroll { height : calc ( 100 vh - 48 px - var ( -- status - bar - height , 0 px ))}. transfer - content { padding - bottom : 52 px }. transfer - heading { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 12 px ; margin : 4 px 2 px 17 px }. transfer - title ,. transfer - subtitle ,. transfer - no ,. transfer - meta ,. summary - eyebrow ,. summary - no ,. pallet - heading - copy text ,. measure - title ,. measure - hint ,. sku - copy text ,. validation - title ,. transfer - validation > text ,. feedback - title ,. transfer - error > text ,. transfer - success > text ,. empty - title ,. transfer - empty > text { display : block }. transfer - title { color : # 14263 d ; font - size : 22 px ; font - weight : 850 ; letter - spacing : .2 px }. transfer - subtitle { margin - top : 4 px ; color : # 718095 ; font - size : 11 px ; line - height : 17 px }. refresh - button { display : flex ; align - items : center ; justify - content : center ; gap : 4 px ; width : auto ; height : 34 px ; margin : 0 ; padding : 0 11 px ; border : 1 px solid # b8d1f5 ; border - radius : 17 px ; color : # 135 ee8 ; font - size : 10 px ; line - height : 34 px ; background : # f8fbff }. refresh - button [ disabled ]{ opacity : .55 }. section - heading { display : flex ; align - items : center ; justify - content : space - between ; margin : 0 2 px 8 px ; color : # 243951 ; font - size : 12 px ; font - weight : 800 }. section - heading text : last - child { color : # 8290 a2 ; font - size : 10 px ; font - weight : 600 }. candidate - list { display : flex ; flex - direction : column ; gap : 9 px ; margin - bottom : 16 px }. transfer - card { box - sizing : border - box ; width : 100 % ; margin : 0 ; padding : 13 px ; border : 1 px solid # dce5f0 ; border - radius : 13 px ; text - align : left ; background : # fff ; box - shadow : 0 4 px 15 px rgba ( 32 , 67 , 111 , .055 )}. transfer - card . selected { border - color : # 6 da3f1 ; background : # f7faff ; box - shadow : 0 5 px 18 px rgba ( 19 , 94 , 232 , .11 )}. transfer - card - head { display : flex ; align - items : center ; justify - content : space - between ; gap : 10 px }. transfer - no { overflow : hidden ; color : # 18324 f ; font - size : 14 px ; font - weight : 850 ; text - overflow : ellipsis ; white - space : nowrap }. status - pill { flex - shrink : 0 ; padding : 4 px 7 px ; border - radius : 6 px ; color : # 135 ee8 ; font - size : 9 px ; font - weight : 750 ; background : # eaf2ff }. task - metrics { display : grid ; grid - template - columns : .75 fr 1.25 fr .75 fr ; gap : 6 px ; margin - top : 11 px }. task - metrics view { min - width : 0 ; padding : 7 px 8 px ; border - radius : 8 px ; background : # f4f7fa }. task - metrics text { display : block ; color : # 8492 a4 ; font - size : 8 px }. task - metrics strong { display : block ; overflow : hidden ; margin - top : 2 px ; color : # 2 c4058 ; font - size : 11 px ; text - overflow : ellipsis ; white - space : nowrap }. transfer - meta { margin - top : 8 px ; color : # 7 b899b ; font - size : 9 px }. transfer - empty { margin - bottom : 12 px ; padding : 26 px 14 px ; border : 1 px dashed # ccd7e4 ; border - radius : 13 px ; color : # 8996 a6 ; font - size : 10 px ; line - height : 18 px ; text - align : center ; background : rgba ( 255 , 255 , 255 , .65 )}. empty - title { color : # 56677 b ; font - size : 12 px ; font - weight : 800 }. order - summary { display : flex ; align - items : center ; justify - content : space - between ; gap : 12 px ; padding : 14 px 15 px ; border - radius : 14 px 14 px 0 0 ; color : # fff ; background : linear - gradient ( 135 deg , # 123 d73 , # 135 ee8 )}. summary - eyebrow { font - size : 9 px ; opacity : .72 }. summary - no { margin - top : 3 px ; font - size : 15 px ; font - weight : 850 }. summary - count { display : flex ; align - items : baseline ; gap : 3 px ; flex - shrink : 0 }. summary - count strong { font - size : 24 px }. summary - count text { font - size : 10 px ; opacity : .8 }. summary - metrics { display : grid ; grid - template - columns : repeat ( 3 , 1 fr ); margin - bottom : 12 px ; padding : 11 px 8 px ; border - radius : 0 0 14 px 14 px ; background : # fff ; box - shadow : 0 5 px 17 px rgba ( 32 , 67 , 111 , .07 )}. summary - metrics view { position : relative ; text - align : center }. summary - metrics view + view : before { position : absolute ; top : 3 px ; bottom : 3 px ; left : 0 ; width : 1 px ; background : # e7edf4 ; content : '' }. summary - metrics strong { display : block ; color : # 17314 f ; font - size : 14 px }. summary - metrics text { display : block ; margin - top : 2 px ; color : # 8390 a1 ; font - size : 8 px }. pallet - card { box - sizing : border - box ; margin - bottom : 12 px ; padding : 14 px ; border : 1 px solid # d9e3ee ; border - radius : 15 px ; background : # fff ; box - shadow : 0 5 px 18 px rgba ( 32 , 67 , 111 , .065 )}. pallet - head { display : flex ; align - items : center ; gap : 9 px ; margin :- 2 px - 2 px 14 px ; padding - bottom : 11 px ; border - bottom : 1 px solid # edf1f5 }. pallet - index { display : flex ; align - items : center ; justify - content : center ; width : 34 px ; height : 34 px ; border - radius : 10 px ; color : # fff ; font - size : 13 px ; font - weight : 850 ; background : # 135 ee8 ; box - shadow : 0 4 px 10 px r
. photo - card { box - sizing : border - box ; margin - bottom : 11 px ; padding : 14 px ; border : 1 px solid # d9e3ee ; border - radius : 13 px ; background : # fff ; box - shadow : 0 5 px 18 px rgba ( 32 , 67 , 111 , .06 )}
. photo - head { display : flex ; align - items : center ; justify - content : space - between ; gap : 10 px ; margin - bottom : 9 px }
. photo - count { flex - shrink : 0 ; padding : 5 px 8 px ; border - radius : 7 px ; color : # 135 ee8 ; font - size : 9 px ; font - weight : 800 ; background : # eaf2ff }
. photo - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 9 px ; margin : 11 px 0 }
. photo - tile { position : relative ; overflow : hidden ; border - radius : 10 px ; background : # eef2f6 }
. photo - image { display : block ; width : 100 % ; height : 112 px }
. photo - remove { position : absolute ; right : 5 px ; bottom : 5 px ; width : auto ; height : 26 px ; margin : 0 ; padding : 0 8 px ; border - radius : 6 px ; color : # fff ; font - size : 9 px ; line - height : 26 px ; background : rgba ( 152 , 35 , 35 , .88 )}
. take - photo - button { display : flex ; align - items : center ; justify - content : center ; gap : 6 px ; box - sizing : border - box ; width : 100 % ; height : 42 px ; margin : 9 px 0 0 ; border : 1 px solid # 9 fc0ef ; border - radius : 9 px ; color : # 135 ee8 ; font - size : 10 px ; font - weight : 800 ; background : # f8fbff }
. take - photo - button [ disabled ]{ color : # 9 aa7b5 ; background : # e7ecf2 ; opacity : 1 }
. print - card { box - sizing : border - box ; margin - bottom : 11 px ; padding : 14 px ; border : 1 px solid # b9d3f4 ; border - radius : 13 px ; background : # f8fbff }. print - row button { width : auto ; margin : 0 ; padding : 0 10 px ; color : # 135 ee8 ; border : 1 px solid # 9 fc0ef ; border - radius : 8 px ; background : # fff }. print - row { display : flex ; align - items : center ; justify - content : space - between ; padding : 10 px 0 ; border - top : 1 px solid # dce8f5 ; color : # 728196 ; font - size : 9 px }. print - code { display : block ; color : # 17314 f ; font - size : 13 px ; font - weight : 850 }
. generate - button { width : 100 % ; margin - top : 12 px ; color : # fff ; background : # 135 ee8 }. generated - card { display : flex ; align - items : center ; justify - content : space - between ; margin - top : 12 px ; padding : 10 px ; border - radius : 9 px ; background : # edf5ff }. generated - card button ,. bind - panel > button { width : auto ; margin : 0 ; color : # 135 ee8 ; background : # fff }. bind - panel { margin - top : 10 px }. bound - state { margin - top : 10 px ; padding : 9 px ; border - radius : 8 px ; color : # 08705 f ; font - weight : 800 ; background : # dff5ed }
2026-09-24 08:55:38 +08:00
. operation - detail - heading { display : flex ; align - items : flex - start ; gap : 10 px ; margin : 0 2 px 12 px ; padding : 2 px 0 }. back - to - list { width : auto ; height : 30 px ; margin : 0 ; padding : 0 9 px ; border : 1 px solid # c8d8ec ; border - radius : 8 px ; color : # 135 ee8 ; font - size : 10 px ; line - height : 30 px ; background : # f8fbff }. operation - detail - heading > view { min - width : 0 }. operation - detail - title { display : block ; color : # 243951 ; font - size : 15 px ; font - weight : 850 }. operation - detail - subtitle { display : block ; margin - top : 3 px ; color : # 8794 a4 ; font - size : 9 px ; line - height : 14 px }
2026-09-17 13:36:08 +08:00
. collapse - button { width : 30 px ; height : 28 px ; margin : 0 ; padding : 0 ; color : # 135 ee8 ; font - size : 16 px ; line - height : 28 px ; background : # edf5ff }
. receipt - comparison { display : grid ; grid - template - columns : repeat ( 3 , 1 fr ); gap : 7 px ; margin - bottom : 9 px }. receipt - comparison view { padding : 9 px ; border - radius : 9 px ; text - align : center ; background : # fff }. receipt - comparison strong ,. receipt - comparison text { display : block }. receipt - comparison strong { color : # 17314 f ; font - size : 15 px }. receipt - comparison text { margin - top : 2 px ; color : # 8390 a1 ; font - size : 8 px }. receipt - comparison . difference { border : 1 px solid # e3a044 ; background : # fff4df }. receipt - comparison . difference strong ,. receipt - comparison . difference text { color : # a35b0c ; font - weight : 800 }
</ style >